@open-mercato/cli 0.6.3-develop.3876.1.d40fe4ec2d → 0.6.3-develop.3894.1.352abf4240

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.3-develop.3876.1.d40fe4ec2d",
3
+ "version": "0.6.3-develop.3894.1.352abf4240",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -59,8 +59,8 @@
59
59
  "@mikro-orm/decorators": "^7.1.1",
60
60
  "@mikro-orm/migrations": "^7.1.1",
61
61
  "@mikro-orm/postgresql": "^7.1.1",
62
- "@open-mercato/queue": "0.6.3-develop.3876.1.d40fe4ec2d",
63
- "@open-mercato/shared": "0.6.3-develop.3876.1.d40fe4ec2d",
62
+ "@open-mercato/queue": "0.6.3-develop.3894.1.352abf4240",
63
+ "@open-mercato/shared": "0.6.3-develop.3894.1.352abf4240",
64
64
  "cross-spawn": "^7.0.6",
65
65
  "pg": "8.21.0",
66
66
  "semver": "^7.8.1",
@@ -70,10 +70,10 @@
70
70
  "typescript": "^6.0.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@open-mercato/shared": "0.6.3-develop.3876.1.d40fe4ec2d"
73
+ "@open-mercato/shared": "0.6.3-develop.3894.1.352abf4240"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.3-develop.3876.1.d40fe4ec2d",
76
+ "@open-mercato/shared": "0.6.3-develop.3894.1.352abf4240",
77
77
  "@types/jest": "^30.0.0",
78
78
  "jest": "^30.4.2",
79
79
  "ts-jest": "^29.4.11"
@@ -632,6 +632,7 @@ describe('server dev managed process exits', () => {
632
632
  const originalAutoSpawnWorkers = process.env.AUTO_SPAWN_WORKERS
633
633
  const originalLazy = process.env.OM_AUTO_SPAWN_WORKERS_LAZY
634
634
  const originalLazyScheduler = process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
635
+ const originalGenerateWatchMode = process.env.OM_DEV_GENERATE_WATCH_MODE
635
636
 
636
637
  beforeEach(() => {
637
638
  jest.restoreAllMocks()
@@ -640,6 +641,12 @@ describe('server dev managed process exits', () => {
640
641
  process.env.AUTO_SPAWN_WORKERS = 'true'
641
642
  delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
642
643
  delete process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
644
+ // These tests stub the resolver to an empty object; the in-process
645
+ // generate watcher's default checksum function would error on the
646
+ // missing methods. Force the legacy out-of-process mode so the
647
+ // managed-exit assertions only exercise the worker/scheduler/Next
648
+ // spawn surface.
649
+ process.env.OM_DEV_GENERATE_WATCH_MODE = 'legacy'
643
650
  })
644
651
 
645
652
  afterEach(() => {
@@ -651,6 +658,8 @@ describe('server dev managed process exits', () => {
651
658
  jest.dontMock('../lib/queue-worker-supervisor')
652
659
  jest.dontMock('../lib/scheduler-supervisor')
653
660
  jest.resetModules()
661
+ if (originalGenerateWatchMode === undefined) delete process.env.OM_DEV_GENERATE_WATCH_MODE
662
+ else process.env.OM_DEV_GENERATE_WATCH_MODE = originalGenerateWatchMode
654
663
  delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
655
664
  delete process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
656
665
  })
@@ -0,0 +1,20 @@
1
+ import { resolveGenerateWatcherMode } from '../in-process-generate-watcher-mode'
2
+
3
+ describe('resolveGenerateWatcherMode', () => {
4
+ it('defaults to in-process when the env var is unset', () => {
5
+ expect(resolveGenerateWatcherMode({})).toBe('in-process')
6
+ })
7
+
8
+ it('defaults to in-process for empty / whitespace / unknown values', () => {
9
+ expect(resolveGenerateWatcherMode({ OM_DEV_GENERATE_WATCH_MODE: '' })).toBe('in-process')
10
+ expect(resolveGenerateWatcherMode({ OM_DEV_GENERATE_WATCH_MODE: ' ' })).toBe('in-process')
11
+ expect(resolveGenerateWatcherMode({ OM_DEV_GENERATE_WATCH_MODE: 'maybe' })).toBe('in-process')
12
+ })
13
+
14
+ it.each(['legacy', 'LEGACY', 'sidecar', 'out-of-process', ' legacy '])(
15
+ 'returns legacy for opt-out alias %p',
16
+ (value) => {
17
+ expect(resolveGenerateWatcherMode({ OM_DEV_GENERATE_WATCH_MODE: value })).toBe('legacy')
18
+ },
19
+ )
20
+ })
@@ -0,0 +1,191 @@
1
+ import { startInProcessGenerateWatcher } from '../in-process-generate-watcher'
2
+
3
+ const silentLogger = { log: jest.fn(), error: jest.fn() }
4
+
5
+ async function flushAsync(times = 5): Promise<void> {
6
+ for (let i = 0; i < times; i++) {
7
+ // eslint-disable-next-line no-await-in-loop
8
+ await Promise.resolve()
9
+ }
10
+ }
11
+
12
+ describe('startInProcessGenerateWatcher', () => {
13
+ beforeEach(() => {
14
+ jest.useFakeTimers()
15
+ silentLogger.log.mockReset()
16
+ silentLogger.error.mockReset()
17
+ })
18
+
19
+ afterEach(() => {
20
+ jest.useRealTimers()
21
+ })
22
+
23
+ it('runs the generator once on startup when skipInitial is false', async () => {
24
+ const runGenerators = jest.fn(async () => undefined)
25
+ const computeStructureChecksum = jest.fn(async () => 'checksum-a')
26
+
27
+ const handle = startInProcessGenerateWatcher({
28
+ pollMs: 1000,
29
+ skipInitial: false,
30
+ quiet: true,
31
+ logger: silentLogger,
32
+ computeStructureChecksum,
33
+ runGenerators,
34
+ })
35
+
36
+ await flushAsync(8)
37
+
38
+ expect(runGenerators).toHaveBeenCalledTimes(1)
39
+ expect(runGenerators.mock.calls[0][0]).toBe('initial')
40
+
41
+ await handle.close()
42
+ await handle.done
43
+ })
44
+
45
+ it('skips the initial run when skipInitial is true', async () => {
46
+ const runGenerators = jest.fn(async () => undefined)
47
+ const computeStructureChecksum = jest.fn(async () => 'checksum-b')
48
+
49
+ const handle = startInProcessGenerateWatcher({
50
+ pollMs: 1000,
51
+ skipInitial: true,
52
+ quiet: true,
53
+ logger: silentLogger,
54
+ computeStructureChecksum,
55
+ runGenerators,
56
+ })
57
+
58
+ await flushAsync(8)
59
+
60
+ expect(runGenerators).not.toHaveBeenCalled()
61
+ expect(computeStructureChecksum).toHaveBeenCalled()
62
+
63
+ await handle.close()
64
+ await handle.done
65
+ })
66
+
67
+ it('re-runs the generator when the checksum changes', async () => {
68
+ let currentChecksum = 'stable'
69
+ const runGenerators = jest.fn(async () => undefined)
70
+ const computeStructureChecksum = jest.fn(async () => currentChecksum)
71
+
72
+ const handle = startInProcessGenerateWatcher({
73
+ pollMs: 1000,
74
+ skipInitial: true,
75
+ quiet: true,
76
+ logger: silentLogger,
77
+ computeStructureChecksum,
78
+ runGenerators,
79
+ })
80
+
81
+ await flushAsync(8)
82
+ expect(runGenerators).not.toHaveBeenCalled()
83
+
84
+ // First tick: same checksum, no regeneration.
85
+ jest.advanceTimersByTime(1000)
86
+ await flushAsync(8)
87
+ expect(runGenerators).not.toHaveBeenCalled()
88
+
89
+ // Second tick: checksum changed, generator must run with 'structure change'.
90
+ currentChecksum = 'changed'
91
+ jest.advanceTimersByTime(1000)
92
+ await flushAsync(8)
93
+ expect(runGenerators).toHaveBeenCalledTimes(1)
94
+ expect(runGenerators.mock.calls[0][0]).toBe('structure change')
95
+
96
+ await handle.close()
97
+ await handle.done
98
+ })
99
+
100
+ it('does not start a new poll while a regeneration is in flight', async () => {
101
+ let release: (() => void) | null = null
102
+ const runGenerators = jest.fn(async (_reason: string) => {
103
+ await new Promise<void>((resolve) => {
104
+ release = resolve
105
+ })
106
+ })
107
+ let currentChecksum = 'a'
108
+ const computeStructureChecksum = jest.fn(async () => currentChecksum)
109
+
110
+ const handle = startInProcessGenerateWatcher({
111
+ pollMs: 1000,
112
+ skipInitial: true,
113
+ quiet: true,
114
+ logger: silentLogger,
115
+ computeStructureChecksum,
116
+ runGenerators,
117
+ })
118
+
119
+ await flushAsync(8)
120
+ // Establish baseline checksum, no run yet.
121
+ expect(runGenerators).not.toHaveBeenCalled()
122
+
123
+ // Tick #1: change checksum so a run starts and blocks.
124
+ currentChecksum = 'b'
125
+ jest.advanceTimersByTime(1000)
126
+ await flushAsync(8)
127
+ expect(runGenerators).toHaveBeenCalledTimes(1)
128
+
129
+ // Tick #2 and #3 must NOT spawn parallel runs while #1 is in flight —
130
+ // the helper serializes via the running/pending pair plus the
131
+ // single-shot finally-scheduled poll cycle.
132
+ currentChecksum = 'c'
133
+ jest.advanceTimersByTime(1000)
134
+ await flushAsync(8)
135
+ jest.advanceTimersByTime(1000)
136
+ await flushAsync(8)
137
+ expect(runGenerators).toHaveBeenCalledTimes(1)
138
+
139
+ release?.()
140
+ await flushAsync(20)
141
+
142
+ await handle.close()
143
+ await handle.done
144
+ })
145
+
146
+ it('logs but does not crash when the generator throws', async () => {
147
+ const runGenerators = jest.fn(async () => {
148
+ throw new Error('boom')
149
+ })
150
+ const computeStructureChecksum = jest.fn(async () => 'k')
151
+
152
+ const handle = startInProcessGenerateWatcher({
153
+ pollMs: 1000,
154
+ skipInitial: false,
155
+ quiet: true,
156
+ logger: silentLogger,
157
+ computeStructureChecksum,
158
+ runGenerators,
159
+ })
160
+
161
+ await flushAsync(8)
162
+ expect(runGenerators).toHaveBeenCalledTimes(1)
163
+ expect(silentLogger.error).toHaveBeenCalled()
164
+ const message = String(silentLogger.error.mock.calls[0][0] ?? '')
165
+ expect(message).toMatch(/Generation failed/)
166
+ expect(message).toMatch(/boom/)
167
+
168
+ await handle.close()
169
+ await handle.done
170
+ })
171
+
172
+ it('close() is idempotent and resolves done exactly once', async () => {
173
+ const runGenerators = jest.fn(async () => undefined)
174
+ const computeStructureChecksum = jest.fn(async () => 'k')
175
+
176
+ const handle = startInProcessGenerateWatcher({
177
+ pollMs: 1000,
178
+ skipInitial: true,
179
+ quiet: true,
180
+ logger: silentLogger,
181
+ computeStructureChecksum,
182
+ runGenerators,
183
+ })
184
+
185
+ await flushAsync(8)
186
+ await handle.close()
187
+ await handle.close()
188
+ await handle.done
189
+ expect(true).toBe(true)
190
+ })
191
+ })
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Resolves the watcher-mode env flag that governs whether the dev runtime
3
+ * runs the generate watcher in-process (default) or as a legacy out-of-process
4
+ * `mercato generate watch --skip-initial` sidecar.
5
+ *
6
+ * `OM_DEV_GENERATE_WATCH_MODE=legacy` opts back into the pre-consolidation
7
+ * behavior (one extra long-running Node process). Any other value, missing
8
+ * value, or unparseable value resolves to `in-process`.
9
+ */
10
+
11
+ export type GenerateWatcherMode = 'in-process' | 'legacy'
12
+
13
+ export function resolveGenerateWatcherMode(env: NodeJS.ProcessEnv): GenerateWatcherMode {
14
+ const raw = env.OM_DEV_GENERATE_WATCH_MODE
15
+ if (typeof raw !== 'string') return 'in-process'
16
+ const normalized = raw.trim().toLowerCase()
17
+ if (normalized === 'legacy' || normalized === 'sidecar' || normalized === 'out-of-process') {
18
+ return 'legacy'
19
+ }
20
+ return 'in-process'
21
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * In-process generate watcher.
3
+ *
4
+ * Polls a structural-fingerprint function on a configurable interval and
5
+ * invokes the supplied generator callback whenever the fingerprint changes.
6
+ * The polling loop is identical to the legacy standalone watcher; the only
7
+ * difference is that it now runs inside whatever process calls it
8
+ * (typically `mercato server dev`), so the dev runtime no longer needs a
9
+ * sidecar `mercato generate watch` Node process.
10
+ *
11
+ * Contract preserved 1:1 with the prior standalone watcher:
12
+ * - Default poll interval 1000 ms (minimum 250 ms).
13
+ * - One initial generator run unless `skipInitial` is true.
14
+ * - Concurrent regeneration requests are coalesced (running + pending).
15
+ * - Generator errors are logged but never crash the watcher.
16
+ * - The polling timer uses `.unref()` so it never blocks process exit.
17
+ */
18
+
19
+ export type GenerateWatcherLogger = Pick<Console, 'log' | 'error'>
20
+
21
+ export type GenerateWatcherOptions = {
22
+ /**
23
+ * Function that returns the current structural fingerprint of the module
24
+ * tree. The watcher re-runs `runGenerators` whenever this value changes.
25
+ */
26
+ computeStructureChecksum: () => Promise<string> | string
27
+ /**
28
+ * Function that performs the actual regeneration work. Called once on
29
+ * startup (unless `skipInitial`) and again whenever the checksum changes.
30
+ * The `reason` argument is suitable for logging (`'initial'`,
31
+ * `'structure change'`, `'queued change'`).
32
+ */
33
+ runGenerators: (reason: string) => Promise<void>
34
+ /** Poll interval in milliseconds. Defaults to 1000. Clamped to >= 250. */
35
+ pollMs?: number
36
+ /** Skip the initial regeneration on startup. Defaults to false. */
37
+ skipInitial?: boolean
38
+ /** Suppress informational logs. Errors are always logged. */
39
+ quiet?: boolean
40
+ /** Logger override. Defaults to `console`. */
41
+ logger?: GenerateWatcherLogger
42
+ }
43
+
44
+ export type GenerateWatcherHandle = {
45
+ /** Resolves when the watcher loop has stopped (after `close()`). */
46
+ readonly done: Promise<void>
47
+ /** Stop the polling loop and resolve `done`. Idempotent. */
48
+ close(): Promise<void>
49
+ }
50
+
51
+ const MIN_POLL_MS = 250
52
+ const DEFAULT_POLL_MS = 1000
53
+
54
+ function resolvePollMs(value: number | undefined): number {
55
+ if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_POLL_MS
56
+ const numeric = Math.floor(value)
57
+ return numeric >= MIN_POLL_MS ? numeric : DEFAULT_POLL_MS
58
+ }
59
+
60
+ /**
61
+ * Start an in-process generate watcher. The returned handle exposes a
62
+ * `close()` to stop polling and a `done` promise that resolves once the
63
+ * watcher loop has finished.
64
+ */
65
+ export function startInProcessGenerateWatcher(
66
+ options: GenerateWatcherOptions,
67
+ ): GenerateWatcherHandle {
68
+ const logger = options.logger ?? console
69
+ const quiet = options.quiet === true
70
+ const pollMs = resolvePollMs(options.pollMs)
71
+ const skipInitial = options.skipInitial === true
72
+ const { computeStructureChecksum, runGenerators } = options
73
+
74
+ let stopping = false
75
+ let pollTimer: ReturnType<typeof setTimeout> | null = null
76
+ let running = false
77
+ let pendingReason: string | null = null
78
+ let previousChecksum = ''
79
+ let doneResolve: (() => void) | null = null
80
+ const done = new Promise<void>((resolve) => {
81
+ doneResolve = resolve
82
+ })
83
+
84
+ async function runOnce(reason: string): Promise<void> {
85
+ if (running) {
86
+ pendingReason = reason
87
+ return
88
+ }
89
+ running = true
90
+ try {
91
+ if (!quiet) {
92
+ logger.log(`[generate:watch] Regenerating (${reason})...`)
93
+ }
94
+ await runGenerators(reason)
95
+ if (!quiet) {
96
+ logger.log('[generate:watch] Generators completed.')
97
+ }
98
+ } catch (error) {
99
+ const message = error instanceof Error ? error.message : String(error)
100
+ logger.error(`[generate:watch] Generation failed: ${message}`)
101
+ } finally {
102
+ running = false
103
+ if (pendingReason && !stopping) {
104
+ const queued = pendingReason
105
+ pendingReason = null
106
+ await runOnce(queued)
107
+ }
108
+ }
109
+ }
110
+
111
+ function scheduleNext(): void {
112
+ if (stopping) return
113
+ pollTimer = setTimeout(() => {
114
+ void (async () => {
115
+ if (stopping) return
116
+ try {
117
+ const nextChecksum = await computeStructureChecksum()
118
+ if (nextChecksum !== previousChecksum) {
119
+ previousChecksum = nextChecksum
120
+ await runOnce('structure change')
121
+ }
122
+ } catch (error) {
123
+ const message = error instanceof Error ? error.message : String(error)
124
+ logger.error(`[generate:watch] Poll cycle failed: ${message}`)
125
+ } finally {
126
+ if (!stopping) scheduleNext()
127
+ }
128
+ })()
129
+ }, pollMs)
130
+ pollTimer.unref?.()
131
+ }
132
+
133
+ void (async () => {
134
+ try {
135
+ if (!skipInitial) {
136
+ await runOnce('initial')
137
+ }
138
+ previousChecksum = await computeStructureChecksum()
139
+ if (!quiet) {
140
+ if (skipInitial) {
141
+ logger.log('[generate:watch] Skipping initial regeneration and watching the current generated state.')
142
+ }
143
+ logger.log(`[generate:watch] Watching structural module files every ${pollMs}ms (in-process).`)
144
+ }
145
+ scheduleNext()
146
+ } catch (error) {
147
+ const message = error instanceof Error ? error.message : String(error)
148
+ logger.error(`[generate:watch] Initial setup failed: ${message}`)
149
+ // Even if the initial bootstrap fails, schedule polling so a later
150
+ // checksum recovery still picks up the next change on disk.
151
+ scheduleNext()
152
+ }
153
+ })()
154
+
155
+ async function close(): Promise<void> {
156
+ if (stopping) {
157
+ await done
158
+ return
159
+ }
160
+ stopping = true
161
+ if (pollTimer) {
162
+ clearTimeout(pollTimer)
163
+ pollTimer = null
164
+ }
165
+ if (doneResolve) {
166
+ doneResolve()
167
+ doneResolve = null
168
+ }
169
+ }
170
+
171
+ return {
172
+ done,
173
+ close,
174
+ }
175
+ }
@@ -1649,6 +1649,10 @@ function buildReusableEnvironment(
1649
1649
  OM_ENABLE_ENTERPRISE_MODULES_SECURITY: process.env.OM_ENABLE_ENTERPRISE_MODULES_SECURITY ?? enterpriseModulesFlag,
1650
1650
  OM_TEST_MODE: '1',
1651
1651
  OM_TEST_AUTH_RATE_LIMIT_MODE: 'opt-in',
1652
+ // Tests assert on access_logs immediately after CRUD reads; keep the
1653
+ // blocking write path on inside the integration runtime so tests do
1654
+ // not have to call flushPendingCrudAccessLogs() explicitly.
1655
+ OM_CRUD_ACCESS_LOG_BLOCKING: process.env.OM_CRUD_ACCESS_LOG_BLOCKING ?? '1',
1652
1656
  OM_WEBHOOKS_ALLOW_PRIVATE_URLS: process.env.OM_WEBHOOKS_ALLOW_PRIVATE_URLS ?? '1',
1653
1657
  ENABLE_CRUD_API_CACHE: 'true',
1654
1658
  MOCK_GATEWAY_WEBHOOK_SECRET: 'open-mercato-mock-dev-webhook-secret',
@@ -2936,7 +2940,7 @@ export async function startEphemeralEnvironment(options: EphemeralRuntimeOptions
2936
2940
  NEXT_PUBLIC_UMES_DEVTOOLS: 'true',
2937
2941
  CI: 'true',
2938
2942
  TENANT_DATA_ENCRYPTION_FALLBACK_KEY: process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY ?? 'om-ephemeral-integration-fallback-key',
2939
- AUTO_SPAWN_WORKERS: 'false',
2943
+ AUTO_SPAWN_WORKERS: process.env.AUTO_SPAWN_WORKERS ?? 'true',
2940
2944
  AUTO_SPAWN_SCHEDULER: 'false',
2941
2945
  OM_CLI_QUIET: '1',
2942
2946
  MERCATO_QUIET: '1',