@open-mercato/cli 0.7.1-develop.7185.1.0f280ef1f1 → 0.7.1-develop.7186.1.6e080a5017
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.
- package/.turbo/turbo-build.log +2 -2
- package/AGENTS.md +1 -1
- package/dist/agentic/guides/module-facts.json +136 -136
- package/dist/agentic/guides/module-facts.v2.json +136 -136
- package/dist/agentic/guides/modules/ai_assistant/index.md +1 -1
- package/dist/agentic/guides/modules/api_docs/index.md +1 -1
- package/dist/agentic/guides/modules/api_keys/index.md +1 -1
- package/dist/agentic/guides/modules/attachments/index.md +1 -1
- package/dist/agentic/guides/modules/audit_logs/index.md +1 -1
- package/dist/agentic/guides/modules/auth/index.md +1 -1
- package/dist/agentic/guides/modules/business_rules/index.md +1 -1
- package/dist/agentic/guides/modules/catalog/index.md +1 -1
- package/dist/agentic/guides/modules/channel_apns/index.md +1 -1
- package/dist/agentic/guides/modules/channel_discord/index.md +1 -1
- package/dist/agentic/guides/modules/channel_expo/index.md +1 -1
- package/dist/agentic/guides/modules/channel_fcm/index.md +1 -1
- package/dist/agentic/guides/modules/channel_gmail/index.md +1 -1
- package/dist/agentic/guides/modules/channel_imap/index.md +1 -1
- package/dist/agentic/guides/modules/channel_resend/index.md +1 -1
- package/dist/agentic/guides/modules/channel_ses/index.md +1 -1
- package/dist/agentic/guides/modules/checkout/index.md +1 -1
- package/dist/agentic/guides/modules/communication_channels/index.md +1 -1
- package/dist/agentic/guides/modules/configs/index.md +1 -1
- package/dist/agentic/guides/modules/content/index.md +1 -1
- package/dist/agentic/guides/modules/currencies/index.md +1 -1
- package/dist/agentic/guides/modules/customer_accounts/index.md +1 -1
- package/dist/agentic/guides/modules/customers/index.md +1 -1
- package/dist/agentic/guides/modules/dashboards/index.md +1 -1
- package/dist/agentic/guides/modules/data_sync/index.md +1 -1
- package/dist/agentic/guides/modules/design_system/index.md +1 -1
- package/dist/agentic/guides/modules/devices/index.md +1 -1
- package/dist/agentic/guides/modules/dictionaries/index.md +1 -1
- package/dist/agentic/guides/modules/directory/index.md +1 -1
- package/dist/agentic/guides/modules/documents/index.md +1 -1
- package/dist/agentic/guides/modules/entities/index.md +1 -1
- package/dist/agentic/guides/modules/eudr/index.md +1 -1
- package/dist/agentic/guides/modules/events/index.md +1 -1
- package/dist/agentic/guides/modules/feature_toggles/index.md +1 -1
- package/dist/agentic/guides/modules/gateway_stripe/index.md +1 -1
- package/dist/agentic/guides/modules/generators/index.md +1 -1
- package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
- package/dist/agentic/guides/modules/integrations/index.md +1 -1
- package/dist/agentic/guides/modules/messages/index.md +1 -1
- package/dist/agentic/guides/modules/notifications/index.md +1 -1
- package/dist/agentic/guides/modules/onboarding/index.md +1 -1
- package/dist/agentic/guides/modules/payment_gateways/index.md +1 -1
- package/dist/agentic/guides/modules/perspectives/index.md +1 -1
- package/dist/agentic/guides/modules/phone_calls/index.md +1 -1
- package/dist/agentic/guides/modules/planner/index.md +1 -1
- package/dist/agentic/guides/modules/portal/index.md +1 -1
- package/dist/agentic/guides/modules/progress/index.md +1 -1
- package/dist/agentic/guides/modules/push_notifications/index.md +1 -1
- package/dist/agentic/guides/modules/query_index/index.md +1 -1
- package/dist/agentic/guides/modules/record_locks/index.md +1 -1
- package/dist/agentic/guides/modules/resources/index.md +1 -1
- package/dist/agentic/guides/modules/sales/index.md +1 -1
- package/dist/agentic/guides/modules/scheduler/index.md +1 -1
- package/dist/agentic/guides/modules/search/index.md +1 -1
- package/dist/agentic/guides/modules/security/index.md +1 -1
- package/dist/agentic/guides/modules/shipping_carriers/index.md +1 -1
- package/dist/agentic/guides/modules/sso/index.md +1 -1
- package/dist/agentic/guides/modules/staff/index.md +1 -1
- package/dist/agentic/guides/modules/storage_s3/index.md +1 -1
- package/dist/agentic/guides/modules/sync_akeneo/index.md +1 -1
- package/dist/agentic/guides/modules/sync_excel/index.md +1 -1
- package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
- package/dist/agentic/guides/modules/tillio/index.md +1 -1
- package/dist/agentic/guides/modules/translations/index.md +1 -1
- package/dist/agentic/guides/modules/warranty_claims/index.md +1 -1
- package/dist/agentic/guides/modules/webhooks/index.md +1 -1
- package/dist/agentic/guides/modules/wms/index.md +1 -1
- package/dist/agentic/guides/modules/workflows/index.md +1 -1
- package/dist/agentic/guides/reference-module-facts.json +1 -1
- package/dist/agentic/guides/upstream/BACKWARD_COMPATIBILITY.md +1 -0
- package/dist/agentic/guides/upstream/manifest.json +2 -2
- package/dist/lib/generate-watch-structure.js +1 -0
- package/dist/lib/generate-watch-structure.js.map +2 -2
- package/dist/lib/generators/module-registry.js +45 -0
- package/dist/lib/generators/module-registry.js.map +2 -2
- package/dist/lib/module-runtimes.js +99 -0
- package/dist/lib/module-runtimes.js.map +7 -0
- package/dist/mercato.js +9 -0
- package/dist/mercato.js.map +2 -2
- package/package.json +6 -6
- package/src/__tests__/module-runtimes.test.ts +267 -0
- package/src/lib/__tests__/generate-watch-structure.test.ts +18 -0
- package/src/lib/generate-watch-structure.ts +1 -0
- package/src/lib/generators/__tests__/registry-variant-parity.test.ts +28 -1
- package/src/lib/generators/module-registry.ts +52 -0
- package/src/lib/module-runtimes.ts +195 -0
- package/src/mercato.ts +20 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Starting and stopping the module runtimes declared by `runtime.ts` (SPEC-072).
|
|
2
|
+
//
|
|
3
|
+
// Kept out of mercato.ts so the contract can be tested directly: the ordering, the failure
|
|
4
|
+
// semantics, the shutdown and both timeouts are the whole substance of this hook, and none of
|
|
5
|
+
// them are reachable through a CLI command in a test.
|
|
6
|
+
|
|
7
|
+
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
8
|
+
import {
|
|
9
|
+
moduleRuntimeAppliesTo,
|
|
10
|
+
type ModuleRuntime,
|
|
11
|
+
type ModuleRuntimeHandle,
|
|
12
|
+
type ModuleRuntimeRole,
|
|
13
|
+
} from '@open-mercato/shared/modules/runtime'
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_START_TIMEOUT_MS = 30_000
|
|
16
|
+
export const DEFAULT_STOP_TIMEOUT_MS = 30_000
|
|
17
|
+
|
|
18
|
+
export const START_TIMEOUT_ENV_VAR = 'OM_MODULE_RUNTIME_START_TIMEOUT_MS'
|
|
19
|
+
export const STOP_TIMEOUT_ENV_VAR = 'OM_MODULE_RUNTIME_STOP_TIMEOUT_MS'
|
|
20
|
+
|
|
21
|
+
export type ModuleRuntimeCarrier = { id: string; runtime?: ModuleRuntime }
|
|
22
|
+
|
|
23
|
+
export type StartModuleRuntimesOptions = {
|
|
24
|
+
modules: ModuleRuntimeCarrier[]
|
|
25
|
+
container: AppContainer
|
|
26
|
+
role: ModuleRuntimeRole
|
|
27
|
+
/** One line per start and stop. Defaults to console. */
|
|
28
|
+
log?: (message: string) => void
|
|
29
|
+
/** Defaults to `OM_MODULE_RUNTIME_START_TIMEOUT_MS`, then 30s. */
|
|
30
|
+
startTimeoutMs?: number
|
|
31
|
+
/** Defaults to `OM_MODULE_RUNTIME_STOP_TIMEOUT_MS`, then 30s. */
|
|
32
|
+
stopTimeoutMs?: number
|
|
33
|
+
/** Where the timeout overrides are read from. Defaults to `process.env`. */
|
|
34
|
+
env?: NodeJS.ProcessEnv
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type StartedModuleRuntimes = {
|
|
38
|
+
/** Module ids whose runtime is running, in start order. */
|
|
39
|
+
started: string[]
|
|
40
|
+
/** Aborts the shared signal, then stops each runtime in reverse start order. Idempotent. */
|
|
41
|
+
stop(): Promise<void>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class ModuleRuntimeTimeoutError extends Error {
|
|
45
|
+
constructor(moduleId: string, phase: 'start' | 'stop', timeoutMs: number) {
|
|
46
|
+
super(`Module "${moduleId}" did not ${phase} within ${timeoutMs}ms.`)
|
|
47
|
+
this.name = 'ModuleRuntimeTimeoutError'
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reads a timeout override, falling back to `fallbackMs` when it is unset or not a usable number.
|
|
53
|
+
*
|
|
54
|
+
* A start timeout is fatal by design (contract §3), so an operator whose runtime legitimately needs
|
|
55
|
+
* longer than the default — acquiring a lease, waiting on a broker — needs a supported way to say
|
|
56
|
+
* so rather than a worker that exits non-zero on every boot. A malformed value falls back loudly:
|
|
57
|
+
* refusing to start over a typo in a tuning knob would be worse than the default it replaces.
|
|
58
|
+
*/
|
|
59
|
+
function resolveTimeoutMs(
|
|
60
|
+
env: NodeJS.ProcessEnv,
|
|
61
|
+
name: string,
|
|
62
|
+
fallbackMs: number,
|
|
63
|
+
log: (message: string) => void,
|
|
64
|
+
): number {
|
|
65
|
+
const raw = env[name]
|
|
66
|
+
if (raw == null || raw.trim() === '') return fallbackMs
|
|
67
|
+
const parsed = Number(raw)
|
|
68
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
69
|
+
log(`[runtime] ignoring ${name}="${raw}": expected a positive number of milliseconds, using ${fallbackMs}ms`)
|
|
70
|
+
return fallbackMs
|
|
71
|
+
}
|
|
72
|
+
return parsed
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Rejects if `work` outlives `timeoutMs`.
|
|
77
|
+
*
|
|
78
|
+
* The timer is always cleared, including on the winning path: an uncleared timer keeps the event
|
|
79
|
+
* loop alive, so a CLI command that finished its work would hang until the timeout elapsed — the
|
|
80
|
+
* kind of bug that only shows up as "the process takes 30 seconds to exit".
|
|
81
|
+
*/
|
|
82
|
+
async function withTimeout<T>(work: Promise<T>, timeoutMs: number, onTimeout: () => Error): Promise<T> {
|
|
83
|
+
let timer: NodeJS.Timeout | undefined
|
|
84
|
+
try {
|
|
85
|
+
return await Promise.race([
|
|
86
|
+
work,
|
|
87
|
+
new Promise<never>((_resolve, reject) => {
|
|
88
|
+
timer = setTimeout(() => reject(onTimeout()), timeoutMs)
|
|
89
|
+
}),
|
|
90
|
+
])
|
|
91
|
+
} finally {
|
|
92
|
+
if (timer) clearTimeout(timer)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Starts every module runtime that applies to this role, once.
|
|
98
|
+
*
|
|
99
|
+
* A throwing or timing-out `start` is fatal: the runtimes already started are stopped, and the
|
|
100
|
+
* error is rethrown for the caller to exit on. A process that came up without a runtime it was
|
|
101
|
+
* supposed to have looks healthy while doing nothing, which is worse than not coming up.
|
|
102
|
+
*/
|
|
103
|
+
export async function startModuleRuntimes(options: StartModuleRuntimesOptions): Promise<StartedModuleRuntimes> {
|
|
104
|
+
const log = options.log ?? ((message: string) => console.log(message))
|
|
105
|
+
const env = options.env ?? process.env
|
|
106
|
+
const startTimeoutMs = options.startTimeoutMs
|
|
107
|
+
?? resolveTimeoutMs(env, START_TIMEOUT_ENV_VAR, DEFAULT_START_TIMEOUT_MS, log)
|
|
108
|
+
const stopTimeoutMs = options.stopTimeoutMs
|
|
109
|
+
?? resolveTimeoutMs(env, STOP_TIMEOUT_ENV_VAR, DEFAULT_STOP_TIMEOUT_MS, log)
|
|
110
|
+
|
|
111
|
+
const controller = new AbortController()
|
|
112
|
+
const started: Array<{ id: string; handle: ModuleRuntimeHandle | void }> = []
|
|
113
|
+
|
|
114
|
+
// Sorted by module id so a failure is reproducible rather than dependent on registry order.
|
|
115
|
+
// Compared by code point rather than `localeCompare`, which depends on the host's default locale
|
|
116
|
+
// and ICU build — "reproducible" must not mean "on machines with the same locale".
|
|
117
|
+
//
|
|
118
|
+
// No dependency graph on purpose: a module that needs another module's runtime should depend on
|
|
119
|
+
// its service through DI, which already expresses that and already detects cycles.
|
|
120
|
+
const applicable = options.modules
|
|
121
|
+
.filter((m): m is ModuleRuntimeCarrier & { runtime: ModuleRuntime } =>
|
|
122
|
+
Boolean(m.runtime) && moduleRuntimeAppliesTo(m.runtime as ModuleRuntime, options.role))
|
|
123
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
124
|
+
|
|
125
|
+
const stopStarted = async (): Promise<void> => {
|
|
126
|
+
controller.abort()
|
|
127
|
+
for (const entry of [...started].reverse()) {
|
|
128
|
+
if (!entry.handle) continue
|
|
129
|
+
try {
|
|
130
|
+
await withTimeout(
|
|
131
|
+
Promise.resolve(entry.handle.stop()),
|
|
132
|
+
stopTimeoutMs,
|
|
133
|
+
() => new ModuleRuntimeTimeoutError(entry.id, 'stop', stopTimeoutMs),
|
|
134
|
+
)
|
|
135
|
+
log(`[runtime] stopped "${entry.id}"`)
|
|
136
|
+
} catch (error) {
|
|
137
|
+
// A shutdown that cannot finish must not become a shutdown that never finishes: report and
|
|
138
|
+
// carry on to the next runtime, so one stuck module cannot hold the process open.
|
|
139
|
+
log(`[runtime] "${entry.id}" failed to stop: ${error instanceof Error ? error.message : String(error)}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
started.length = 0
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const module of applicable) {
|
|
146
|
+
const startedAt = Date.now()
|
|
147
|
+
// Called through an async wrapper so a `start` that throws synchronously rejects rather than
|
|
148
|
+
// escaping the try below, which would skip stopping the runtimes already started.
|
|
149
|
+
const starting = (async () =>
|
|
150
|
+
module.runtime.start({ container: options.container, role: options.role, signal: controller.signal }))()
|
|
151
|
+
try {
|
|
152
|
+
const handle = await withTimeout(
|
|
153
|
+
starting,
|
|
154
|
+
startTimeoutMs,
|
|
155
|
+
() => new ModuleRuntimeTimeoutError(module.id, 'start', startTimeoutMs),
|
|
156
|
+
)
|
|
157
|
+
started.push({ id: module.id, handle })
|
|
158
|
+
log(`[runtime] started "${module.id}" (${options.role}, ${Date.now() - startedAt}ms)`)
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// A timed-out `start` keeps running: its handle was never pushed to `started`, so shutdown
|
|
161
|
+
// would not reach it. Trail the promise and release whatever it eventually hands back — and
|
|
162
|
+
// swallow a late rejection, which would otherwise surface as an unhandled rejection long
|
|
163
|
+
// after the error below has already been reported.
|
|
164
|
+
if (error instanceof ModuleRuntimeTimeoutError) {
|
|
165
|
+
void starting
|
|
166
|
+
.then((handle) => handle?.stop())
|
|
167
|
+
.catch(() => {})
|
|
168
|
+
}
|
|
169
|
+
await stopStarted()
|
|
170
|
+
throw error
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let stopping: Promise<void> | null = null
|
|
175
|
+
return {
|
|
176
|
+
started: started.map((entry) => entry.id),
|
|
177
|
+
stop: () => (stopping ??= stopStarted()),
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* True when this process is a Next production build.
|
|
183
|
+
*
|
|
184
|
+
* A build evaluates application code and must not acquire brokers, sockets or leases — and must
|
|
185
|
+
* certainly not briefly own work it cannot finish. Hosts have been carrying this check by hand;
|
|
186
|
+
* it belongs with the runner.
|
|
187
|
+
*
|
|
188
|
+
* At its only call site today — `mercato queue worker --all` — it is always false: `NEXT_PHASE` is
|
|
189
|
+
* set by Next, not by a worker process. It guards the entry that matters once the `server` role
|
|
190
|
+
* lands, where module code really is evaluated during `next build`; it is here so that entry
|
|
191
|
+
* inherits the check rather than reinventing it.
|
|
192
|
+
*/
|
|
193
|
+
export function isProductionBuildPhase(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
194
|
+
return env.NEXT_PHASE === 'phase-production-build'
|
|
195
|
+
}
|
package/src/mercato.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Commands that need to run before generation (e.g., `init`) handle missing modules gracefully.
|
|
3
3
|
|
|
4
4
|
import { registerWorkerShutdownHook, runWorker } from '@open-mercato/queue/worker'
|
|
5
|
+
import { isProductionBuildPhase, startModuleRuntimes } from './lib/module-runtimes'
|
|
5
6
|
import type { Module, ModuleWorker } from '@open-mercato/shared/modules/registry'
|
|
6
7
|
import { getCliModules, hasCliModules, registerCliModules } from './registry'
|
|
7
8
|
export { getCliModules, hasCliModules, registerCliModules }
|
|
@@ -1741,6 +1742,25 @@ export async function run(argv = process.argv) {
|
|
|
1741
1742
|
console.log('[worker] Local scheduler started in the shared worker process.')
|
|
1742
1743
|
}
|
|
1743
1744
|
|
|
1745
|
+
// SPEC-072 — module runtimes, once per process. After the queue workers are bound so
|
|
1746
|
+
// a runtime may rely on them, and before the process announces itself as up.
|
|
1747
|
+
//
|
|
1748
|
+
// Only on `--all`: that is the process a deployment runs and the one `server start`
|
|
1749
|
+
// spawns. A single-queue worker is a targeted invocation, and starting every module's
|
|
1750
|
+
// runtime in each of N of them would run N copies of each.
|
|
1751
|
+
//
|
|
1752
|
+
// The build-phase guard is always false here — NEXT_PHASE is set by Next, not by a
|
|
1753
|
+
// worker — and is kept so the check lives with the runner rather than being reinvented
|
|
1754
|
+
// by the Next-side entry, where module code really is evaluated during `next build`.
|
|
1755
|
+
if (!isProductionBuildPhase()) {
|
|
1756
|
+
const runtimes = await startModuleRuntimes({
|
|
1757
|
+
modules: getCliModules(),
|
|
1758
|
+
container: await createRequestContainer(),
|
|
1759
|
+
role: 'worker',
|
|
1760
|
+
})
|
|
1761
|
+
if (runtimes.started.length > 0) registerWorkerShutdownHook(() => runtimes.stop())
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1744
1764
|
console.log('[worker] All workers started. Press Ctrl+C to stop')
|
|
1745
1765
|
|
|
1746
1766
|
// Keep the process alive
|