@open-mercato/cli 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 (217) hide show
  1. package/.turbo/turbo-build.log +8 -2
  2. package/AGENTS.md +2 -0
  3. package/dist/agentic/guides/module-facts.json +7612 -848
  4. package/dist/agentic/guides/module-facts.v2.json +7612 -848
  5. package/dist/agentic/guides/modules/agent_orchestrator/active-extension-bindings.md +1 -1
  6. package/dist/agentic/guides/modules/agent_orchestrator/index.md +1 -1
  7. package/dist/agentic/guides/modules/ai_assistant/encryption.md +13 -0
  8. package/dist/agentic/guides/modules/ai_assistant/exact-override-targets.md +3 -0
  9. package/dist/agentic/guides/modules/ai_assistant/index.md +4 -3
  10. package/dist/agentic/guides/modules/api_docs/index.md +1 -1
  11. package/dist/agentic/guides/modules/api_keys/domain-commands.md +12 -0
  12. package/dist/agentic/guides/modules/api_keys/index.md +3 -2
  13. package/dist/agentic/guides/modules/attachments/index.md +1 -1
  14. package/dist/agentic/guides/modules/audit_logs/index.md +1 -1
  15. package/dist/agentic/guides/modules/auth/index.md +1 -1
  16. package/dist/agentic/guides/modules/availability/acl-features.md +13 -0
  17. package/dist/agentic/guides/modules/availability/backend-pages.md +14 -0
  18. package/dist/agentic/guides/modules/availability/di-registrations-rich.md +11 -0
  19. package/dist/agentic/guides/modules/availability/domain-commands.md +13 -0
  20. package/dist/agentic/guides/modules/availability/entities.md +11 -0
  21. package/dist/agentic/guides/modules/availability/events.md +13 -0
  22. package/dist/agentic/guides/modules/availability/exact-override-targets.md +19 -0
  23. package/dist/agentic/guides/modules/availability/host-extension-points.md +10 -0
  24. package/dist/agentic/guides/modules/availability/index.md +22 -0
  25. package/dist/agentic/guides/modules/availability/owned-contract-module-metadata.md +11 -0
  26. package/dist/agentic/guides/modules/availability/setup.md +11 -0
  27. package/dist/agentic/guides/modules/availability/umes-hosts.md +14 -0
  28. package/dist/agentic/guides/modules/business_rules/index.md +1 -1
  29. package/dist/agentic/guides/modules/catalog/active-extension-bindings.md +1 -1
  30. package/dist/agentic/guides/modules/catalog/di-registrations-rich.md +1 -0
  31. package/dist/agentic/guides/modules/catalog/exact-override-targets.md +3 -0
  32. package/dist/agentic/guides/modules/catalog/index.md +1 -1
  33. package/dist/agentic/guides/modules/catalog/workers.md +2 -0
  34. package/dist/agentic/guides/modules/channel_apns/index.md +1 -1
  35. package/dist/agentic/guides/modules/channel_discord/index.md +1 -1
  36. package/dist/agentic/guides/modules/channel_expo/index.md +1 -1
  37. package/dist/agentic/guides/modules/channel_fcm/index.md +1 -1
  38. package/dist/agentic/guides/modules/channel_gmail/index.md +1 -1
  39. package/dist/agentic/guides/modules/channel_imap/index.md +1 -1
  40. package/dist/agentic/guides/modules/channel_resend/index.md +1 -1
  41. package/dist/agentic/guides/modules/channel_ses/index.md +1 -1
  42. package/dist/agentic/guides/modules/checkout/index.md +1 -1
  43. package/dist/agentic/guides/modules/communication_channels/acl-features.md +2 -1
  44. package/dist/agentic/guides/modules/communication_channels/contribution-resolutions.md +1 -0
  45. package/dist/agentic/guides/modules/communication_channels/di-registrations-rich.md +11 -10
  46. package/dist/agentic/guides/modules/communication_channels/domain-commands.md +1 -0
  47. package/dist/agentic/guides/modules/communication_channels/events.md +2 -1
  48. package/dist/agentic/guides/modules/communication_channels/exact-override-targets.md +12 -10
  49. package/dist/agentic/guides/modules/communication_channels/incoming-installed-contributions.md +1 -0
  50. package/dist/agentic/guides/modules/communication_channels/index.md +2 -2
  51. package/dist/agentic/guides/modules/communication_channels/umes-contributions.md +1 -0
  52. package/dist/agentic/guides/modules/communication_channels/umes-hosts.md +1 -0
  53. package/dist/agentic/guides/modules/configs/index.md +1 -1
  54. package/dist/agentic/guides/modules/content/index.md +1 -1
  55. package/dist/agentic/guides/modules/currencies/index.md +1 -1
  56. package/dist/agentic/guides/modules/customer_accounts/index.md +1 -1
  57. package/dist/agentic/guides/modules/customer_groups/acl-features.md +16 -0
  58. package/dist/agentic/guides/modules/customer_groups/active-extension-bindings.md +13 -0
  59. package/dist/agentic/guides/modules/customer_groups/backend-pages.md +14 -0
  60. package/dist/agentic/guides/modules/customer_groups/cli-commands.md +11 -0
  61. package/dist/agentic/guides/modules/customer_groups/contribution-resolutions.md +13 -0
  62. package/dist/agentic/guides/modules/customer_groups/di-registrations-rich.md +13 -0
  63. package/dist/agentic/guides/modules/customer_groups/di-service-tokens.md +11 -0
  64. package/dist/agentic/guides/modules/customer_groups/domain-commands.md +11 -0
  65. package/dist/agentic/guides/modules/customer_groups/entities.md +13 -0
  66. package/dist/agentic/guides/modules/customer_groups/events.md +17 -0
  67. package/dist/agentic/guides/modules/customer_groups/exact-override-targets.md +27 -0
  68. package/dist/agentic/guides/modules/customer_groups/index.md +26 -0
  69. package/dist/agentic/guides/modules/customer_groups/owned-contract-module-metadata.md +11 -0
  70. package/dist/agentic/guides/modules/customer_groups/setup.md +11 -0
  71. package/dist/agentic/guides/modules/customer_groups/umes-contributions.md +13 -0
  72. package/dist/agentic/guides/modules/customer_groups/umes-hosts.md +21 -0
  73. package/dist/agentic/guides/modules/customers/acl-features.md +2 -1
  74. package/dist/agentic/guides/modules/customers/active-extension-bindings.md +8 -7
  75. package/dist/agentic/guides/modules/customers/contribution-resolutions.md +1 -0
  76. package/dist/agentic/guides/modules/customers/domain-commands.md +1 -0
  77. package/dist/agentic/guides/modules/customers/entities.md +1 -0
  78. package/dist/agentic/guides/modules/customers/events.md +2 -1
  79. package/dist/agentic/guides/modules/customers/exact-override-targets.md +2 -0
  80. package/dist/agentic/guides/modules/customers/index.md +3 -3
  81. package/dist/agentic/guides/modules/customers/umes-contributions.md +1 -0
  82. package/dist/agentic/guides/modules/customers/umes-hosts.md +3 -0
  83. package/dist/agentic/guides/modules/dashboards/index.md +1 -1
  84. package/dist/agentic/guides/modules/data_sync/index.md +1 -1
  85. package/dist/agentic/guides/modules/design_system/index.md +1 -1
  86. package/dist/agentic/guides/modules/devices/index.md +1 -1
  87. package/dist/agentic/guides/modules/dictionaries/index.md +1 -1
  88. package/dist/agentic/guides/modules/directory/active-extension-bindings.md +1 -1
  89. package/dist/agentic/guides/modules/directory/index.md +1 -1
  90. package/dist/agentic/guides/modules/documents/index.md +1 -1
  91. package/dist/agentic/guides/modules/entities/active-extension-bindings.md +1 -1
  92. package/dist/agentic/guides/modules/entities/index.md +1 -1
  93. package/dist/agentic/guides/modules/eudr/index.md +1 -1
  94. package/dist/agentic/guides/modules/events/index.md +1 -1
  95. package/dist/agentic/guides/modules/feature_toggles/index.md +1 -1
  96. package/dist/agentic/guides/modules/forms/acl-features.md +16 -0
  97. package/dist/agentic/guides/modules/forms/active-extension-bindings.md +14 -0
  98. package/dist/agentic/guides/modules/forms/backend-pages.md +17 -0
  99. package/dist/agentic/guides/modules/forms/contribution-resolutions.md +21 -0
  100. package/dist/agentic/guides/modules/forms/di-registrations-rich.md +27 -0
  101. package/dist/agentic/guides/modules/forms/di-service-tokens.md +19 -0
  102. package/dist/agentic/guides/modules/forms/domain-commands.md +30 -0
  103. package/dist/agentic/guides/modules/forms/encryption.md +11 -0
  104. package/dist/agentic/guides/modules/forms/entities.md +21 -0
  105. package/dist/agentic/guides/modules/forms/events.md +28 -0
  106. package/dist/agentic/guides/modules/forms/exact-override-targets.md +59 -0
  107. package/dist/agentic/guides/modules/forms/frontend-pages.md +16 -0
  108. package/dist/agentic/guides/modules/forms/index.md +28 -0
  109. package/dist/agentic/guides/modules/forms/owned-contract-module-metadata.md +11 -0
  110. package/dist/agentic/guides/modules/forms/setup.md +11 -0
  111. package/dist/agentic/guides/modules/forms/umes-contributions.md +21 -0
  112. package/dist/agentic/guides/modules/forms/umes-hosts.md +43 -0
  113. package/dist/agentic/guides/modules/forms/workers.md +12 -0
  114. package/dist/agentic/guides/modules/gateway_stripe/index.md +1 -1
  115. package/dist/agentic/guides/modules/generators/index.md +1 -1
  116. package/dist/agentic/guides/modules/inbox_ops/active-extension-bindings.md +1 -1
  117. package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
  118. package/dist/agentic/guides/modules/integrations/di-registrations-rich.md +8 -8
  119. package/dist/agentic/guides/modules/integrations/di-service-tokens.md +4 -4
  120. package/dist/agentic/guides/modules/integrations/exact-override-targets.md +8 -8
  121. package/dist/agentic/guides/modules/integrations/index.md +1 -1
  122. package/dist/agentic/guides/modules/messages/index.md +1 -1
  123. package/dist/agentic/guides/modules/notifications/index.md +1 -1
  124. package/dist/agentic/guides/modules/onboarding/index.md +1 -1
  125. package/dist/agentic/guides/modules/payment_gateways/index.md +1 -1
  126. package/dist/agentic/guides/modules/perspectives/index.md +1 -1
  127. package/dist/agentic/guides/modules/phone_calls/index.md +1 -1
  128. package/dist/agentic/guides/modules/planner/active-extension-bindings.md +1 -1
  129. package/dist/agentic/guides/modules/planner/index.md +1 -1
  130. package/dist/agentic/guides/modules/portal/index.md +1 -1
  131. package/dist/agentic/guides/modules/progress/index.md +1 -1
  132. package/dist/agentic/guides/modules/push_notifications/index.md +1 -1
  133. package/dist/agentic/guides/modules/query_index/index.md +1 -1
  134. package/dist/agentic/guides/modules/record_locks/index.md +1 -1
  135. package/dist/agentic/guides/modules/resources/active-extension-bindings.md +1 -1
  136. package/dist/agentic/guides/modules/resources/index.md +1 -1
  137. package/dist/agentic/guides/modules/sales/active-extension-bindings.md +1 -1
  138. package/dist/agentic/guides/modules/sales/exact-override-targets.md +4 -4
  139. package/dist/agentic/guides/modules/sales/index.md +1 -1
  140. package/dist/agentic/guides/modules/sales/setup.md +1 -1
  141. package/dist/agentic/guides/modules/scheduler/index.md +1 -1
  142. package/dist/agentic/guides/modules/search/index.md +1 -1
  143. package/dist/agentic/guides/modules/security/index.md +1 -1
  144. package/dist/agentic/guides/modules/seeds/index.md +1 -1
  145. package/dist/agentic/guides/modules/shipping_carriers/index.md +1 -1
  146. package/dist/agentic/guides/modules/sso/index.md +1 -1
  147. package/dist/agentic/guides/modules/staff/active-extension-bindings.md +1 -1
  148. package/dist/agentic/guides/modules/staff/index.md +1 -1
  149. package/dist/agentic/guides/modules/storage_s3/index.md +1 -1
  150. package/dist/agentic/guides/modules/sync_akeneo/index.md +1 -1
  151. package/dist/agentic/guides/modules/sync_excel/active-extension-bindings.md +11 -0
  152. package/dist/agentic/guides/modules/sync_excel/index.md +3 -2
  153. package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
  154. package/dist/agentic/guides/modules/telemetry/index.md +1 -1
  155. package/dist/agentic/guides/modules/tillio/index.md +1 -1
  156. package/dist/agentic/guides/modules/translations/index.md +1 -1
  157. package/dist/agentic/guides/modules/warranty_claims/index.md +1 -1
  158. package/dist/agentic/guides/modules/webhooks/index.md +1 -1
  159. package/dist/agentic/guides/modules/wms/active-extension-bindings.md +1 -1
  160. package/dist/agentic/guides/modules/wms/contribution-resolutions.md +3 -0
  161. package/dist/agentic/guides/modules/wms/di-registrations-rich.md +8 -8
  162. package/dist/agentic/guides/modules/wms/exact-override-targets.md +11 -8
  163. package/dist/agentic/guides/modules/wms/index.md +3 -3
  164. package/dist/agentic/guides/modules/wms/umes-contributions.md +3 -0
  165. package/dist/agentic/guides/modules/workflows/active-extension-bindings.md +3 -3
  166. package/dist/agentic/guides/modules/workflows/index.md +1 -1
  167. package/dist/agentic/guides/reference-module-facts.json +1 -1
  168. package/dist/agentic/guides/upstream/BACKWARD_COMPATIBILITY.md +73 -1
  169. package/dist/agentic/guides/upstream/manifest.json +2 -2
  170. package/dist/agentic/shared/ai/skills/om-auto-upgrade-0.8.0-to-0.9.0/SKILL.md +112 -0
  171. package/dist/agentic/shared/ai/skills/om-data-model-design/references/sensitive-data.md +1 -1
  172. package/dist/agentic/shared/ai/skills/tiers.json +2 -1
  173. package/dist/bin.js +2 -2
  174. package/dist/bin.js.map +2 -2
  175. package/dist/lib/db/commands.js +84 -5
  176. package/dist/lib/db/commands.js.map +2 -2
  177. package/dist/lib/db/encryption-backfill-check.js +112 -0
  178. package/dist/lib/db/encryption-backfill-check.js.map +7 -0
  179. package/dist/lib/db/migration-reindex.js +2 -1
  180. package/dist/lib/db/migration-reindex.js.map +2 -2
  181. package/dist/lib/events-single-delivery.js.map +2 -2
  182. package/dist/lib/flush-before-exit.js +19 -0
  183. package/dist/lib/flush-before-exit.js.map +7 -0
  184. package/dist/lib/generate-watch-structure.js +113 -19
  185. package/dist/lib/generate-watch-structure.js.map +3 -3
  186. package/dist/lib/generators/module-registry.js +4 -2
  187. package/dist/lib/generators/module-registry.js.map +2 -2
  188. package/dist/lib/generators/web-research-adapters.js +32 -11
  189. package/dist/lib/generators/web-research-adapters.js.map +2 -2
  190. package/dist/lib/telemetry-init.js +69 -11
  191. package/dist/lib/telemetry-init.js.map +3 -3
  192. package/dist/lib/testing/integration.js +29 -2
  193. package/dist/lib/testing/integration.js.map +2 -2
  194. package/package.json +8 -8
  195. package/src/bin.ts +4 -4
  196. package/src/lib/__tests__/flush-before-exit.test.ts +32 -0
  197. package/src/lib/__tests__/generate-watch-structure.test.ts +261 -3
  198. package/src/lib/__tests__/telemetry-init.test.ts +89 -0
  199. package/src/lib/db/__tests__/commands.test.ts +43 -0
  200. package/src/lib/db/__tests__/dynamic-import-specifier.test.ts +36 -0
  201. package/src/lib/db/__tests__/encryption-backfill-check.test.ts +146 -0
  202. package/src/lib/db/__tests__/migration-reindex.test.ts +48 -3
  203. package/src/lib/db/commands.ts +113 -10
  204. package/src/lib/db/encryption-backfill-check.ts +163 -0
  205. package/src/lib/db/migration-reindex.ts +2 -1
  206. package/src/lib/events-single-delivery.ts +5 -2
  207. package/src/lib/flush-before-exit.ts +39 -0
  208. package/src/lib/generate-watch-structure.ts +134 -19
  209. package/src/lib/generators/__tests__/module-facts.customers.fixture.test.ts +14 -3
  210. package/src/lib/generators/__tests__/module-package-sources.test.ts +2 -2
  211. package/src/lib/generators/__tests__/registry-variant-parity.test.ts +16 -0
  212. package/src/lib/generators/__tests__/web-research-adapters.test.ts +141 -0
  213. package/src/lib/generators/module-registry.ts +2 -0
  214. package/src/lib/generators/web-research-adapters.ts +41 -20
  215. package/src/lib/telemetry-init.ts +68 -11
  216. package/src/lib/testing/__tests__/integration.test.ts +26 -0
  217. package/src/lib/testing/integration.ts +32 -2
@@ -0,0 +1,146 @@
1
+ import {
2
+ checkEncryptionMapBackfills,
3
+ findEncryptionBackfillGaps,
4
+ formatEncryptionBackfillWarning,
5
+ isBackfilledByMigration,
6
+ isEncryptionBackfillCheckEnabled,
7
+ readDeclaredEncryptionMaps,
8
+ type EncryptionMapRow,
9
+ } from '../encryption-backfill-check'
10
+
11
+ const TENANT_A = '11111111-1111-1111-1111-111111111111'
12
+ const TENANT_B = '22222222-2222-2222-2222-222222222222'
13
+ const ORG = '33333333-3333-3333-3333-333333333333'
14
+
15
+ function row(tenantId: string, entityId: string, fields: string[], isActive = true): EncryptionMapRow {
16
+ return { tenantId, organizationId: ORG, entityId, fieldsJson: fields.map((field) => ({ field })), isActive }
17
+ }
18
+
19
+ describe('readDeclaredEncryptionMaps', () => {
20
+ it('reads defaultEncryptionMaps and skips system-scoped or malformed entries', () => {
21
+ const declared = readDeclaredEncryptionMaps('demo', {
22
+ defaultEncryptionMaps: [
23
+ { entityId: 'demo:thing', fields: [{ field: 'secret' }, { field: 'email', hashField: 'email_hash' }] },
24
+ { entityId: 'demo:system', keyScope: 'system', fields: [{ field: 'token' }] },
25
+ { entityId: 'demo:empty', fields: [] },
26
+ { fields: [{ field: 'orphan' }] },
27
+ ],
28
+ })
29
+
30
+ expect(declared).toEqual([{ moduleId: 'demo', entityId: 'demo:thing', fields: ['secret', 'email'] }])
31
+ })
32
+
33
+ it('falls back to the default export', () => {
34
+ expect(readDeclaredEncryptionMaps('demo', { default: [{ entityId: 'demo:thing', fields: [{ field: 'secret' }] }] }))
35
+ .toEqual([{ moduleId: 'demo', entityId: 'demo:thing', fields: ['secret'] }])
36
+ })
37
+ })
38
+
39
+ describe('isBackfilledByMigration', () => {
40
+ it('accepts a helper call or raw encryption_maps SQL naming the entity and field', () => {
41
+ const helperSource = `buildEncryptionMapBackfillSql({ entityId: 'demo:thing', fields: [{ field: 'secret' }] })`
42
+ const rawSource = `insert into "encryption_maps" select 'demo:thing', '[{"field":"secret"}]'::jsonb`
43
+
44
+ expect(isBackfilledByMigration([helperSource], 'demo:thing', 'secret')).toBe(true)
45
+ expect(isBackfilledByMigration([rawSource], 'demo:thing', 'secret')).toBe(true)
46
+ })
47
+
48
+ it('rejects migrations that do not write encryption maps or name another field', () => {
49
+ expect(isBackfilledByMigration([`alter table demo add column "secret" text; -- demo:thing`], 'demo:thing', 'secret')).toBe(false)
50
+ expect(isBackfilledByMigration([`buildEncryptionMapBackfillSql({ entityId: 'demo:thing', fields: [{ field: 'secret_hash' }] })`], 'demo:thing', 'secret')).toBe(false)
51
+ })
52
+ })
53
+
54
+ describe('findEncryptionBackfillGaps', () => {
55
+ const declared = [{ moduleId: 'demo', entityId: 'demo:thing', fields: ['secret', 'email'] }]
56
+
57
+ it('reports declared fields missing for existing encryption-enabled scopes', () => {
58
+ const gaps = findEncryptionBackfillGaps({
59
+ declared,
60
+ rows: [row(TENANT_A, 'other:x', ['a']), row(TENANT_B, 'demo:thing', ['secret', 'email'])],
61
+ migrationSources: [],
62
+ })
63
+
64
+ expect(gaps).toEqual([
65
+ { moduleId: 'demo', entityId: 'demo:thing', field: 'secret', missingScopes: 1, totalScopes: 2 },
66
+ { moduleId: 'demo', entityId: 'demo:thing', field: 'email', missingScopes: 1, totalScopes: 2 },
67
+ ])
68
+ })
69
+
70
+ it('reports a new field on an existing map', () => {
71
+ const gaps = findEncryptionBackfillGaps({ declared, rows: [row(TENANT_A, 'demo:thing', ['secret'])], migrationSources: [] })
72
+
73
+ expect(gaps.map((gap) => gap.field)).toEqual(['email'])
74
+ })
75
+
76
+ it('ignores scopes with encryption disabled and fields a migration backfills', () => {
77
+ expect(findEncryptionBackfillGaps({ declared, rows: [row(TENANT_A, 'other:x', ['a'], false)], migrationSources: [] })).toEqual([])
78
+ expect(
79
+ findEncryptionBackfillGaps({
80
+ declared,
81
+ rows: [row(TENANT_A, 'other:x', ['a'])],
82
+ migrationSources: [`buildEncryptionMapBackfillSql({ entityId: 'demo:thing', fields: [{ field: 'secret' }, { field: 'email' }] })`],
83
+ }),
84
+ ).toEqual([])
85
+ })
86
+ })
87
+
88
+ describe('checkEncryptionMapBackfills', () => {
89
+ it('warns once with every gap', async () => {
90
+ const warn = jest.fn()
91
+ const gaps = await checkEncryptionMapBackfills({
92
+ loadDeclaredMaps: async () => [{ moduleId: 'demo', entityId: 'demo:thing', fields: ['secret'] }],
93
+ loadMigrationSources: () => [],
94
+ queryEncryptionMaps: async () => [row(TENANT_A, 'other:x', ['a'])],
95
+ warn,
96
+ })
97
+
98
+ expect(gaps).toHaveLength(1)
99
+ expect(warn).toHaveBeenCalledTimes(1)
100
+ expect(warn.mock.calls[0][0]).toContain('demo: demo:thing [secret] (missing in 1/1 tenant scopes)')
101
+ expect(warn.mock.calls[0][0]).toContain('buildEncryptionMapBackfillSql')
102
+ })
103
+
104
+ it('does not query the database when no module declares maps', async () => {
105
+ const queryEncryptionMaps = jest.fn(async () => [])
106
+ await checkEncryptionMapBackfills({ loadDeclaredMaps: async () => [], loadMigrationSources: () => [], queryEncryptionMaps, warn: jest.fn() })
107
+
108
+ expect(queryEncryptionMaps).not.toHaveBeenCalled()
109
+ })
110
+
111
+ it('turns failures into a warning instead of throwing', async () => {
112
+ const warn = jest.fn()
113
+ const gaps = await checkEncryptionMapBackfills({
114
+ loadDeclaredMaps: async () => [{ moduleId: 'demo', entityId: 'demo:thing', fields: ['secret'] }],
115
+ loadMigrationSources: () => [],
116
+ queryEncryptionMaps: async () => {
117
+ throw new Error('connection refused')
118
+ },
119
+ warn,
120
+ })
121
+
122
+ expect(gaps).toEqual([])
123
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('connection refused'))
124
+ })
125
+ })
126
+
127
+ describe('formatEncryptionBackfillWarning', () => {
128
+ it('groups fields per entity and caps the list', () => {
129
+ const gaps = Array.from({ length: 22 }, (_unused, index) => [
130
+ { moduleId: 'demo', entityId: `demo:thing_${index}`, field: 'secret', missingScopes: 1, totalScopes: 2 },
131
+ { moduleId: 'demo', entityId: `demo:thing_${index}`, field: 'email', missingScopes: 2, totalScopes: 2 },
132
+ ]).flat()
133
+ const message = formatEncryptionBackfillWarning(gaps)
134
+
135
+ expect(message).toContain(' - demo: demo:thing_0 [secret, email] (missing in 2/2 tenant scopes)')
136
+ expect(message).not.toContain('demo:thing_20 ')
137
+ expect(message).toContain('… and 2 more entities')
138
+ })
139
+ })
140
+
141
+ describe('isEncryptionBackfillCheckEnabled', () => {
142
+ it('is on by default and can be switched off', () => {
143
+ expect(isEncryptionBackfillCheckEnabled({})).toBe(true)
144
+ expect(isEncryptionBackfillCheckEnabled({ OM_ENCRYPTION_BACKFILL_CHECK: 'off' })).toBe(false)
145
+ })
146
+ })
@@ -1,4 +1,6 @@
1
+ import fs from 'node:fs'
1
2
  import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
2
4
  import {
3
5
  collectQueryIndexReindexEntityTypes,
4
6
  formatManualReindexInstructions,
@@ -14,6 +16,10 @@ function migration(name: string, overrides: Partial<AppliedMigration> = {}): App
14
16
  return { moduleId: 'customers', migrationsPath: MIGRATIONS_PATH, name, ...overrides }
15
17
  }
16
18
 
19
+ function resolveImportSpecifier(specifier: string): string {
20
+ return specifier.startsWith('file:') ? fileURLToPath(specifier) : specifier
21
+ }
22
+
17
23
  describe('resolveMigrationFilePath', () => {
18
24
  const existing = new Set([path.join(MIGRATIONS_PATH, 'Migration20260901120000_reindex.ts')])
19
25
  const fileExists = (filePath: string) => existing.has(filePath)
@@ -61,7 +67,7 @@ describe('collectQueryIndexReindexEntityTypes', () => {
61
67
 
62
68
  const collected = await collectQueryIndexReindexEntityTypes(
63
69
  [migration('a'), migration('b'), migration('c')],
64
- { importModule: async (filePath) => modules[filePath], fileExists },
70
+ { importModule: async (specifier) => modules[resolveImportSpecifier(specifier)], fileExists },
65
71
  )
66
72
 
67
73
  expect(collected).toEqual(['customers:customer_dictionary_entry', 'workflows:workflow_definition'])
@@ -74,8 +80,8 @@ describe('collectQueryIndexReindexEntityTypes', () => {
74
80
  {
75
81
  fileExists,
76
82
  onWarn,
77
- importModule: async (filePath) => {
78
- if (filePath.endsWith('broken.ts')) throw new Error('boom')
83
+ importModule: async (specifier) => {
84
+ if (resolveImportSpecifier(specifier).endsWith('broken.ts')) throw new Error('boom')
79
85
  return { queryIndexReindexEntityTypes: ['dictionaries:dictionary_entry'] }
80
86
  },
81
87
  },
@@ -118,6 +124,45 @@ describe('collectQueryIndexReindexEntityTypes', () => {
118
124
  expect(collected).toEqual([])
119
125
  expect(importModule).not.toHaveBeenCalled()
120
126
  })
127
+
128
+ it('imports cwd-relative node_modules migration paths via file: URLs (#6238)', async () => {
129
+ const relativePath = path.join(
130
+ 'node_modules',
131
+ '@open-mercato',
132
+ 'test-migration-reindex',
133
+ 'Migration20260901120000_reindex.js',
134
+ )
135
+ const absolutePath = path.resolve(relativePath)
136
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
137
+ fs.writeFileSync(
138
+ absolutePath,
139
+ 'module.exports = { queryIndexReindexEntityTypes: ["customers:deal"] };\n',
140
+ )
141
+
142
+ try {
143
+ const onWarn = jest.fn()
144
+ const collected = await collectQueryIndexReindexEntityTypes(
145
+ [{
146
+ moduleId: 'customers',
147
+ migrationsPath: path.dirname(absolutePath),
148
+ name: 'Migration20260901120000_reindex',
149
+ filePath: relativePath,
150
+ }],
151
+ {
152
+ onWarn,
153
+ importModule: async (specifier) => {
154
+ expect(specifier.startsWith('file:')).toBe(true)
155
+ return import(specifier)
156
+ },
157
+ },
158
+ )
159
+
160
+ expect(onWarn).not.toHaveBeenCalled()
161
+ expect(collected).toEqual(['customers:deal'])
162
+ } finally {
163
+ fs.rmSync(path.dirname(absolutePath), { recursive: true, force: true })
164
+ }
165
+ })
121
166
  })
122
167
 
123
168
  describe('isMigrationReindexEnabled', () => {
@@ -9,6 +9,13 @@ import { PostgreSqlDriver } from '@mikro-orm/postgresql'
9
9
  import { getSslConfig } from '@open-mercato/shared/lib/db/ssl'
10
10
  import type { PackageResolver, ModuleEntry } from '../resolver'
11
11
  import { quotePostgresIdentifier } from './identifiers'
12
+ import {
13
+ checkEncryptionMapBackfills,
14
+ isEncryptionBackfillCheckEnabled,
15
+ readDeclaredEncryptionMaps,
16
+ type DeclaredEncryptionMap,
17
+ type EncryptionMapRow,
18
+ } from './encryption-backfill-check'
12
19
  import {
13
20
  collectQueryIndexReindexEntityTypes,
14
21
  isMigrationReindexEnabled,
@@ -59,18 +66,47 @@ function sortModules(mods: ModuleEntry[]): ModuleEntry[] {
59
66
  }
60
67
 
61
68
  /**
62
- * Custom dynamic import provider for MikroORM that properly handles Windows paths.
63
- * MikroORM's built-in handling has a bug where it converts file:// URLs back to
64
- * Windows paths when the extension isn't in require.extensions (which is always
65
- * true for .ts files in ESM mode).
69
+ * Convert a filesystem path into a specifier `import()` can resolve under plain
70
+ * Node ESM on every platform (#6238).
71
+ *
72
+ * Windows already needed `file:` URLs for drive-letter absolute paths. On POSIX,
73
+ * cwd-relative paths such as `node_modules/.../Migration.js` are treated as
74
+ * bare package names (`node_modules`), so migration reindex declarations were
75
+ * logged and skipped. Absolute paths become `file:` URLs on every platform.
76
+ * Already-URL / `node:` / bare package (and package-subpath) specifiers stay as-is.
66
77
  */
67
- async function dynamicImportProvider(id: string): Promise<any> {
68
- // On Windows, convert absolute paths to file:// URLs
69
- // Check if it's a Windows absolute path (e.g., C:\... or D:\...)
70
- if (process.platform === 'win32' && /^[a-zA-Z]:[\\/]/.test(id)) {
71
- id = pathToFileURL(id).href
78
+ export function toImportableModuleSpecifier(id: string): string {
79
+ if (
80
+ id.startsWith('file:') ||
81
+ id.startsWith('data:') ||
82
+ id.startsWith('node:') ||
83
+ id.startsWith('http:') ||
84
+ id.startsWith('https:')
85
+ ) {
86
+ return id
72
87
  }
73
- return import(id)
88
+
89
+ const isWindowsAbsolute = /^[a-zA-Z]:[\\/]/.test(id)
90
+ const isFilesystemRelative =
91
+ id.startsWith('./') ||
92
+ id.startsWith('../') ||
93
+ id.startsWith('.\\') ||
94
+ id.startsWith('..\\') ||
95
+ /(^|[\\/])node_modules([\\/]|$)/.test(id)
96
+
97
+ if (path.isAbsolute(id) || isWindowsAbsolute || isFilesystemRelative) {
98
+ return pathToFileURL(path.resolve(id)).href
99
+ }
100
+
101
+ return id
102
+ }
103
+
104
+ /**
105
+ * Custom dynamic import provider for MikroORM and migration reindex loading.
106
+ * Always converts filesystem paths to `file:` URLs before `import()` (#6238).
107
+ */
108
+ async function dynamicImportProvider(id: string): Promise<any> {
109
+ return import(toImportableModuleSpecifier(id))
74
110
  }
75
111
 
76
112
  /**
@@ -228,6 +264,72 @@ function getMigrationsPath(entry: ModuleEntry, resolver: PackageResolver): strin
228
264
  return path.join(roots.pkgBase, 'migrations').replace(/\\/g, '/')
229
265
  }
230
266
 
267
+ async function loadModuleEncryptionMaps(entry: ModuleEntry, resolver: PackageResolver): Promise<DeclaredEncryptionMap[]> {
268
+ const roots = resolver.getModulePaths(entry)
269
+ const appFile = path.join(roots.appBase, 'encryption.ts')
270
+ const pkgFile = path.join(roots.pkgBase, 'encryption.ts')
271
+ try {
272
+ if (fs.existsSync(appFile)) return readDeclaredEncryptionMaps(entry.id, await importWithTypeScriptFile(appFile))
273
+ if (entry.from === '@app' || resolver.isMonorepo()) {
274
+ return fs.existsSync(pkgFile) ? readDeclaredEncryptionMaps(entry.id, await importWithTypeScriptFile(pkgFile)) : []
275
+ }
276
+ return readDeclaredEncryptionMaps(entry.id, await import(`${resolver.getModuleImportBase(entry).pkgBase}/encryption`))
277
+ } catch {
278
+ return []
279
+ }
280
+ }
281
+
282
+ function readModuleMigrationSources(modules: ModuleEntry[], resolver: PackageResolver): string[] {
283
+ const sources: string[] = []
284
+ for (const entry of modules) {
285
+ const migrationsPath = getMigrationsPath(entry, resolver)
286
+ if (!fs.existsSync(migrationsPath)) continue
287
+ for (const file of fs.readdirSync(migrationsPath)) {
288
+ if (!/^Migration.*\.(ts|js)$/.test(file) || file.endsWith('.d.ts')) continue
289
+ sources.push(fs.readFileSync(path.join(migrationsPath, file), 'utf8'))
290
+ }
291
+ }
292
+ return sources
293
+ }
294
+
295
+ async function queryEncryptionMapRows(): Promise<EncryptionMapRow[] | null> {
296
+ const { Client } = await import('pg')
297
+ const client = new Client({ connectionString: getClientUrl(), ssl: getSslConfig(), connectionTimeoutMillis: 5000 })
298
+ await client.connect()
299
+ try {
300
+ const table = await client.query(`select to_regclass('encryption_maps') is not null as "exists"`)
301
+ if (!table.rows[0]?.exists) return null
302
+ const result = await client.query(
303
+ `select "tenant_id", "organization_id", "entity_id", "fields_json", "is_active" from "encryption_maps" where "deleted_at" is null`,
304
+ )
305
+ return result.rows.map((row) => ({
306
+ tenantId: row.tenant_id ?? null,
307
+ organizationId: row.organization_id ?? null,
308
+ entityId: String(row.entity_id),
309
+ fieldsJson: row.fields_json,
310
+ isActive: row.is_active === true,
311
+ }))
312
+ } finally {
313
+ try {
314
+ await client.end()
315
+ } catch { }
316
+ }
317
+ }
318
+
319
+ export async function warnAboutMissingEncryptionMapBackfills(modules: ModuleEntry[], resolver: PackageResolver): Promise<void> {
320
+ if (!isEncryptionBackfillCheckEnabled()) return
321
+ await checkEncryptionMapBackfills({
322
+ loadDeclaredMaps: async () => {
323
+ const declared: DeclaredEncryptionMap[] = []
324
+ for (const entry of modules) declared.push(...(await loadModuleEncryptionMaps(entry, resolver)))
325
+ return declared
326
+ },
327
+ loadMigrationSources: () => readModuleMigrationSources(modules, resolver),
328
+ queryEncryptionMaps: queryEncryptionMapRows,
329
+ warn: (message) => console.warn(message),
330
+ })
331
+ }
332
+
231
333
  export interface DbOptions {
232
334
  quiet?: boolean
233
335
  }
@@ -338,6 +440,7 @@ export async function dbGenerate(resolver: PackageResolver, options: DbOptions =
338
440
  }
339
441
 
340
442
  console.log(results.join('\n'))
443
+ await warnAboutMissingEncryptionMapBackfills(ordered, resolver)
341
444
  }
342
445
 
343
446
  export async function dbMigrate(resolver: PackageResolver, options: DbOptions = {}): Promise<void> {
@@ -0,0 +1,163 @@
1
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
2
+
3
+ /**
4
+ * Advisory check run after `db:generate`. Tenant encryption maps are seeded only when a tenant is
5
+ * created, so an `encryption.ts` entry added after tenants exist never reaches them unless a module
6
+ * migration backfills it (`buildEncryptionMapBackfillSql`). The connected database is the evidence:
7
+ * a declared `(entityId, field)` that some existing encryption-enabled scope lacks, and that no
8
+ * migration mentions, is reported. The check only warns; it never fails generation.
9
+ */
10
+
11
+ export type DeclaredEncryptionMap = {
12
+ moduleId: string
13
+ entityId: string
14
+ fields: string[]
15
+ }
16
+
17
+ export type EncryptionMapRow = {
18
+ tenantId: string | null
19
+ organizationId: string | null
20
+ entityId: string
21
+ fieldsJson: unknown
22
+ isActive: boolean
23
+ }
24
+
25
+ export type EncryptionBackfillGap = {
26
+ moduleId: string
27
+ entityId: string
28
+ field: string
29
+ missingScopes: number
30
+ totalScopes: number
31
+ }
32
+
33
+ export type EncryptionBackfillCheckDeps = {
34
+ loadDeclaredMaps: () => Promise<DeclaredEncryptionMap[]>
35
+ loadMigrationSources: () => string[]
36
+ queryEncryptionMaps: () => Promise<EncryptionMapRow[] | null>
37
+ warn: (message: string) => void
38
+ }
39
+
40
+ export function isEncryptionBackfillCheckEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
41
+ return parseBooleanWithDefault(env.OM_ENCRYPTION_BACKFILL_CHECK, true)
42
+ }
43
+
44
+ export function readDeclaredEncryptionMaps(moduleId: string, moduleExports: unknown): DeclaredEncryptionMap[] {
45
+ if (!moduleExports || typeof moduleExports !== 'object') return []
46
+ const exportsRecord = moduleExports as Record<string, unknown>
47
+ const maps = exportsRecord.defaultEncryptionMaps ?? exportsRecord.encryptionMaps ?? exportsRecord.default
48
+ if (!Array.isArray(maps)) return []
49
+ const declared: DeclaredEncryptionMap[] = []
50
+ for (const map of maps) {
51
+ if (!map || typeof map !== 'object') continue
52
+ const { entityId, keyScope, fields } = map as Record<string, unknown>
53
+ if (typeof entityId !== 'string' || keyScope === 'system' || !Array.isArray(fields)) continue
54
+ const fieldNames = fields
55
+ .map((rule) => (rule && typeof rule === 'object' ? (rule as Record<string, unknown>).field : null))
56
+ .filter((field): field is string => typeof field === 'string' && field.length > 0)
57
+ if (fieldNames.length) declared.push({ moduleId, entityId, fields: fieldNames })
58
+ }
59
+ return declared
60
+ }
61
+
62
+ function readRuleFields(fieldsJson: unknown): Set<string> {
63
+ const fields = new Set<string>()
64
+ if (!Array.isArray(fieldsJson)) return fields
65
+ for (const rule of fieldsJson) {
66
+ if (rule && typeof rule === 'object' && typeof (rule as Record<string, unknown>).field === 'string') {
67
+ fields.add((rule as Record<string, string>).field)
68
+ }
69
+ }
70
+ return fields
71
+ }
72
+
73
+ function scopeKey(tenantId: string | null, organizationId: string | null): string {
74
+ return `${tenantId ?? ''}|${organizationId ?? ''}`
75
+ }
76
+
77
+ function mentionsLiteral(source: string, value: string): boolean {
78
+ return source.includes(`'${value}'`) || source.includes(`"${value}"`) || source.includes(`\`${value}\``)
79
+ }
80
+
81
+ export function isBackfilledByMigration(migrationSources: readonly string[], entityId: string, field: string): boolean {
82
+ return migrationSources.some(
83
+ (source) =>
84
+ (source.includes('encryption_maps') || source.includes('buildEncryptionMapBackfillSql')) &&
85
+ mentionsLiteral(source, entityId) &&
86
+ mentionsLiteral(source, field),
87
+ )
88
+ }
89
+
90
+ export function findEncryptionBackfillGaps(input: {
91
+ declared: readonly DeclaredEncryptionMap[]
92
+ rows: readonly EncryptionMapRow[]
93
+ migrationSources: readonly string[]
94
+ }): EncryptionBackfillGap[] {
95
+ const activeScopes = new Set<string>()
96
+ const fieldsByScopeAndEntity = new Map<string, Set<string>>()
97
+ for (const row of input.rows) {
98
+ const scope = scopeKey(row.tenantId, row.organizationId)
99
+ if (row.isActive) activeScopes.add(scope)
100
+ const key = `${scope}|${row.entityId}`
101
+ const fields = fieldsByScopeAndEntity.get(key) ?? new Set<string>()
102
+ for (const field of readRuleFields(row.fieldsJson)) fields.add(field)
103
+ fieldsByScopeAndEntity.set(key, fields)
104
+ }
105
+ if (activeScopes.size === 0) return []
106
+
107
+ const gaps: EncryptionBackfillGap[] = []
108
+ for (const map of input.declared) {
109
+ for (const field of map.fields) {
110
+ let missingScopes = 0
111
+ for (const scope of activeScopes) {
112
+ if (!fieldsByScopeAndEntity.get(`${scope}|${map.entityId}`)?.has(field)) missingScopes += 1
113
+ }
114
+ if (missingScopes === 0) continue
115
+ if (isBackfilledByMigration(input.migrationSources, map.entityId, field)) continue
116
+ gaps.push({ moduleId: map.moduleId, entityId: map.entityId, field, missingScopes, totalScopes: activeScopes.size })
117
+ }
118
+ }
119
+ return gaps
120
+ }
121
+
122
+ const MAX_WARNING_ENTITIES = 20
123
+
124
+ export function formatEncryptionBackfillWarning(gaps: readonly EncryptionBackfillGap[]): string {
125
+ const byEntity = new Map<string, { moduleId: string; fields: string[]; missingScopes: number; totalScopes: number }>()
126
+ for (const gap of gaps) {
127
+ const entry = byEntity.get(gap.entityId) ?? { moduleId: gap.moduleId, fields: [], missingScopes: 0, totalScopes: gap.totalScopes }
128
+ entry.fields.push(gap.field)
129
+ entry.missingScopes = Math.max(entry.missingScopes, gap.missingScopes)
130
+ byEntity.set(gap.entityId, entry)
131
+ }
132
+ const entities = [...byEntity.entries()]
133
+ const lines = entities
134
+ .slice(0, MAX_WARNING_ENTITIES)
135
+ .map(
136
+ ([entityId, entry]) =>
137
+ ` - ${entry.moduleId}: ${entityId} [${entry.fields.join(', ')}] (missing in ${entry.missingScopes}/${entry.totalScopes} tenant scopes)`,
138
+ )
139
+ if (entities.length > MAX_WARNING_ENTITIES) lines.push(` … and ${entities.length - MAX_WARNING_ENTITIES} more entities`)
140
+ return [
141
+ '⚠️ Encryption maps declared in encryption.ts are missing for existing tenants, and no migration backfills them:',
142
+ ...lines,
143
+ ' Tenants that already exist keep writing these fields as plaintext. If the module may already be deployed,',
144
+ ' add a migration that calls buildEncryptionMapBackfillSql from @open-mercato/shared/lib/encryption/migration-backfill.',
145
+ ' Local database only: `yarn mercato entities seed-encryption --tenant <id>`. Silence with OM_ENCRYPTION_BACKFILL_CHECK=off.',
146
+ ].join('\n')
147
+ }
148
+
149
+ export async function checkEncryptionMapBackfills(deps: EncryptionBackfillCheckDeps): Promise<EncryptionBackfillGap[]> {
150
+ try {
151
+ const declared = await deps.loadDeclaredMaps()
152
+ if (!declared.length) return []
153
+ const rows = await deps.queryEncryptionMaps()
154
+ if (!rows?.length) return []
155
+ const gaps = findEncryptionBackfillGaps({ declared, rows, migrationSources: deps.loadMigrationSources() })
156
+ if (gaps.length) deps.warn(formatEncryptionBackfillWarning(gaps))
157
+ return gaps
158
+ } catch (error) {
159
+ const message = error instanceof Error ? error.message : String(error)
160
+ deps.warn(`⚠️ Skipped the encryption map backfill check: ${message}`)
161
+ return []
162
+ }
163
+ }
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
3
4
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
4
5
  import {
5
6
  formatQueryIndexRebuildCommands,
@@ -58,7 +59,7 @@ export async function collectQueryIndexReindexEntityTypes(
58
59
  if (!filePath) continue
59
60
  let moduleExports: unknown
60
61
  try {
61
- moduleExports = await deps.importModule(filePath)
62
+ moduleExports = await deps.importModule(pathToFileURL(path.resolve(filePath)).href)
62
63
  } catch (error) {
63
64
  deps.onWarn?.(
64
65
  `[query_index] Could not read reindex declarations from ${migration.moduleId}/${migration.name}: ${
@@ -2,8 +2,11 @@ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
2
2
  import type { AutoSpawnWorkersMode } from './auto-spawn-workers'
3
3
 
4
4
  // Keep these env names in sync with the runtime source of truth,
5
- // `@open-mercato/events/single-delivery`. The CLI cannot import the events
6
- // package (no dependency edge), so the reconcile logic is mirrored here for the
5
+ // `@open-mercato/events/single-delivery`. This guard runs ahead of app
6
+ // bootstrap (before the Postgres driver and the rest of the module graph
7
+ // load), so it deliberately avoids importing `@open-mercato/events` here even
8
+ // though the package is now a declared dependency (bin.ts's post-bootstrap
9
+ // exit-flush uses it) — the reconcile logic is mirrored instead for this
7
10
  // server-bootstrap guard only; the bus and worker own the runtime behavior.
8
11
  //
9
12
  // This guard is the ONLY place that reconciles the flag against worker
@@ -0,0 +1,39 @@
1
+ import { flushTelemetry } from './flush-telemetry'
2
+
3
+ export type ExitFlushSteps = {
4
+ flushBroadcasts: () => Promise<void>
5
+ flushTelemetry: () => Promise<void>
6
+ }
7
+
8
+ async function flushPendingBrowserBroadcasts(): Promise<void> {
9
+ const { BROADCAST_FLUSH_DEADLINE_MS, flushPendingBroadcasts } = await import(
10
+ '@open-mercato/events/broadcast-coalescer'
11
+ )
12
+ await flushPendingBroadcasts({ deadlineMs: BROADCAST_FLUSH_DEADLINE_MS })
13
+ }
14
+
15
+ /**
16
+ * Runs before a returning CLI command's explicit `process.exit()`, which Node
17
+ * skips `beforeExit` for — the natural-exit path the broadcast-coalescer
18
+ * shutdown hook relies on to deliver a burst's trailing browser dispatch.
19
+ *
20
+ * The broadcast flush runs FIRST so its final `pg_notify` and the telemetry it
21
+ * produces are still captured by the telemetry flush that follows. It is capped
22
+ * at BROADCAST_FLUSH_DEADLINE_MS (3000ms), so a stalled Postgres connection at
23
+ * exit degrades to a dropped tail instead of hanging the command. Both steps are
24
+ * best-effort: neither can reject, and neither changes the command's exit code.
25
+ *
26
+ * Imported from the `broadcast-coalescer` deep path, and only after `run()` has
27
+ * bootstrapped the app, so it never loads the Postgres driver ahead of
28
+ * `initTelemetry()`.
29
+ */
30
+ export async function flushBeforeExit(steps: Partial<ExitFlushSteps> = {}): Promise<void> {
31
+ const flushBroadcasts = steps.flushBroadcasts ?? flushPendingBrowserBroadcasts
32
+ try {
33
+ await flushBroadcasts()
34
+ } catch (error) {
35
+ const reason = error instanceof Error ? error.message : String(error)
36
+ console.warn(`[events] Broadcast flush on exit failed; the last browser refresh may be lost: ${reason}`)
37
+ }
38
+ await (steps.flushTelemetry ?? flushTelemetry)()
39
+ }