@open-mercato/cli 0.7.1-develop.7185.1.0f280ef1f1 → 0.7.1-develop.7191.1.3c9346566b

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 (92) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -1
  3. package/dist/agentic/guides/module-facts.json +137 -137
  4. package/dist/agentic/guides/module-facts.v2.json +137 -137
  5. package/dist/agentic/guides/modules/ai_assistant/index.md +1 -1
  6. package/dist/agentic/guides/modules/api_docs/index.md +1 -1
  7. package/dist/agentic/guides/modules/api_keys/index.md +1 -1
  8. package/dist/agentic/guides/modules/attachments/index.md +1 -1
  9. package/dist/agentic/guides/modules/audit_logs/index.md +1 -1
  10. package/dist/agentic/guides/modules/auth/index.md +1 -1
  11. package/dist/agentic/guides/modules/business_rules/index.md +1 -1
  12. package/dist/agentic/guides/modules/catalog/index.md +1 -1
  13. package/dist/agentic/guides/modules/channel_apns/index.md +1 -1
  14. package/dist/agentic/guides/modules/channel_discord/index.md +1 -1
  15. package/dist/agentic/guides/modules/channel_expo/index.md +1 -1
  16. package/dist/agentic/guides/modules/channel_fcm/index.md +1 -1
  17. package/dist/agentic/guides/modules/channel_gmail/index.md +1 -1
  18. package/dist/agentic/guides/modules/channel_imap/index.md +1 -1
  19. package/dist/agentic/guides/modules/channel_resend/index.md +1 -1
  20. package/dist/agentic/guides/modules/channel_ses/index.md +1 -1
  21. package/dist/agentic/guides/modules/checkout/index.md +1 -1
  22. package/dist/agentic/guides/modules/communication_channels/index.md +1 -1
  23. package/dist/agentic/guides/modules/configs/index.md +1 -1
  24. package/dist/agentic/guides/modules/content/index.md +1 -1
  25. package/dist/agentic/guides/modules/currencies/index.md +1 -1
  26. package/dist/agentic/guides/modules/customer_accounts/index.md +1 -1
  27. package/dist/agentic/guides/modules/customers/active-extension-bindings.md +1 -1
  28. package/dist/agentic/guides/modules/customers/index.md +1 -1
  29. package/dist/agentic/guides/modules/dashboards/index.md +1 -1
  30. package/dist/agentic/guides/modules/data_sync/index.md +1 -1
  31. package/dist/agentic/guides/modules/design_system/index.md +1 -1
  32. package/dist/agentic/guides/modules/devices/index.md +1 -1
  33. package/dist/agentic/guides/modules/dictionaries/index.md +1 -1
  34. package/dist/agentic/guides/modules/directory/index.md +1 -1
  35. package/dist/agentic/guides/modules/documents/index.md +1 -1
  36. package/dist/agentic/guides/modules/entities/index.md +1 -1
  37. package/dist/agentic/guides/modules/eudr/index.md +1 -1
  38. package/dist/agentic/guides/modules/events/index.md +1 -1
  39. package/dist/agentic/guides/modules/feature_toggles/index.md +1 -1
  40. package/dist/agentic/guides/modules/gateway_stripe/index.md +1 -1
  41. package/dist/agentic/guides/modules/generators/index.md +1 -1
  42. package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
  43. package/dist/agentic/guides/modules/integrations/index.md +1 -1
  44. package/dist/agentic/guides/modules/messages/index.md +1 -1
  45. package/dist/agentic/guides/modules/notifications/index.md +1 -1
  46. package/dist/agentic/guides/modules/onboarding/index.md +1 -1
  47. package/dist/agentic/guides/modules/payment_gateways/index.md +1 -1
  48. package/dist/agentic/guides/modules/perspectives/index.md +1 -1
  49. package/dist/agentic/guides/modules/phone_calls/index.md +1 -1
  50. package/dist/agentic/guides/modules/planner/index.md +1 -1
  51. package/dist/agentic/guides/modules/portal/index.md +1 -1
  52. package/dist/agentic/guides/modules/progress/index.md +1 -1
  53. package/dist/agentic/guides/modules/push_notifications/index.md +1 -1
  54. package/dist/agentic/guides/modules/query_index/index.md +1 -1
  55. package/dist/agentic/guides/modules/record_locks/index.md +1 -1
  56. package/dist/agentic/guides/modules/resources/index.md +1 -1
  57. package/dist/agentic/guides/modules/sales/index.md +1 -1
  58. package/dist/agentic/guides/modules/scheduler/index.md +1 -1
  59. package/dist/agentic/guides/modules/search/index.md +1 -1
  60. package/dist/agentic/guides/modules/security/index.md +1 -1
  61. package/dist/agentic/guides/modules/shipping_carriers/index.md +1 -1
  62. package/dist/agentic/guides/modules/sso/index.md +1 -1
  63. package/dist/agentic/guides/modules/staff/index.md +1 -1
  64. package/dist/agentic/guides/modules/storage_s3/index.md +1 -1
  65. package/dist/agentic/guides/modules/sync_akeneo/index.md +1 -1
  66. package/dist/agentic/guides/modules/sync_excel/index.md +1 -1
  67. package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
  68. package/dist/agentic/guides/modules/tillio/index.md +1 -1
  69. package/dist/agentic/guides/modules/translations/index.md +1 -1
  70. package/dist/agentic/guides/modules/warranty_claims/index.md +1 -1
  71. package/dist/agentic/guides/modules/webhooks/index.md +1 -1
  72. package/dist/agentic/guides/modules/wms/index.md +1 -1
  73. package/dist/agentic/guides/modules/workflows/index.md +1 -1
  74. package/dist/agentic/guides/reference-module-facts.json +1 -1
  75. package/dist/agentic/guides/upstream/BACKWARD_COMPATIBILITY.md +1 -0
  76. package/dist/agentic/guides/upstream/manifest.json +2 -2
  77. package/dist/lib/generate-watch-structure.js +1 -0
  78. package/dist/lib/generate-watch-structure.js.map +2 -2
  79. package/dist/lib/generators/module-registry.js +45 -0
  80. package/dist/lib/generators/module-registry.js.map +2 -2
  81. package/dist/lib/module-runtimes.js +99 -0
  82. package/dist/lib/module-runtimes.js.map +7 -0
  83. package/dist/mercato.js +9 -0
  84. package/dist/mercato.js.map +2 -2
  85. package/package.json +6 -6
  86. package/src/__tests__/module-runtimes.test.ts +267 -0
  87. package/src/lib/__tests__/generate-watch-structure.test.ts +18 -0
  88. package/src/lib/generate-watch-structure.ts +1 -0
  89. package/src/lib/generators/__tests__/registry-variant-parity.test.ts +28 -1
  90. package/src/lib/generators/module-registry.ts +52 -0
  91. package/src/lib/module-runtimes.ts +195 -0
  92. package/src/mercato.ts +20 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cli",
3
- "version": "0.7.1-develop.7185.1.0f280ef1f1",
3
+ "version": "0.7.1-develop.7191.1.3c9346566b",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -60,9 +60,9 @@
60
60
  "@mikro-orm/decorators": "^7.1.14",
61
61
  "@mikro-orm/migrations": "^7.1.14",
62
62
  "@mikro-orm/postgresql": "^7.1.14",
63
- "@open-mercato/queue": "0.7.1-develop.7185.1.0f280ef1f1",
64
- "@open-mercato/shared": "0.7.1-develop.7185.1.0f280ef1f1",
65
- "@open-mercato/telemetry": "0.7.1-develop.7185.1.0f280ef1f1",
63
+ "@open-mercato/queue": "0.7.1-develop.7191.1.3c9346566b",
64
+ "@open-mercato/shared": "0.7.1-develop.7191.1.3c9346566b",
65
+ "@open-mercato/telemetry": "0.7.1-develop.7191.1.3c9346566b",
66
66
  "cross-spawn": "^7.0.6",
67
67
  "pg": "8.23.0",
68
68
  "semver": "^7.8.5",
@@ -73,10 +73,10 @@
73
73
  "typescript-js": "npm:typescript@6.0.3"
74
74
  },
75
75
  "peerDependencies": {
76
- "@open-mercato/shared": "0.7.1-develop.7185.1.0f280ef1f1"
76
+ "@open-mercato/shared": "0.7.1-develop.7191.1.3c9346566b"
77
77
  },
78
78
  "devDependencies": {
79
- "@open-mercato/shared": "0.7.1-develop.7185.1.0f280ef1f1",
79
+ "@open-mercato/shared": "0.7.1-develop.7191.1.3c9346566b",
80
80
  "@types/jest": "^30.0.0",
81
81
  "jest": "^30.4.2",
82
82
  "ts-jest": "^29.4.12"
@@ -0,0 +1,267 @@
1
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
2
+ import type { ModuleRuntime } from '@open-mercato/shared/modules/runtime'
3
+ import {
4
+ isProductionBuildPhase,
5
+ startModuleRuntimes,
6
+ type ModuleRuntimeCarrier,
7
+ } from '../lib/module-runtimes'
8
+
9
+ const container = {} as AppContainer
10
+ const silent = () => {}
11
+
12
+ function moduleWith(id: string, runtime: ModuleRuntime): ModuleRuntimeCarrier {
13
+ return { id, runtime }
14
+ }
15
+
16
+ describe('startModuleRuntimes', () => {
17
+ it('starts only the modules that declare a runtime', async () => {
18
+ const start = jest.fn().mockResolvedValue(undefined)
19
+ const started = await startModuleRuntimes({
20
+ modules: [{ id: 'no_runtime' }, moduleWith('has_runtime', { start })],
21
+ container,
22
+ role: 'worker',
23
+ log: silent,
24
+ })
25
+
26
+ expect(start).toHaveBeenCalledTimes(1)
27
+ expect(started.started).toEqual(['has_runtime'])
28
+ })
29
+
30
+ it('starts in module id order, so a failure is reproducible', async () => {
31
+ const order: string[] = []
32
+ const record = (id: string): ModuleRuntime => ({ start: async () => { order.push(id) } })
33
+
34
+ await startModuleRuntimes({
35
+ modules: [moduleWith('zulu', record('zulu')), moduleWith('alpha', record('alpha')), moduleWith('mike', record('mike'))],
36
+ container,
37
+ role: 'worker',
38
+ log: silent,
39
+ })
40
+
41
+ expect(order).toEqual(['alpha', 'mike', 'zulu'])
42
+ })
43
+
44
+ it('defaults to the worker role — the only one wired — and honours a declared role set', async () => {
45
+ const byDefault = jest.fn().mockResolvedValue(undefined)
46
+ const serverOnly = jest.fn().mockResolvedValue(undefined)
47
+ const modules = [
48
+ moduleWith('default_roles', { start: byDefault }),
49
+ moduleWith('server_only', { roles: ['server'], start: serverOnly }),
50
+ ]
51
+
52
+ await startModuleRuntimes({ modules, container, role: 'worker', log: silent })
53
+ expect(byDefault).toHaveBeenCalledTimes(1)
54
+ expect(serverOnly).not.toHaveBeenCalled()
55
+
56
+ await startModuleRuntimes({ modules, container, role: 'scheduler', log: silent })
57
+ // Neither applies: 'scheduler' is in neither the default set nor the declared one.
58
+ expect(byDefault).toHaveBeenCalledTimes(1)
59
+ expect(serverOnly).not.toHaveBeenCalled()
60
+
61
+ // A role a module names explicitly still applies, so widening the default later is additive.
62
+ await startModuleRuntimes({ modules, container, role: 'server', log: silent })
63
+ expect(byDefault).toHaveBeenCalledTimes(1)
64
+ expect(serverOnly).toHaveBeenCalledTimes(1)
65
+ })
66
+
67
+ it('fails startup when a runtime throws, and stops the ones already started', async () => {
68
+ const stop = jest.fn().mockResolvedValue(undefined)
69
+ const modules = [
70
+ moduleWith('a_ok', { start: async () => ({ stop }) }),
71
+ moduleWith('b_broken', { start: async () => { throw new Error('no broker') } }),
72
+ ]
73
+
74
+ await expect(startModuleRuntimes({ modules, container, role: 'worker', log: silent }))
75
+ .rejects.toThrow('no broker')
76
+
77
+ // The whole point: a half-started process must not be left running. A module that came up
78
+ // before the failure holds a lease or a subscription, and nothing else will release it.
79
+ expect(stop).toHaveBeenCalledTimes(1)
80
+ })
81
+
82
+ it('fails startup when a runtime throws synchronously, and stops the ones already started', async () => {
83
+ const stop = jest.fn().mockResolvedValue(undefined)
84
+ const modules = [
85
+ moduleWith('a_ok', { start: async () => ({ stop }) }),
86
+ moduleWith('b_broken', { start: (() => { throw new Error('bad config') }) as ModuleRuntime['start'] }),
87
+ ]
88
+
89
+ await expect(startModuleRuntimes({ modules, container, role: 'worker', log: silent }))
90
+ .rejects.toThrow('bad config')
91
+ expect(stop).toHaveBeenCalledTimes(1)
92
+ })
93
+
94
+ it('fails startup when a runtime does not start within the timeout', async () => {
95
+ const modules = [moduleWith('slow', { start: () => new Promise<never>(() => {}) })]
96
+
97
+ await expect(startModuleRuntimes({ modules, container, role: 'worker', log: silent, startTimeoutMs: 10 }))
98
+ .rejects.toThrow(/did not start within 10ms/)
99
+ })
100
+
101
+ // A start timeout is fatal, so an operator whose runtime legitimately needs longer than the
102
+ // default needs a supported way to say so rather than a worker that exits non-zero every boot.
103
+ it('takes both timeouts from the environment', async () => {
104
+ const modules = [moduleWith('slow', { start: () => new Promise<never>(() => {}) })]
105
+
106
+ await expect(startModuleRuntimes({
107
+ modules,
108
+ container,
109
+ role: 'worker',
110
+ log: silent,
111
+ env: { OM_MODULE_RUNTIME_START_TIMEOUT_MS: '7' },
112
+ })).rejects.toThrow(/did not start within 7ms/)
113
+
114
+ const stopped = jest.fn().mockResolvedValue(undefined)
115
+ const started = await startModuleRuntimes({
116
+ modules: [
117
+ moduleWith('a_first', { start: async () => ({ stop: stopped }) }),
118
+ moduleWith('b_hangs', { start: async () => ({ stop: () => new Promise<never>(() => {}) }) }),
119
+ ],
120
+ container,
121
+ role: 'worker',
122
+ log: silent,
123
+ env: { OM_MODULE_RUNTIME_STOP_TIMEOUT_MS: '10' },
124
+ })
125
+
126
+ await expect(started.stop()).resolves.toBeUndefined()
127
+ expect(stopped).toHaveBeenCalledTimes(1)
128
+ })
129
+
130
+ it('prefers an explicit timeout over the environment', async () => {
131
+ const modules = [moduleWith('slow', { start: () => new Promise<never>(() => {}) })]
132
+
133
+ await expect(startModuleRuntimes({
134
+ modules,
135
+ container,
136
+ role: 'worker',
137
+ log: silent,
138
+ startTimeoutMs: 5,
139
+ env: { OM_MODULE_RUNTIME_START_TIMEOUT_MS: '600000' },
140
+ })).rejects.toThrow(/did not start within 5ms/)
141
+ })
142
+
143
+ // Refusing to boot over a typo in a tuning knob would be worse than the default it replaces.
144
+ it('falls back to the default timeout, loudly, when the environment value is unusable', async () => {
145
+ const lines: string[] = []
146
+ const modules = [moduleWith('ok', { start: async () => undefined })]
147
+
148
+ await startModuleRuntimes({
149
+ modules,
150
+ container,
151
+ role: 'worker',
152
+ log: (message) => lines.push(message),
153
+ env: { OM_MODULE_RUNTIME_START_TIMEOUT_MS: 'soon', OM_MODULE_RUNTIME_STOP_TIMEOUT_MS: '-1' },
154
+ })
155
+
156
+ expect(lines).toEqual(expect.arrayContaining([
157
+ expect.stringContaining('ignoring OM_MODULE_RUNTIME_START_TIMEOUT_MS="soon"'),
158
+ expect.stringContaining('ignoring OM_MODULE_RUNTIME_STOP_TIMEOUT_MS="-1"'),
159
+ ]))
160
+ })
161
+
162
+ // The handle from a timed-out start never reaches `started`, so shutdown cannot reach it either.
163
+ // Without trailing the promise, a runtime that ignores `signal` keeps whatever it acquired.
164
+ it('releases the handle of a start that resolves after its timeout', async () => {
165
+ const stop = jest.fn().mockResolvedValue(undefined)
166
+ let resolveStart: (handle: { stop: () => Promise<void> }) => void = () => {}
167
+ const modules = [moduleWith('late', {
168
+ start: () => new Promise((resolve) => { resolveStart = resolve }),
169
+ })]
170
+
171
+ await expect(startModuleRuntimes({ modules, container, role: 'worker', log: silent, startTimeoutMs: 10 }))
172
+ .rejects.toThrow(/did not start within 10ms/)
173
+
174
+ resolveStart({ stop })
175
+ await Promise.resolve()
176
+ await Promise.resolve()
177
+
178
+ expect(stop).toHaveBeenCalledTimes(1)
179
+ })
180
+
181
+ it('stops runtimes in reverse start order', async () => {
182
+ const order: string[] = []
183
+ const runtime = (id: string): ModuleRuntime => ({
184
+ start: async () => ({ stop: async () => { order.push(id) } }),
185
+ })
186
+
187
+ const started = await startModuleRuntimes({
188
+ modules: [moduleWith('alpha', runtime('alpha')), moduleWith('bravo', runtime('bravo'))],
189
+ container,
190
+ role: 'worker',
191
+ log: silent,
192
+ })
193
+ await started.stop()
194
+
195
+ expect(order).toEqual(['bravo', 'alpha'])
196
+ })
197
+
198
+ it('aborts the shared signal before stopping, so a runtime can wind down at its own boundary', async () => {
199
+ let abortedDuringStop: boolean | null = null
200
+ const started = await startModuleRuntimes({
201
+ modules: [moduleWith('watcher', {
202
+ start: async ({ signal }) => ({ stop: async () => { abortedDuringStop = signal.aborted } }),
203
+ })],
204
+ container,
205
+ role: 'worker',
206
+ log: silent,
207
+ })
208
+
209
+ await started.stop()
210
+ expect(abortedDuringStop).toBe(true)
211
+ })
212
+
213
+ it('keeps going when one runtime fails to stop, and does not hang on one that never does', async () => {
214
+ const lastStopped = jest.fn().mockResolvedValue(undefined)
215
+ const started = await startModuleRuntimes({
216
+ modules: [
217
+ moduleWith('a_first', { start: async () => ({ stop: lastStopped }) }),
218
+ moduleWith('b_throws', { start: async () => ({ stop: async () => { throw new Error('rude') } }) }),
219
+ moduleWith('c_hangs', { start: async () => ({ stop: () => new Promise<never>(() => {}) }) }),
220
+ ],
221
+ container,
222
+ role: 'worker',
223
+ log: silent,
224
+ stopTimeoutMs: 10,
225
+ })
226
+
227
+ // Resolves rather than rejecting or hanging: shutdown is best-effort by design, because a
228
+ // shutdown that cannot finish would otherwise become one that never finishes.
229
+ await expect(started.stop()).resolves.toBeUndefined()
230
+ expect(lastStopped).toHaveBeenCalledTimes(1)
231
+ })
232
+
233
+ it('stops only once however many times stop is called', async () => {
234
+ const stop = jest.fn().mockResolvedValue(undefined)
235
+ const started = await startModuleRuntimes({
236
+ modules: [moduleWith('once', { start: async () => ({ stop }) })],
237
+ container,
238
+ role: 'worker',
239
+ log: silent,
240
+ })
241
+
242
+ await Promise.all([started.stop(), started.stop(), started.stop()])
243
+ expect(stop).toHaveBeenCalledTimes(1)
244
+ })
245
+
246
+ it('tolerates a runtime that returns no handle', async () => {
247
+ const started = await startModuleRuntimes({
248
+ modules: [moduleWith('fire_and_forget', { start: async () => undefined })],
249
+ container,
250
+ role: 'worker',
251
+ log: silent,
252
+ })
253
+
254
+ await expect(started.stop()).resolves.toBeUndefined()
255
+ })
256
+ })
257
+
258
+ describe('isProductionBuildPhase', () => {
259
+ it('detects a Next production build', () => {
260
+ expect(isProductionBuildPhase({ NEXT_PHASE: 'phase-production-build' } as NodeJS.ProcessEnv)).toBe(true)
261
+ })
262
+
263
+ it('is false for a server, a worker and an unset environment', () => {
264
+ expect(isProductionBuildPhase({ NEXT_PHASE: 'phase-production-server' } as NodeJS.ProcessEnv)).toBe(false)
265
+ expect(isProductionBuildPhase({} as NodeJS.ProcessEnv)).toBe(false)
266
+ })
267
+ })
@@ -78,6 +78,24 @@ describe('calculateGenerateWatchStructureChecksum', () => {
78
78
  expect(currentChecksum()).not.toBe(before)
79
79
  })
80
80
 
81
+ // A runtime.ts the watcher cannot see is the failure SPEC-072 exists to remove, reappearing in
82
+ // the dev loop: the registry is not regenerated, `Module.runtime` stays undefined, and the
83
+ // runtime never starts — with no error and no warning.
84
+ it('changes when a module runtime is added, edited and removed', () => {
85
+ const before = currentChecksum()
86
+ const runtimePath = path.join(pkgModule, 'runtime.ts')
87
+
88
+ write(runtimePath, 'export const runtime = { start: async () => {} }\n')
89
+ const afterAdd = currentChecksum()
90
+ expect(afterAdd).not.toBe(before)
91
+
92
+ write(runtimePath, 'export const runtime = { roles: ["worker"], start: async () => {} }\n')
93
+ expect(currentChecksum()).not.toBe(afterAdd)
94
+
95
+ fs.rmSync(runtimePath)
96
+ expect(currentChecksum()).toBe(before)
97
+ })
98
+
81
99
  it('changes when a discovered worker is added and removed', () => {
82
100
  const before = currentChecksum()
83
101
  const workerPath = path.join(pkgModule, 'workers', 'sync-customers.ts')
@@ -15,6 +15,7 @@ const STRUCTURAL_CONVENTION_FILES = [
15
15
  'di.ts',
16
16
  'acl.ts',
17
17
  'setup.ts',
18
+ 'runtime.ts',
18
19
  'encryption.ts',
19
20
  'ce.ts',
20
21
  'search.ts',
@@ -17,7 +17,7 @@ import path from 'node:path'
17
17
  import os from 'node:os'
18
18
  import ts from 'typescript-js'
19
19
  import type { PackageResolver, ModuleEntry } from '../../resolver'
20
- import { generateModuleRegistry, generateModuleRegistryApp } from '../module-registry'
20
+ import { generateModuleRegistry, generateModuleRegistryApp, generateModuleRegistryCli } from '../module-registry'
21
21
 
22
22
  /** Properties both registry variants must agree on, per module. */
23
23
  const SHARED_PROPERTIES = [
@@ -33,6 +33,7 @@ const SHARED_PROPERTIES = [
33
33
  'features',
34
34
  'customEntities',
35
35
  'setup',
36
+ 'runtime',
36
37
  'defaultEncryptionMaps',
37
38
  'integrations',
38
39
  'bundles',
@@ -102,6 +103,10 @@ function scaffoldFixture(): ModuleEntry[] {
102
103
  )
103
104
  touchFile(pkgModulePath('orders', 'acl.ts'), "export const features = ['orders.view', 'orders.create']\n")
104
105
  touchFile(pkgModulePath('orders', 'setup.ts'), "export const setup = { defaultRoleFeatures: ['orders.view'] }\n")
106
+ touchFile(
107
+ pkgModulePath('orders', 'runtime.ts'),
108
+ "export const runtime = { roles: ['worker'], start: async () => ({ stop: async () => {} }) }\n",
109
+ )
105
110
  touchFile(
106
111
  pkgModulePath('orders', 'encryption.ts'),
107
112
  "export const defaultEncryptionMaps = [{ entityId: 'orders:sales_order', fields: [{ field: 'customer_email' }] }]\nexport default defaultEncryptionMaps\n",
@@ -190,8 +195,10 @@ describe('module registry variant parity', () => {
190
195
  const resolver = createMockResolver(scaffoldFixture())
191
196
  const mainResult = await generateModuleRegistry({ resolver, quiet: true })
192
197
  const appResult = await generateModuleRegistryApp({ resolver, quiet: true })
198
+ const cliResult = await generateModuleRegistryCli({ resolver, quiet: true })
193
199
  expect(mainResult.errors).toEqual([])
194
200
  expect(appResult.errors).toEqual([])
201
+ expect(cliResult.errors).toEqual([])
195
202
 
196
203
  mainVariant = parseModulePropertiesById(readGenerated('modules.generated.ts'), 'modules.generated.ts')
197
204
  runtimeVariant = parseModulePropertiesById(
@@ -236,6 +243,26 @@ describe('module registry variant parity', () => {
236
243
  expect(restrictTo(mainVariant.get('bare')!, SHARED_PROPERTIES)).toEqual(['customFieldSets', 'id', 'info'])
237
244
  })
238
245
 
246
+ // The worker wiring reads `getCliModules()` — modules.cli.generated.ts — while the web tier
247
+ // reads the app and bootstrap registries. A convention file that reaches only some of them is a
248
+ // runtime that starts in some processes and not others, with nothing to say so.
249
+ it('emits the module runtime in every registry a process can read', () => {
250
+ const registryFiles = [
251
+ 'modules.generated.ts',
252
+ 'modules.runtime.generated.ts',
253
+ 'modules.app.generated.ts',
254
+ 'modules.bootstrap.generated.ts',
255
+ 'modules.cli.generated.ts',
256
+ ]
257
+
258
+ const carriesRuntime = registryFiles.map((fileName) => {
259
+ const byId = parseModulePropertiesById(readGenerated(fileName), fileName)
260
+ return [fileName, byId.get('orders')?.has('runtime'), byId.get('bare')?.has('runtime')]
261
+ })
262
+
263
+ expect(carriesRuntime).toEqual(registryFiles.map((fileName) => [fileName, true, false]))
264
+ })
265
+
239
266
  describe('known, intentional divergences', () => {
240
267
  it('only the main variant carries eager api handlers and the cli entry point', () => {
241
268
  expect(mainVariant.get('orders')!.has('apis')).toBe(true)
@@ -2705,6 +2705,13 @@ function buildLegacyModuleSetupValue(importName: string): WriterFunction {
2705
2705
  ])
2706
2706
  }
2707
2707
 
2708
+ function buildLegacyModuleRuntimeValue(importName: string): WriterFunction {
2709
+ return logicalOr([
2710
+ legacyNamespaceMemberFallback(importName, ['default', 'runtime']),
2711
+ identifier('undefined'),
2712
+ ])
2713
+ }
2714
+
2708
2715
  function buildLegacyIntegrationListValue(
2709
2716
  importName: string,
2710
2717
  pluralMember: string,
@@ -3637,6 +3644,7 @@ async function generateModuleRegistryFromDiscovery(options: ModuleRegistryRender
3637
3644
  let customFieldSetsExpr: string = '[]'
3638
3645
  const dashboardWidgets: string[] = []
3639
3646
  let setupImportName: string | null = null
3647
+ let moduleRuntimeImportName: string | null = null
3640
3648
  let encryptionImportName: string | null = null
3641
3649
  let integrationImportName: string | null = null
3642
3650
 
@@ -3772,6 +3780,12 @@ async function generateModuleRegistryFromDiscovery(options: ModuleRegistryRender
3772
3780
  }
3773
3781
  }
3774
3782
 
3783
+ // 11c. Module runtime: runtime.ts (SPEC-072)
3784
+ {
3785
+ const moduleRuntime = resolveConventionFile(discovered.resolve('runtime.ts'), 'RUNTIME', modId, importIdRef, imports, runtimeImports)
3786
+ if (moduleRuntime) moduleRuntimeImportName = moduleRuntime.importName
3787
+ }
3788
+
3775
3789
  // 12. Custom fields: data/fields.ts
3776
3790
  {
3777
3791
  const fields = resolveConventionFile(discovered.resolve('data/fields.ts'), 'F', modId, importIdRef, imports, runtimeImports)
@@ -3931,6 +3945,9 @@ async function generateModuleRegistryFromDiscovery(options: ModuleRegistryRender
3931
3945
  if (setupImportName) {
3932
3946
  setupAndBeyondEntries.push({ name: 'setup', value: buildLegacyModuleSetupValue(setupImportName) })
3933
3947
  }
3948
+ if (moduleRuntimeImportName) {
3949
+ setupAndBeyondEntries.push({ name: 'runtime', value: buildLegacyModuleRuntimeValue(moduleRuntimeImportName) })
3950
+ }
3934
3951
  if (encryptionImportName) {
3935
3952
  setupAndBeyondEntries.push({
3936
3953
  name: 'defaultEncryptionMaps',
@@ -4328,6 +4345,7 @@ async function generateModuleRegistryAppFromDiscovery(options: ModuleRegistryRen
4328
4345
  let customEntitiesImportName: string | null = null
4329
4346
  let dashboardWidgetsValue: WriterFunction = emptyArray()
4330
4347
  let setupImportName: string | null = null
4348
+ let moduleRuntimeImportName: string | null = null
4331
4349
  let encryptionImportName: string | null = null
4332
4350
  let integrationImportName: string | null = null
4333
4351
 
@@ -4352,6 +4370,13 @@ async function generateModuleRegistryAppFromDiscovery(options: ModuleRegistryRen
4352
4370
  if (setup) setupImportName = setup.importName
4353
4371
  }
4354
4372
 
4373
+ // Module runtime: runtime.ts (SPEC-072)
4374
+ {
4375
+ const moduleRuntime = resolveConventionFile(discovered.resolve('runtime.ts'), 'RUNTIME', modId, importIdRef, imports)
4376
+ if (moduleRuntime) bootstrapImports.push(buildImportStatement(`* as ${moduleRuntime.importName}`, moduleRuntime.importPath))
4377
+ if (moduleRuntime) moduleRuntimeImportName = moduleRuntime.importName
4378
+ }
4379
+
4355
4380
  {
4356
4381
  const encryption = resolveConventionFile(discovered.resolve('encryption.ts'), 'ENCRYPTION', modId, importIdRef, imports)
4357
4382
  if (encryption) bootstrapImports.push(buildImportStatement(`* as ${encryption.importName}`, encryption.importPath))
@@ -4543,6 +4568,16 @@ async function generateModuleRegistryAppFromDiscovery(options: ModuleRegistryRen
4543
4568
  }),
4544
4569
  })
4545
4570
  }
4571
+ if (moduleRuntimeImportName) {
4572
+ moduleEntries.push({
4573
+ name: 'runtime',
4574
+ value: namespaceFallback({
4575
+ importName: moduleRuntimeImportName,
4576
+ members: ['default', 'runtime'],
4577
+ fallback: identifier('undefined'),
4578
+ }),
4579
+ })
4580
+ }
4546
4581
  if (encryptionImportName) {
4547
4582
  moduleEntries.push({
4548
4583
  name: 'defaultEncryptionMaps',
@@ -4744,6 +4779,7 @@ async function generateModuleRegistryCliFromDiscovery(options: ModuleRegistryRen
4744
4779
  let vectorImportName: string | null = null
4745
4780
  let dashboardWidgetsValue: WriterFunction = emptyArray()
4746
4781
  let setupImportName: string | null = null
4782
+ let moduleRuntimeImportName: string | null = null
4747
4783
  let encryptionImportName: string | null = null
4748
4784
  let integrationImportName: string | null = null
4749
4785
 
@@ -4768,6 +4804,12 @@ async function generateModuleRegistryCliFromDiscovery(options: ModuleRegistryRen
4768
4804
  if (setup) setupImportName = setup.importName
4769
4805
  }
4770
4806
 
4807
+ // Module runtime: runtime.ts (SPEC-072)
4808
+ {
4809
+ const moduleRuntime = resolveConventionFile(discovered.resolve('runtime.ts'), 'RUNTIME', modId, importIdRef, imports)
4810
+ if (moduleRuntime) moduleRuntimeImportName = moduleRuntime.importName
4811
+ }
4812
+
4771
4813
  // Module encryption defaults: encryption.ts
4772
4814
  {
4773
4815
  const encryption = resolveConventionFile(discovered.resolve('encryption.ts'), 'ENCRYPTION', modId, importIdRef, imports)
@@ -4937,6 +4979,16 @@ async function generateModuleRegistryCliFromDiscovery(options: ModuleRegistryRen
4937
4979
  }),
4938
4980
  })
4939
4981
  }
4982
+ if (moduleRuntimeImportName) {
4983
+ moduleEntries.push({
4984
+ name: 'runtime',
4985
+ value: namespaceFallback({
4986
+ importName: moduleRuntimeImportName,
4987
+ members: ['default', 'runtime'],
4988
+ fallback: identifier('undefined'),
4989
+ }),
4990
+ })
4991
+ }
4940
4992
  if (encryptionImportName) {
4941
4993
  moduleEntries.push({
4942
4994
  name: 'defaultEncryptionMaps',