@open-mercato/cli 0.6.1-develop.3287.1.450f4ffb56 → 0.6.1-develop.3306.1.9ad9ff2526

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cli",
3
- "version": "0.6.1-develop.3287.1.450f4ffb56",
3
+ "version": "0.6.1-develop.3306.1.9ad9ff2526",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -59,8 +59,8 @@
59
59
  "@mikro-orm/decorators": "^7.0.15",
60
60
  "@mikro-orm/migrations": "^7.0.15",
61
61
  "@mikro-orm/postgresql": "^7.0.15",
62
- "@open-mercato/queue": "0.6.1-develop.3287.1.450f4ffb56",
63
- "@open-mercato/shared": "0.6.1-develop.3287.1.450f4ffb56",
62
+ "@open-mercato/queue": "0.6.1-develop.3306.1.9ad9ff2526",
63
+ "@open-mercato/shared": "0.6.1-develop.3306.1.9ad9ff2526",
64
64
  "cross-spawn": "^7.0.6",
65
65
  "pg": "8.20.0",
66
66
  "semver": "^7.8.0",
@@ -70,10 +70,10 @@
70
70
  "typescript": "^5.9.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@open-mercato/shared": "0.6.1-develop.3287.1.450f4ffb56"
73
+ "@open-mercato/shared": "0.6.1-develop.3306.1.9ad9ff2526"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.1-develop.3287.1.450f4ffb56",
76
+ "@open-mercato/shared": "0.6.1-develop.3306.1.9ad9ff2526",
77
77
  "@types/jest": "^30.0.0",
78
78
  "jest": "^30.4.2",
79
79
  "ts-jest": "^29.4.9"
@@ -62,6 +62,16 @@ const eventsWorkerFixture: Pick<Module, 'id' | 'workers'> = {
62
62
  ],
63
63
  }
64
64
 
65
+ const schedulerCliFixture: Pick<Module, 'id' | 'cli'> = {
66
+ id: 'scheduler',
67
+ cli: [
68
+ {
69
+ command: 'start',
70
+ run: jest.fn(),
71
+ },
72
+ ],
73
+ }
74
+
65
75
  describe('mercato CLI module registration', () => {
66
76
  beforeEach(() => {
67
77
  // Reset module state by re-importing
@@ -620,6 +630,7 @@ describe('server dev managed process exits', () => {
620
630
  const originalAutoSpawnScheduler = process.env.AUTO_SPAWN_SCHEDULER
621
631
  const originalAutoSpawnWorkers = process.env.AUTO_SPAWN_WORKERS
622
632
  const originalLazy = process.env.OM_AUTO_SPAWN_WORKERS_LAZY
633
+ const originalLazyScheduler = process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
623
634
 
624
635
  beforeEach(() => {
625
636
  jest.restoreAllMocks()
@@ -627,6 +638,7 @@ describe('server dev managed process exits', () => {
627
638
  process.env.AUTO_SPAWN_SCHEDULER = 'false'
628
639
  process.env.AUTO_SPAWN_WORKERS = 'true'
629
640
  delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
641
+ delete process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
630
642
  })
631
643
 
632
644
  afterEach(() => {
@@ -636,8 +648,10 @@ describe('server dev managed process exits', () => {
636
648
  jest.dontMock('../lib/generators')
637
649
  jest.dontMock('../lib/resolver')
638
650
  jest.dontMock('../lib/queue-worker-supervisor')
651
+ jest.dontMock('../lib/scheduler-supervisor')
639
652
  jest.resetModules()
640
653
  delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
654
+ delete process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
641
655
  })
642
656
 
643
657
  afterAll(() => {
@@ -645,6 +659,8 @@ describe('server dev managed process exits', () => {
645
659
  process.env.AUTO_SPAWN_WORKERS = originalAutoSpawnWorkers
646
660
  if (originalLazy === undefined) delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
647
661
  else process.env.OM_AUTO_SPAWN_WORKERS_LAZY = originalLazy
662
+ if (originalLazyScheduler === undefined) delete process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
663
+ else process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY = originalLazyScheduler
648
664
  })
649
665
 
650
666
  it('skips scheduler auto-start when the module is not enabled', async () => {
@@ -801,12 +817,79 @@ describe('server dev managed process exits', () => {
801
817
  consoleErrorSpy.mockRestore()
802
818
  consoleLogSpy.mockRestore()
803
819
  })
820
+
821
+ it('starts the lazy scheduler supervisor instead of the scheduler process when OM_AUTO_SPAWN_SCHEDULER_LAZY=true', async () => {
822
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
823
+ const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation()
824
+ process.env.AUTO_SPAWN_SCHEDULER = 'true'
825
+ process.env.AUTO_SPAWN_WORKERS = 'false'
826
+ process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY = 'true'
827
+
828
+ jest.doMock('node:fs', () => {
829
+ const actual = jest.requireActual('node:fs')
830
+ return {
831
+ ...actual,
832
+ existsSync: jest.fn((candidate: string) =>
833
+ candidate.includes('next/dist/bin/next') || candidate.includes('@open-mercato/cli/bin/mercato'),
834
+ ),
835
+ unlinkSync: jest.fn(),
836
+ }
837
+ })
838
+ jest.doMock('../lib/generators', () => ({
839
+ generateModulePackageSources: jest.fn().mockResolvedValue(undefined),
840
+ }))
841
+ jest.doMock('../lib/resolver', () => ({
842
+ resolveEnvironment: () => ({
843
+ appDir: '/tmp/test-app',
844
+ rootDir: '/tmp/test-root',
845
+ }),
846
+ createResolver: () => ({}),
847
+ }))
848
+
849
+ const schedulerClose = jest.fn().mockResolvedValue(undefined)
850
+ const startLazySchedulerSupervisor = jest.fn(() => ({
851
+ started: false,
852
+ getActiveChild: () => undefined,
853
+ close: schedulerClose,
854
+ done: Promise.resolve(),
855
+ }))
856
+ jest.doMock('../lib/scheduler-supervisor', () => ({
857
+ startLazySchedulerSupervisor,
858
+ }))
859
+
860
+ jest.doMock('child_process', () =>
861
+ buildMockChildProcessModule((args) =>
862
+ args[0]?.includes('next/dist/bin/next') ? { code: null, signal: 'SIGTERM' } : undefined,
863
+ ),
864
+ )
865
+
866
+ const mercado = await import('../mercato')
867
+ mercado.registerCliModules([schedulerCliFixture as Module])
868
+
869
+ const exitCode = await mercado.run(['node', 'mercato', 'server', 'dev'])
870
+
871
+ expect(exitCode).toBe(0)
872
+ expect(startLazySchedulerSupervisor).toHaveBeenCalledTimes(1)
873
+ const supervisorCall = startLazySchedulerSupervisor.mock.calls[0][0]
874
+ expect(supervisorCall.pollMs).toBe(1000)
875
+ expect(supervisorCall.restartOnUnexpectedExit).toBe(true)
876
+ expect(schedulerClose).toHaveBeenCalled()
877
+
878
+ const { spawn } = await import('child_process')
879
+ const allSpawnCalls = (spawn as jest.Mock).mock.calls.map((call) => call[1] as string[])
880
+ const schedulerSpawn = allSpawnCalls.find((args) => args.slice(1).join(' ') === 'scheduler start')
881
+ expect(schedulerSpawn).toBeUndefined()
882
+
883
+ consoleErrorSpy.mockRestore()
884
+ consoleLogSpy.mockRestore()
885
+ })
804
886
  })
805
887
 
806
888
  describe('server start managed process exits', () => {
807
889
  const originalAutoSpawnScheduler = process.env.AUTO_SPAWN_SCHEDULER
808
890
  const originalAutoSpawnWorkers = process.env.AUTO_SPAWN_WORKERS
809
891
  const originalLazy = process.env.OM_AUTO_SPAWN_WORKERS_LAZY
892
+ const originalLazyScheduler = process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
810
893
 
811
894
  beforeEach(() => {
812
895
  jest.restoreAllMocks()
@@ -814,6 +897,7 @@ describe('server start managed process exits', () => {
814
897
  process.env.AUTO_SPAWN_SCHEDULER = 'false'
815
898
  process.env.AUTO_SPAWN_WORKERS = 'true'
816
899
  delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
900
+ delete process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
817
901
  })
818
902
 
819
903
  afterEach(() => {
@@ -822,8 +906,10 @@ describe('server start managed process exits', () => {
822
906
  jest.dontMock('../lib/resolver')
823
907
  jest.dontMock('../lib/server-start-lock')
824
908
  jest.dontMock('../lib/queue-worker-supervisor')
909
+ jest.dontMock('../lib/scheduler-supervisor')
825
910
  jest.resetModules()
826
911
  delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
912
+ delete process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
827
913
  })
828
914
 
829
915
  afterAll(() => {
@@ -831,6 +917,8 @@ describe('server start managed process exits', () => {
831
917
  process.env.AUTO_SPAWN_WORKERS = originalAutoSpawnWorkers
832
918
  if (originalLazy === undefined) delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
833
919
  else process.env.OM_AUTO_SPAWN_WORKERS_LAZY = originalLazy
920
+ if (originalLazyScheduler === undefined) delete process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
921
+ else process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY = originalLazyScheduler
834
922
  })
835
923
 
836
924
  it('skips scheduler auto-start when the module is not enabled', async () => {
@@ -0,0 +1,63 @@
1
+ import {
2
+ resolveAutoSpawnSchedulerEnabled,
3
+ resolveAutoSpawnSchedulerLazy,
4
+ resolveAutoSpawnSchedulerMode,
5
+ resolveLazySchedulerPollMs,
6
+ resolveLazySchedulerRestart,
7
+ } from '../auto-spawn-scheduler'
8
+
9
+ describe('auto scheduler spawn env resolution', () => {
10
+ it('defaults to eager scheduler auto-spawn', () => {
11
+ expect(resolveAutoSpawnSchedulerMode({})).toBe('eager')
12
+ expect(resolveAutoSpawnSchedulerEnabled({})).toBe(true)
13
+ expect(resolveAutoSpawnSchedulerLazy({})).toBe(false)
14
+ })
15
+
16
+ it('returns off when AUTO_SPAWN_SCHEDULER=false', () => {
17
+ expect(resolveAutoSpawnSchedulerMode({ AUTO_SPAWN_SCHEDULER: 'false' })).toBe('off')
18
+ })
19
+
20
+ it('returns off when OM_AUTO_SPAWN_SCHEDULER=false and legacy unset', () => {
21
+ expect(resolveAutoSpawnSchedulerMode({ OM_AUTO_SPAWN_SCHEDULER: 'false' })).toBe('off')
22
+ })
23
+
24
+ it('legacy AUTO_SPAWN_SCHEDULER=true wins over OM_AUTO_SPAWN_SCHEDULER=false', () => {
25
+ expect(resolveAutoSpawnSchedulerMode({
26
+ AUTO_SPAWN_SCHEDULER: 'true',
27
+ OM_AUTO_SPAWN_SCHEDULER: 'false',
28
+ })).toBe('eager')
29
+ })
30
+
31
+ it('returns lazy when scheduler is enabled and OM_AUTO_SPAWN_SCHEDULER_LAZY=true', () => {
32
+ expect(resolveAutoSpawnSchedulerMode({
33
+ AUTO_SPAWN_SCHEDULER: 'true',
34
+ OM_AUTO_SPAWN_SCHEDULER_LAZY: 'true',
35
+ })).toBe('lazy')
36
+ })
37
+
38
+ it('lazy is ignored when AUTO_SPAWN_SCHEDULER=false', () => {
39
+ expect(resolveAutoSpawnSchedulerMode({
40
+ AUTO_SPAWN_SCHEDULER: 'false',
41
+ OM_AUTO_SPAWN_SCHEDULER_LAZY: 'true',
42
+ })).toBe('off')
43
+ })
44
+
45
+ it('treats invalid AUTO_SPAWN_SCHEDULER values as default', () => {
46
+ expect(resolveAutoSpawnSchedulerMode({ AUTO_SPAWN_SCHEDULER: 'maybe' })).toBe('eager')
47
+ })
48
+
49
+ it('resolves and clamps the lazy scheduler poll interval', () => {
50
+ expect(resolveLazySchedulerPollMs({})).toBe(1000)
51
+ expect(resolveLazySchedulerPollMs({ OM_AUTO_SPAWN_SCHEDULER_LAZY_POLL_MS: '500' })).toBe(500)
52
+ expect(resolveLazySchedulerPollMs({ OM_AUTO_SPAWN_SCHEDULER_LAZY_POLL_MS: '50' })).toBe(250)
53
+ expect(resolveLazySchedulerPollMs({ OM_AUTO_SPAWN_SCHEDULER_LAZY_POLL_MS: 'abc' })).toBe(1000)
54
+ expect(resolveLazySchedulerPollMs({ OM_AUTO_SPAWN_SCHEDULER_LAZY_POLL_MS: '0' })).toBe(1000)
55
+ })
56
+
57
+ it('defaults lazy scheduler restart to enabled', () => {
58
+ expect(resolveLazySchedulerRestart({})).toBe(true)
59
+ expect(resolveLazySchedulerRestart({ OM_AUTO_SPAWN_SCHEDULER_LAZY_RESTART: 'false' })).toBe(false)
60
+ expect(resolveLazySchedulerRestart({ OM_AUTO_SPAWN_SCHEDULER_LAZY_RESTART: 'true' })).toBe(true)
61
+ expect(resolveLazySchedulerRestart({ OM_AUTO_SPAWN_SCHEDULER_LAZY_RESTART: 'maybe' })).toBe(true)
62
+ })
63
+ })
@@ -0,0 +1,199 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import type { ChildProcess } from 'node:child_process'
3
+ import {
4
+ startLazySchedulerSupervisor,
5
+ type LazySchedulerSupervisorProbeFn,
6
+ type LazySchedulerSupervisorSpawnFn,
7
+ } from '../scheduler-supervisor'
8
+
9
+ type FakeChild = ChildProcess & {
10
+ triggerExit: (code: number | null, signal?: NodeJS.Signals | null) => void
11
+ }
12
+
13
+ function createFakeChild(): FakeChild {
14
+ const child = new EventEmitter() as unknown as FakeChild
15
+ ;(child as any).stdout = null
16
+ ;(child as any).stderr = null
17
+ ;(child as any).pid = Math.floor(Math.random() * 100000) + 1
18
+ ;(child as any).killed = false
19
+ ;(child as any).exitCode = null
20
+ ;(child as any).signalCode = null
21
+ ;(child as any).kill = jest.fn((signal: NodeJS.Signals = 'SIGTERM') => {
22
+ if ((child as any).killed) return true
23
+ ;(child as any).killed = true
24
+ ;(child as any).signalCode = signal
25
+ process.nextTick(() => child.emit('exit', null, signal))
26
+ return true
27
+ })
28
+ child.triggerExit = (code, signal = null) => {
29
+ ;(child as any).exitCode = code
30
+ ;(child as any).signalCode = signal
31
+ child.emit('exit', code, signal)
32
+ }
33
+ return child
34
+ }
35
+
36
+ const silentLogger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() }
37
+
38
+ async function flushAsync(times = 5): Promise<void> {
39
+ for (let i = 0; i < times; i++) {
40
+ // eslint-disable-next-line no-await-in-loop
41
+ await Promise.resolve()
42
+ }
43
+ }
44
+
45
+ describe('startLazySchedulerSupervisor', () => {
46
+ it('does not spawn the scheduler while no enabled schedules exist', async () => {
47
+ const spawnFn = jest.fn() as unknown as jest.MockedFunction<LazySchedulerSupervisorSpawnFn>
48
+ const probeFn = jest.fn(async () => ({
49
+ enabledSchedules: 0,
50
+ dueSchedules: 0,
51
+ error: false,
52
+ })) as jest.MockedFunction<LazySchedulerSupervisorProbeFn>
53
+
54
+ const handle = startLazySchedulerSupervisor({
55
+ mercatoBin: '/tmp/mercato',
56
+ appDir: '/tmp/app',
57
+ runtimeEnv: { ...process.env },
58
+ pollMs: 250,
59
+ restartOnUnexpectedExit: true,
60
+ spawnFn,
61
+ probeFn,
62
+ logger: silentLogger,
63
+ })
64
+
65
+ await flushAsync(20)
66
+ expect(probeFn).toHaveBeenCalled()
67
+ expect(spawnFn).not.toHaveBeenCalled()
68
+ expect(handle.started).toBe(false)
69
+
70
+ await handle.close()
71
+ })
72
+
73
+ it('spawns the existing scheduler command after an enabled schedule appears', async () => {
74
+ const child = createFakeChild()
75
+ const spawnFn = jest.fn(() => child) as unknown as jest.MockedFunction<LazySchedulerSupervisorSpawnFn>
76
+ const probeFn = jest.fn(async () => ({
77
+ enabledSchedules: 1,
78
+ dueSchedules: 0,
79
+ error: false,
80
+ })) as jest.MockedFunction<LazySchedulerSupervisorProbeFn>
81
+
82
+ const handle = startLazySchedulerSupervisor({
83
+ mercatoBin: '/tmp/mercato',
84
+ appDir: '/tmp/app',
85
+ runtimeEnv: { ...process.env },
86
+ pollMs: 250,
87
+ restartOnUnexpectedExit: false,
88
+ spawnFn,
89
+ probeFn,
90
+ logger: silentLogger,
91
+ })
92
+
93
+ await flushAsync(20)
94
+
95
+ expect(spawnFn).toHaveBeenCalledTimes(1)
96
+ const [command, args] = spawnFn.mock.calls[0]
97
+ expect(command).toBe('node')
98
+ expect(args).toEqual(['/tmp/mercato', 'scheduler', 'start'])
99
+ expect(handle.started).toBe(true)
100
+
101
+ await flushAsync(20)
102
+ expect(spawnFn).toHaveBeenCalledTimes(1)
103
+
104
+ await handle.close()
105
+ })
106
+
107
+ it('does not spawn when the probe reports an error', async () => {
108
+ const spawnFn = jest.fn() as unknown as jest.MockedFunction<LazySchedulerSupervisorSpawnFn>
109
+ const probeFn = jest.fn(async () => ({
110
+ enabledSchedules: 0,
111
+ dueSchedules: 0,
112
+ error: true,
113
+ errorMessage: 'database unavailable',
114
+ })) as jest.MockedFunction<LazySchedulerSupervisorProbeFn>
115
+
116
+ const handle = startLazySchedulerSupervisor({
117
+ mercatoBin: '/tmp/mercato',
118
+ appDir: '/tmp/app',
119
+ runtimeEnv: { ...process.env },
120
+ pollMs: 250,
121
+ restartOnUnexpectedExit: true,
122
+ spawnFn,
123
+ probeFn,
124
+ logger: silentLogger,
125
+ })
126
+
127
+ await flushAsync(20)
128
+ expect(spawnFn).not.toHaveBeenCalled()
129
+
130
+ await handle.close()
131
+ })
132
+
133
+ it('terminates the scheduler child on close()', async () => {
134
+ const child = createFakeChild()
135
+ const spawnFn = jest.fn(() => child) as unknown as jest.MockedFunction<LazySchedulerSupervisorSpawnFn>
136
+ const probeFn = jest.fn(async () => ({
137
+ enabledSchedules: 1,
138
+ dueSchedules: 1,
139
+ error: false,
140
+ })) as jest.MockedFunction<LazySchedulerSupervisorProbeFn>
141
+
142
+ const handle = startLazySchedulerSupervisor({
143
+ mercatoBin: '/tmp/mercato',
144
+ appDir: '/tmp/app',
145
+ runtimeEnv: { ...process.env },
146
+ pollMs: 250,
147
+ restartOnUnexpectedExit: true,
148
+ spawnFn,
149
+ probeFn,
150
+ logger: silentLogger,
151
+ })
152
+
153
+ await flushAsync(20)
154
+ await handle.close()
155
+
156
+ expect((child as any).kill).toHaveBeenCalledWith('SIGTERM')
157
+ })
158
+
159
+ it('restarts an unexpectedly exited scheduler only while schedules still exist', async () => {
160
+ const firstChild = createFakeChild()
161
+ const secondChild = createFakeChild()
162
+ const spawnFn = jest
163
+ .fn()
164
+ .mockImplementationOnce(() => firstChild)
165
+ .mockImplementationOnce(() => secondChild) as unknown as jest.MockedFunction<LazySchedulerSupervisorSpawnFn>
166
+
167
+ let enabledSchedules = 1
168
+ const probeFn = jest.fn(async () => ({
169
+ enabledSchedules,
170
+ dueSchedules: enabledSchedules,
171
+ error: false,
172
+ })) as jest.MockedFunction<LazySchedulerSupervisorProbeFn>
173
+
174
+ const handle = startLazySchedulerSupervisor({
175
+ mercatoBin: '/tmp/mercato',
176
+ appDir: '/tmp/app',
177
+ runtimeEnv: { ...process.env },
178
+ pollMs: 250,
179
+ restartOnUnexpectedExit: true,
180
+ spawnFn,
181
+ probeFn,
182
+ logger: silentLogger,
183
+ })
184
+
185
+ await flushAsync(20)
186
+ expect(spawnFn).toHaveBeenCalledTimes(1)
187
+
188
+ firstChild.triggerExit(1, null)
189
+ await flushAsync(40)
190
+ expect(spawnFn).toHaveBeenCalledTimes(2)
191
+
192
+ enabledSchedules = 0
193
+ secondChild.triggerExit(1, null)
194
+ await flushAsync(40)
195
+ expect(spawnFn).toHaveBeenCalledTimes(2)
196
+
197
+ await handle.close()
198
+ })
199
+ })
@@ -0,0 +1,39 @@
1
+ import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
2
+
3
+ export type AutoSpawnSchedulerMode = 'off' | 'eager' | 'lazy'
4
+
5
+ export type AutoSpawnSchedulerEnvSource = Pick<NodeJS.ProcessEnv, string> | Record<string, string | undefined>
6
+
7
+ const DEFAULT_LAZY_SCHEDULER_POLL_MS = 1000
8
+ const MIN_LAZY_SCHEDULER_POLL_MS = 250
9
+
10
+ export function resolveAutoSpawnSchedulerEnabled(env: AutoSpawnSchedulerEnvSource = process.env): boolean {
11
+ const legacy = parseBooleanToken(env.AUTO_SPAWN_SCHEDULER)
12
+ if (legacy !== null) return legacy
13
+ const aliased = parseBooleanToken(env.OM_AUTO_SPAWN_SCHEDULER)
14
+ if (aliased !== null) return aliased
15
+ return true
16
+ }
17
+
18
+ export function resolveAutoSpawnSchedulerLazy(env: AutoSpawnSchedulerEnvSource = process.env): boolean {
19
+ return parseBooleanToken(env.OM_AUTO_SPAWN_SCHEDULER_LAZY) === true
20
+ }
21
+
22
+ export function resolveAutoSpawnSchedulerMode(env: AutoSpawnSchedulerEnvSource = process.env): AutoSpawnSchedulerMode {
23
+ if (!resolveAutoSpawnSchedulerEnabled(env)) return 'off'
24
+ if (resolveAutoSpawnSchedulerLazy(env)) return 'lazy'
25
+ return 'eager'
26
+ }
27
+
28
+ export function resolveLazySchedulerPollMs(env: AutoSpawnSchedulerEnvSource = process.env): number {
29
+ const raw = env.OM_AUTO_SPAWN_SCHEDULER_LAZY_POLL_MS
30
+ if (typeof raw !== 'string' || raw.trim() === '') return DEFAULT_LAZY_SCHEDULER_POLL_MS
31
+ const parsed = Number(raw)
32
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LAZY_SCHEDULER_POLL_MS
33
+ return Math.max(MIN_LAZY_SCHEDULER_POLL_MS, Math.floor(parsed))
34
+ }
35
+
36
+ export function resolveLazySchedulerRestart(env: AutoSpawnSchedulerEnvSource = process.env): boolean {
37
+ const parsed = parseBooleanToken(env.OM_AUTO_SPAWN_SCHEDULER_LAZY_RESTART)
38
+ return parsed === null ? true : parsed
39
+ }
@@ -5,11 +5,14 @@ import {
5
5
  getMigrationSnapshotName,
6
6
  shouldCreateInitialModuleMigration,
7
7
  resolveGeneratedMigrationPath,
8
+ dbGenerate,
8
9
  dbGreenfield,
9
10
  } from '../commands'
11
+ import { MetadataStorage } from '@mikro-orm/core'
10
12
  import fs from 'node:fs'
11
13
  import os from 'node:os'
12
14
  import path from 'node:path'
15
+ import type { ModuleEntry, PackageResolver } from '../../resolver'
13
16
 
14
17
  describe('db commands security', () => {
15
18
  describe('sanitizeModuleId', () => {
@@ -245,3 +248,102 @@ describe('db commands', () => {
245
248
  })
246
249
  })
247
250
  })
251
+
252
+ describe('dbGenerate metadata isolation (issue #1911)', () => {
253
+ // Regression test for https://github.com/open-mercato/open-mercato/issues/1911.
254
+ // Without per-iteration MetadataStorage.clear(), every module's migration
255
+ // accumulated the @Entity() decorator registrations from previously-loaded
256
+ // modules and therefore produced polluted CREATE TABLE statements. The fix
257
+ // is a single MetadataStorage.clear() call at the top of every iteration of
258
+ // the dbGenerate loop. The test verifies that the clear is invoked once per
259
+ // iteration and that it actually wipes any pre-existing global metadata
260
+ // entries (the practical pollution vector that caused the bug).
261
+
262
+ const tempDirs: string[] = []
263
+ let clearSpy: jest.SpyInstance
264
+
265
+ beforeEach(() => {
266
+ process.env.DATABASE_URL = process.env.DATABASE_URL || 'postgres://noop@localhost:5432/test'
267
+ MetadataStorage.clear()
268
+ clearSpy = jest.spyOn(MetadataStorage, 'clear')
269
+ })
270
+
271
+ afterEach(() => {
272
+ clearSpy.mockRestore()
273
+ for (const dir of tempDirs.splice(0)) {
274
+ try { fs.rmSync(dir, { recursive: true, force: true }) } catch {}
275
+ }
276
+ MetadataStorage.clear()
277
+ })
278
+
279
+ function createTempModule(id: string): string {
280
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `mercato-mod-${id}-`))
281
+ tempDirs.push(dir)
282
+ fs.mkdirSync(path.join(dir, 'data'), { recursive: true })
283
+ fs.mkdirSync(path.join(dir, 'migrations'), { recursive: true })
284
+ fs.writeFileSync(path.join(dir, 'migrations', '.snapshot-open-mercato.json'), '{}', 'utf8')
285
+ fs.writeFileSync(
286
+ path.join(dir, 'data', 'entities.ts'),
287
+ `export class TestEntity_${id} {}\n`,
288
+ 'utf8',
289
+ )
290
+ return dir
291
+ }
292
+
293
+ function createMockResolver(modules: { id: string; dir: string }[]): PackageResolver {
294
+ const entries: ModuleEntry[] = modules.map((m) => ({ id: m.id, from: '@app' as const }))
295
+ const byId = new Map(modules.map((m) => [m.id, m.dir]))
296
+ return {
297
+ isMonorepo: () => true,
298
+ getRootDir: () => '/tmp/test-root',
299
+ getAppDir: () => '/tmp/test-app',
300
+ getOutputDir: () => '/tmp/test-out',
301
+ getModulesConfigPath: () => '/tmp/test-root/modules.ts',
302
+ discoverPackages: () => [],
303
+ loadEnabledModules: () => entries,
304
+ getModulePaths: (entry: ModuleEntry) => {
305
+ const dir = byId.get(entry.id) ?? '/nonexistent'
306
+ return { appBase: dir, pkgBase: dir }
307
+ },
308
+ getModuleImportBase: (entry: ModuleEntry) => ({
309
+ appBase: `@/modules/${entry.id}`,
310
+ pkgBase: `@open-mercato/core/modules/${entry.id}`,
311
+ }),
312
+ getPackageOutputDir: () => '/tmp/test-out',
313
+ getPackageRoot: () => '/tmp/test-root',
314
+ }
315
+ }
316
+
317
+ it('calls MetadataStorage.clear() at the start of every module iteration', async () => {
318
+ const moduleA = { id: 'modulealpha', dir: createTempModule('modulealpha') }
319
+ const moduleB = { id: 'modulebeta', dir: createTempModule('modulebeta') }
320
+ const resolver = createMockResolver([moduleA, moduleB])
321
+
322
+ await dbGenerate(resolver)
323
+
324
+ // The fix adds one MetadataStorage.clear() at the top of every iteration of
325
+ // the dbGenerate loop. With two modules, clear must be invoked at least
326
+ // twice. Without the fix (the regression introduced by 3b0b8eb6a), clear is
327
+ // never invoked from dbGenerate at all.
328
+ expect(clearSpy.mock.calls.length).toBeGreaterThanOrEqual(2)
329
+ })
330
+
331
+ it('wipes pre-existing global metadata entries before processing each module', async () => {
332
+ // Simulate the @Entity decorator side effect from a previously-loaded
333
+ // module by manually registering a metadata entry. Without the fix, this
334
+ // entry would persist into the next module's MikroORM.init() and end up
335
+ // in its generated migration as an unrelated CREATE TABLE statement.
336
+ MetadataStorage.getMetadata('LingeringEntity', '/stale/path/lingering')
337
+ expect(Object.keys(MetadataStorage.getMetadata())).toHaveLength(1)
338
+
339
+ const moduleA = { id: 'modulealpha', dir: createTempModule('modulealpha') }
340
+ const resolver = createMockResolver([moduleA])
341
+
342
+ await dbGenerate(resolver)
343
+
344
+ // After dbGenerate runs, the clear must have wiped the stale entry. We
345
+ // observe the registry state at clear time AND after dbGenerate exits.
346
+ expect(clearSpy.mock.calls.length).toBeGreaterThanOrEqual(1)
347
+ expect(Object.keys(MetadataStorage.getMetadata())).toHaveLength(0)
348
+ })
349
+ })
@@ -2,7 +2,7 @@ import path from 'node:path'
2
2
  import fs from 'node:fs'
3
3
  import { pathToFileURL } from 'node:url'
4
4
  import ts from 'typescript'
5
- import { MikroORM, type Logger } from '@mikro-orm/core'
5
+ import { MikroORM, MetadataStorage, type Logger } from '@mikro-orm/core'
6
6
  import { ReflectMetadataProvider } from '@mikro-orm/decorators/legacy'
7
7
  import { Migrator } from '@mikro-orm/migrations'
8
8
  import { PostgreSqlDriver } from '@mikro-orm/postgresql'
@@ -234,18 +234,19 @@ export async function dbGenerate(resolver: PackageResolver, options: DbOptions =
234
234
  const ordered = sortModules(modules)
235
235
  const results: string[] = []
236
236
 
237
- const moduleClasses = new Map<string, any[]>()
238
- for (const entry of ordered) {
239
- moduleClasses.set(entry.id, await loadModuleEntities(entry, resolver))
240
- }
241
-
242
237
  const sslConfig = getSslConfig()
243
238
  const usedFileNames = new Set<string>()
244
239
 
245
240
  for (const entry of ordered) {
241
+ // Clear the global @Entity() decorator registry before loading this module's
242
+ // entities. MikroORM's migrator reads MetadataStorage at createMigration()
243
+ // time, so without this clear, every module's migration would include every
244
+ // previously-loaded module's tables (see issue #1911).
245
+ MetadataStorage.clear()
246
+
246
247
  const modId = entry.id
247
248
  const sanitizedModId = sanitizeModuleId(modId)
248
- const entities = moduleClasses.get(modId) ?? []
249
+ const entities = await loadModuleEntities(entry, resolver)
249
250
  if (!entities.length) {
250
251
  if (entry.from === '@app') {
251
252
  results.push(formatResult(modId, 'no entities discovered', ''))