@open-mercato/cli 0.6.1-develop.3291.1.6fad645fd0 → 0.6.1

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.
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Lazy local scheduler supervisor.
3
+ *
4
+ * Keeps the server runtime from spawning the local scheduler polling engine
5
+ * until at least one enabled schedule exists. The scheduler process itself is
6
+ * still the existing `mercato scheduler start` command, so schedule execution,
7
+ * locking, RBAC checks, and queue/command targets remain unchanged.
8
+ */
9
+
10
+ import { spawn as nodeSpawn } from 'node:child_process'
11
+ import type { ChildProcess, SpawnOptions } from 'node:child_process'
12
+ import { getSslConfig } from '@open-mercato/shared/lib/db/ssl'
13
+
14
+ export type SchedulerScheduleProbeResult = {
15
+ enabledSchedules: number
16
+ dueSchedules: number
17
+ error?: boolean
18
+ errorMessage?: string
19
+ }
20
+
21
+ export type LazySchedulerSupervisorProbeFn = (
22
+ env: NodeJS.ProcessEnv,
23
+ ) => Promise<SchedulerScheduleProbeResult>
24
+
25
+ export type LazySchedulerSupervisorSpawnFn = (
26
+ command: string,
27
+ args: readonly string[],
28
+ options: SpawnOptions,
29
+ ) => ChildProcess
30
+
31
+ export type LazySchedulerSupervisorOptions = {
32
+ mercatoBin: string
33
+ appDir: string
34
+ runtimeEnv: NodeJS.ProcessEnv
35
+ pollMs: number
36
+ restartOnUnexpectedExit: boolean
37
+ spawnFn?: LazySchedulerSupervisorSpawnFn
38
+ probeFn?: LazySchedulerSupervisorProbeFn
39
+ logger?: Pick<Console, 'log' | 'warn' | 'error'>
40
+ onSpawn?: (child: ChildProcess) => void
41
+ onChildExit?: (code: number | null, signal: NodeJS.Signals | null) => void
42
+ }
43
+
44
+ export type LazySchedulerSupervisorHandle = {
45
+ readonly started: boolean
46
+ getActiveChild(): ChildProcess | undefined
47
+ close(): Promise<void>
48
+ done: Promise<void>
49
+ }
50
+
51
+ const PROBE_ERROR_LOG_THROTTLE_MS = 60_000
52
+
53
+ function coerceCount(value: unknown): number {
54
+ if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value))
55
+ if (typeof value === 'bigint') return Number(value)
56
+ if (typeof value === 'string') {
57
+ const parsed = Number(value)
58
+ return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : 0
59
+ }
60
+ return 0
61
+ }
62
+
63
+ function isMissingScheduledJobsTableError(error: unknown): boolean {
64
+ return Boolean(
65
+ error
66
+ && typeof error === 'object'
67
+ && 'code' in error
68
+ && (error as { code?: unknown }).code === '42P01',
69
+ )
70
+ }
71
+
72
+ export async function probeEnabledSchedules(env: NodeJS.ProcessEnv = process.env): Promise<SchedulerScheduleProbeResult> {
73
+ const databaseUrl = env.DATABASE_URL?.trim()
74
+ if (!databaseUrl) {
75
+ return {
76
+ enabledSchedules: 0,
77
+ dueSchedules: 0,
78
+ error: true,
79
+ errorMessage: 'DATABASE_URL is not set',
80
+ }
81
+ }
82
+
83
+ const { Client } = await import('pg')
84
+ const client = new Client({
85
+ connectionString: databaseUrl,
86
+ ssl: getSslConfig(),
87
+ })
88
+
89
+ try {
90
+ await client.connect()
91
+ const result = await client.query(`
92
+ select
93
+ count(*)::int as enabled_schedules,
94
+ count(*) filter (where next_run_at <= now())::int as due_schedules
95
+ from scheduled_jobs
96
+ where is_enabled = true
97
+ and deleted_at is null
98
+ `)
99
+ const row = result.rows[0] ?? {}
100
+ return {
101
+ enabledSchedules: coerceCount(row.enabled_schedules),
102
+ dueSchedules: coerceCount(row.due_schedules),
103
+ error: false,
104
+ }
105
+ } catch (error) {
106
+ if (isMissingScheduledJobsTableError(error)) {
107
+ return {
108
+ enabledSchedules: 0,
109
+ dueSchedules: 0,
110
+ error: false,
111
+ }
112
+ }
113
+ return {
114
+ enabledSchedules: 0,
115
+ dueSchedules: 0,
116
+ error: true,
117
+ errorMessage: error instanceof Error ? error.message : String(error),
118
+ }
119
+ } finally {
120
+ await client.end().catch(() => undefined)
121
+ }
122
+ }
123
+
124
+ export function startLazySchedulerSupervisor(
125
+ options: LazySchedulerSupervisorOptions,
126
+ ): LazySchedulerSupervisorHandle {
127
+ const logger = options.logger ?? console
128
+ const probe = options.probeFn ?? probeEnabledSchedules
129
+ const spawnFn: LazySchedulerSupervisorSpawnFn =
130
+ options.spawnFn ?? ((command, args, opts) => nodeSpawn(command, args as string[], opts))
131
+
132
+ let stopping = false
133
+ let started = false
134
+ let starting = false
135
+ let activeChild: ChildProcess | undefined
136
+ let pollTimer: ReturnType<typeof setTimeout> | null = null
137
+ let lastProbeErrorLoggedAt = 0
138
+ let doneResolve: (() => void) | null = null
139
+ const done = new Promise<void>((resolve) => {
140
+ doneResolve = resolve
141
+ })
142
+
143
+ logger.log('[lazy-scheduler] Watching for enabled schedules before starting the polling engine.')
144
+
145
+ function logProbeError(message: string): void {
146
+ const now = Date.now()
147
+ if (now - lastProbeErrorLoggedAt < PROBE_ERROR_LOG_THROTTLE_MS) return
148
+ lastProbeErrorLoggedAt = now
149
+ logger.warn(`[lazy-scheduler] Schedule probe failed: ${message}`)
150
+ }
151
+
152
+ function spawnScheduler(): void {
153
+ if (stopping || activeChild || starting) return
154
+ starting = true
155
+ logger.log('[lazy-scheduler] Enabled schedule detected - starting scheduler polling engine.')
156
+
157
+ let child: ChildProcess
158
+ try {
159
+ child = spawnFn('node', [options.mercatoBin, 'scheduler', 'start'], {
160
+ stdio: 'inherit',
161
+ env: options.runtimeEnv,
162
+ cwd: options.appDir,
163
+ })
164
+ } catch (error) {
165
+ starting = false
166
+ const message = error instanceof Error ? error.message : String(error)
167
+ logger.error(`[lazy-scheduler] Failed to spawn scheduler: ${message}`)
168
+ return
169
+ }
170
+
171
+ activeChild = child
172
+ started = true
173
+ starting = false
174
+ options.onSpawn?.(child)
175
+
176
+ child.on('exit', (code, signal) => {
177
+ activeChild = undefined
178
+ options.onChildExit?.(code, signal)
179
+ if (stopping) return
180
+ const expected = signal === 'SIGTERM' || signal === 'SIGINT'
181
+ if (expected) return
182
+ const reason = code !== null ? `code ${code}` : `signal ${signal ?? 'unknown'}`
183
+ logger.warn(`[lazy-scheduler] Scheduler polling engine exited (${reason}).`)
184
+ if (!options.restartOnUnexpectedExit) return
185
+ void (async () => {
186
+ const result = await safeProbe()
187
+ if (!result || result.error) return
188
+ if (result.enabledSchedules > 0) {
189
+ logger.warn('[lazy-scheduler] Restarting scheduler because enabled schedules still exist.')
190
+ spawnScheduler()
191
+ }
192
+ })()
193
+ })
194
+ }
195
+
196
+ async function safeProbe(): Promise<SchedulerScheduleProbeResult | null> {
197
+ try {
198
+ return await probe(options.runtimeEnv)
199
+ } catch (error) {
200
+ const message = error instanceof Error ? error.message : String(error)
201
+ logProbeError(message)
202
+ return null
203
+ }
204
+ }
205
+
206
+ async function tickOnce(): Promise<void> {
207
+ if (stopping || activeChild || starting) return
208
+ const result = await safeProbe()
209
+ if (!result) return
210
+ if (result.error) {
211
+ if (result.errorMessage) logProbeError(result.errorMessage)
212
+ return
213
+ }
214
+ if (result.enabledSchedules > 0) {
215
+ spawnScheduler()
216
+ }
217
+ }
218
+
219
+ function scheduleNext(): void {
220
+ if (stopping || activeChild) return
221
+ pollTimer = setTimeout(() => {
222
+ void (async () => {
223
+ try {
224
+ await tickOnce()
225
+ } catch (error) {
226
+ const message = error instanceof Error ? error.message : String(error)
227
+ logger.warn(`[lazy-scheduler] Poll cycle failed: ${message}`)
228
+ } finally {
229
+ scheduleNext()
230
+ }
231
+ })()
232
+ }, options.pollMs)
233
+ pollTimer.unref?.()
234
+ }
235
+
236
+ void (async () => {
237
+ try {
238
+ await tickOnce()
239
+ } catch (error) {
240
+ const message = error instanceof Error ? error.message : String(error)
241
+ logger.warn(`[lazy-scheduler] Initial poll failed: ${message}`)
242
+ } finally {
243
+ scheduleNext()
244
+ }
245
+ })()
246
+
247
+ async function close(): Promise<void> {
248
+ if (stopping) {
249
+ await done
250
+ return
251
+ }
252
+ stopping = true
253
+ if (pollTimer) {
254
+ clearTimeout(pollTimer)
255
+ pollTimer = null
256
+ }
257
+
258
+ const child = activeChild
259
+ if (child) {
260
+ await new Promise<void>((resolve) => {
261
+ if (child.exitCode !== null || child.signalCode !== null) return resolve()
262
+ child.once('exit', () => resolve())
263
+ try {
264
+ if (!child.killed) child.kill('SIGTERM')
265
+ } catch (error) {
266
+ const message = error instanceof Error ? error.message : String(error)
267
+ logger.warn(`[lazy-scheduler] Failed to send SIGTERM to scheduler: ${message}`)
268
+ resolve()
269
+ }
270
+ })
271
+ activeChild = undefined
272
+ }
273
+
274
+ doneResolve?.()
275
+ doneResolve = null
276
+ }
277
+
278
+ return {
279
+ get started() {
280
+ return started
281
+ },
282
+ getActiveChild: () => activeChild,
283
+ close,
284
+ done,
285
+ }
286
+ }
@@ -413,6 +413,18 @@ describe('integration cache and options', () => {
413
413
  }),
414
414
  ).resolves.toBe(true)
415
415
 
416
+ await rm(sourceFile, { force: true })
417
+ await expect(
418
+ shouldReuseBuildArtifacts(120, 'integration', {
419
+ inputPaths: [sourceFile],
420
+ artifactPaths: [artifactPath],
421
+ cacheStatePath,
422
+ environmentFingerprint: 'enterprise=off',
423
+ projectRoot: tempRoot,
424
+ }),
425
+ ).resolves.toBe(false)
426
+
427
+ await writeFile(sourceFile, 'const value = 1')
416
428
  await writeFile(sourceFile, 'const value = 2')
417
429
  await expect(
418
430
  shouldReuseBuildArtifacts(120, 'integration', {
@@ -920,7 +920,15 @@ async function hasBuildInputChangesSince(
920
920
  }
921
921
  seenPaths.add(resolvedPath)
922
922
 
923
- const fileStat = await stat(resolvedPath)
923
+ let fileStat: Awaited<ReturnType<typeof stat>>
924
+ try {
925
+ fileStat = await stat(resolvedPath)
926
+ } catch (error) {
927
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
928
+ return true
929
+ }
930
+ throw error
931
+ }
924
932
  if (fileStat.isFile() && fileStat.mtimeMs > timestampMs) {
925
933
  return true
926
934
  }
package/src/mercato.ts CHANGED
@@ -15,6 +15,12 @@ import {
15
15
  resolveLazyRestart,
16
16
  } from './lib/auto-spawn-workers'
17
17
  import { startLazyWorkerSupervisor } from './lib/queue-worker-supervisor'
18
+ import {
19
+ resolveAutoSpawnSchedulerMode,
20
+ resolveLazySchedulerPollMs,
21
+ resolveLazySchedulerRestart,
22
+ } from './lib/auto-spawn-scheduler'
23
+ import { startLazySchedulerSupervisor } from './lib/scheduler-supervisor'
18
24
  import { parseModuleInstallArgs } from './lib/module-install-args'
19
25
  import { resolveNextBuildIdCandidate } from './lib/next-build-id'
20
26
  import { acquireServerStartLock } from './lib/server-start-lock'
@@ -1679,6 +1685,7 @@ export async function run(argv = process.argv) {
1679
1685
  let stopping = false
1680
1686
  let envChangePromiseResolve: ((result: DevServerRestartResult) => void) | null = null
1681
1687
  let activeLazySupervisor: ReturnType<typeof startLazyWorkerSupervisor> | null = null
1688
+ let activeLazySchedulerSupervisor: ReturnType<typeof startLazySchedulerSupervisor> | null = null
1682
1689
  const envReloader = createDevEnvReloader(appDir, process.env, initialProcessEnvironmentEntries)
1683
1690
 
1684
1691
  function cleanup() {
@@ -1691,6 +1698,9 @@ export async function run(argv = process.argv) {
1691
1698
  if (activeLazySupervisor) {
1692
1699
  void activeLazySupervisor.close().catch(() => undefined)
1693
1700
  }
1701
+ if (activeLazySchedulerSupervisor) {
1702
+ void activeLazySchedulerSupervisor.close().catch(() => undefined)
1703
+ }
1694
1704
  }
1695
1705
 
1696
1706
  async function cleanupAndWait() {
@@ -1713,6 +1723,14 @@ export async function run(argv = process.argv) {
1713
1723
  }
1714
1724
  activeLazySupervisor = null
1715
1725
  }
1726
+ if (activeLazySchedulerSupervisor) {
1727
+ try {
1728
+ await activeLazySchedulerSupervisor.close()
1729
+ } catch {
1730
+ // Scheduler supervisor close errors should not block dev runtime cleanup.
1731
+ }
1732
+ activeLazySchedulerSupervisor = null
1733
+ }
1716
1734
  // Safety net: remove Next.js dev lock file in case the child didn't clean up
1717
1735
  const lockFile = path.join(appDir, '.mercato', 'next', 'dev', 'lock')
1718
1736
  try {
@@ -1803,7 +1821,7 @@ export async function run(argv = process.argv) {
1803
1821
  envReloader.reload()
1804
1822
  const runtimeEnv = buildServerProcessEnvironment(process.env)
1805
1823
  const autoSpawnWorkersMode = resolveAutoSpawnWorkersMode(process.env)
1806
- const autoSpawnScheduler = process.env.AUTO_SPAWN_SCHEDULER !== 'false'
1824
+ const autoSpawnSchedulerMode = resolveAutoSpawnSchedulerMode(process.env)
1807
1825
  const queueStrategy = process.env.QUEUE_STRATEGY || 'local'
1808
1826
  const schedulerCommand = lookupModuleCommand(getCliModules(), 'scheduler', 'start')
1809
1827
  const managedExitPromises: Promise<DevServerExitResult>[] = [
@@ -1828,7 +1846,7 @@ export async function run(argv = process.argv) {
1828
1846
  restartOnUnexpectedExit: resolveLazyRestart(process.env),
1829
1847
  })
1830
1848
  } else {
1831
- console.log('[server] Starting workers for all queues...')
1849
+ console.log('[server] Eager worker auto-spawn enabled - starting workers for all queues...')
1832
1850
  const workerProcess = spawn('node', [mercatoBin, 'queue', 'worker', '--all'], {
1833
1851
  stdio: 'inherit',
1834
1852
  env: runtimeEnv,
@@ -1839,11 +1857,20 @@ export async function run(argv = process.argv) {
1839
1857
  }
1840
1858
  }
1841
1859
 
1842
- if (autoSpawnScheduler && queueStrategy === 'local') {
1860
+ if (autoSpawnSchedulerMode !== 'off' && queueStrategy === 'local') {
1843
1861
  if (schedulerCommand.status !== 'ok') {
1844
1862
  console.log(`[server] Skipping scheduler auto-start — ${describeMissingModuleCommand(schedulerCommand)}`)
1863
+ } else if (autoSpawnSchedulerMode === 'lazy') {
1864
+ console.log('[server] Lazy scheduler auto-spawn enabled - scheduler will start when an enabled schedule exists.')
1865
+ activeLazySchedulerSupervisor = startLazySchedulerSupervisor({
1866
+ mercatoBin,
1867
+ appDir,
1868
+ runtimeEnv,
1869
+ pollMs: resolveLazySchedulerPollMs(process.env),
1870
+ restartOnUnexpectedExit: resolveLazySchedulerRestart(process.env),
1871
+ })
1845
1872
  } else {
1846
- console.log('[server] Starting scheduler polling engine...')
1873
+ console.log('[server] Eager scheduler auto-spawn enabled - starting scheduler polling engine...')
1847
1874
  const schedulerProcess = spawn('node', [mercatoBin, 'scheduler', 'start'], {
1848
1875
  stdio: 'inherit',
1849
1876
  env: runtimeEnv,
@@ -1885,7 +1912,7 @@ export async function run(argv = process.argv) {
1885
1912
 
1886
1913
  const processes: ChildProcess[] = []
1887
1914
  const autoSpawnWorkersMode = resolveAutoSpawnWorkersMode(process.env)
1888
- const autoSpawnScheduler = process.env.AUTO_SPAWN_SCHEDULER !== 'false'
1915
+ const autoSpawnSchedulerMode = resolveAutoSpawnSchedulerMode(process.env)
1889
1916
  const queueStrategy = process.env.QUEUE_STRATEGY || 'local'
1890
1917
  const runtimeEnv = buildServerProcessEnvironment(process.env)
1891
1918
  const schedulerCommand = lookupModuleCommand(getCliModules(), 'scheduler', 'start')
@@ -1893,6 +1920,7 @@ export async function run(argv = process.argv) {
1893
1920
  port: runtimeEnv.PORT ?? process.env.PORT ?? null,
1894
1921
  })
1895
1922
  let activeLazySupervisor: ReturnType<typeof startLazyWorkerSupervisor> | null = null
1923
+ let activeLazySchedulerSupervisor: ReturnType<typeof startLazySchedulerSupervisor> | null = null
1896
1924
  let stopping = false
1897
1925
 
1898
1926
  function cleanup() {
@@ -1905,6 +1933,9 @@ export async function run(argv = process.argv) {
1905
1933
  if (activeLazySupervisor) {
1906
1934
  void activeLazySupervisor.close().catch(() => undefined)
1907
1935
  }
1936
+ if (activeLazySchedulerSupervisor) {
1937
+ void activeLazySchedulerSupervisor.close().catch(() => undefined)
1938
+ }
1908
1939
  }
1909
1940
 
1910
1941
  async function cleanupAndWait() {
@@ -1926,6 +1957,14 @@ export async function run(argv = process.argv) {
1926
1957
  }
1927
1958
  activeLazySupervisor = null
1928
1959
  }
1960
+ if (activeLazySchedulerSupervisor) {
1961
+ try {
1962
+ await activeLazySchedulerSupervisor.close()
1963
+ } catch {
1964
+ // Scheduler supervisor close errors should not block server shutdown.
1965
+ }
1966
+ activeLazySchedulerSupervisor = null
1967
+ }
1929
1968
  }
1930
1969
 
1931
1970
  process.on('SIGTERM', () => {
@@ -1972,7 +2011,7 @@ export async function run(argv = process.argv) {
1972
2011
  restartOnUnexpectedExit: resolveLazyRestart(process.env),
1973
2012
  })
1974
2013
  } else {
1975
- console.log('[server] Starting workers for all queues...')
2014
+ console.log('[server] Eager worker auto-spawn enabled - starting workers for all queues...')
1976
2015
  const workerProcess = spawn('node', [mercatoBin, 'queue', 'worker', '--all'], {
1977
2016
  stdio: 'inherit',
1978
2017
  env: runtimeEnv,
@@ -1983,11 +2022,20 @@ export async function run(argv = process.argv) {
1983
2022
  }
1984
2023
  }
1985
2024
 
1986
- if (autoSpawnScheduler && queueStrategy === 'local') {
2025
+ if (autoSpawnSchedulerMode !== 'off' && queueStrategy === 'local') {
1987
2026
  if (schedulerCommand.status !== 'ok') {
1988
2027
  console.log(`[server] Skipping scheduler auto-start — ${describeMissingModuleCommand(schedulerCommand)}`)
2028
+ } else if (autoSpawnSchedulerMode === 'lazy') {
2029
+ console.log('[server] Lazy scheduler auto-spawn enabled - scheduler will start when an enabled schedule exists.')
2030
+ activeLazySchedulerSupervisor = startLazySchedulerSupervisor({
2031
+ mercatoBin,
2032
+ appDir,
2033
+ runtimeEnv,
2034
+ pollMs: resolveLazySchedulerPollMs(process.env),
2035
+ restartOnUnexpectedExit: resolveLazySchedulerRestart(process.env),
2036
+ })
1989
2037
  } else {
1990
- console.log('[server] Starting scheduler polling engine...')
2038
+ console.log('[server] Eager scheduler auto-spawn enabled - starting scheduler polling engine...')
1991
2039
  const schedulerProcess = spawn('node', [mercatoBin, 'scheduler', 'start'], {
1992
2040
  stdio: 'inherit',
1993
2041
  env: runtimeEnv,