@open-mercato/cli 0.6.6-develop.5509.1.006f4d4f24 → 0.6.6-develop.5523.1.e223ca1915

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.6-develop.5509.1.006f4d4f24",
3
+ "version": "0.6.6-develop.5523.1.e223ca1915",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -59,8 +59,8 @@
59
59
  "@mikro-orm/decorators": "^7.1.4",
60
60
  "@mikro-orm/migrations": "^7.1.4",
61
61
  "@mikro-orm/postgresql": "^7.1.4",
62
- "@open-mercato/queue": "0.6.6-develop.5509.1.006f4d4f24",
63
- "@open-mercato/shared": "0.6.6-develop.5509.1.006f4d4f24",
62
+ "@open-mercato/queue": "0.6.6-develop.5523.1.e223ca1915",
63
+ "@open-mercato/shared": "0.6.6-develop.5523.1.e223ca1915",
64
64
  "cross-spawn": "^7.0.6",
65
65
  "pg": "8.21.0",
66
66
  "semver": "^7.8.3",
@@ -70,10 +70,10 @@
70
70
  "typescript": "^6.0.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@open-mercato/shared": "0.6.6-develop.5509.1.006f4d4f24"
73
+ "@open-mercato/shared": "0.6.6-develop.5523.1.e223ca1915"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.6-develop.5509.1.006f4d4f24",
76
+ "@open-mercato/shared": "0.6.6-develop.5523.1.e223ca1915",
77
77
  "@types/jest": "^30.0.0",
78
78
  "jest": "^30.4.2",
79
79
  "ts-jest": "^29.4.11"
@@ -0,0 +1,138 @@
1
+ import {
2
+ assertSingleInstanceStrategies,
3
+ evaluateSingleInstanceGuard,
4
+ SingleInstanceStrategyError,
5
+ type InfraStrategySnapshot,
6
+ } from '../single-instance-strategy-guard'
7
+
8
+ const singleInstanceSnapshot: InfraStrategySnapshot = {
9
+ cacheStrategy: 'memory',
10
+ queueStrategy: 'local',
11
+ rateLimitStrategy: 'memory',
12
+ }
13
+
14
+ const multiInstanceSafeSnapshot: InfraStrategySnapshot = {
15
+ cacheStrategy: 'redis',
16
+ queueStrategy: 'async',
17
+ rateLimitStrategy: 'redis',
18
+ }
19
+
20
+ describe('evaluateSingleInstanceGuard', () => {
21
+ it('is a no-op outside production even with single-instance strategies', () => {
22
+ const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
23
+ NODE_ENV: 'development',
24
+ OM_MULTI_INSTANCE: '1',
25
+ })
26
+ expect(result.action).toBe('ok')
27
+ expect(result.offenders).toHaveLength(3)
28
+ })
29
+
30
+ it('is a no-op in production when all strategies are multi-instance safe', () => {
31
+ const result = evaluateSingleInstanceGuard(multiInstanceSafeSnapshot, {
32
+ NODE_ENV: 'production',
33
+ OM_MULTI_INSTANCE: '1',
34
+ })
35
+ expect(result.action).toBe('ok')
36
+ expect(result.offenders).toHaveLength(0)
37
+ })
38
+
39
+ it('fails in production when a multi-instance topology is declared via OM_MULTI_INSTANCE', () => {
40
+ const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
41
+ NODE_ENV: 'production',
42
+ OM_MULTI_INSTANCE: '1',
43
+ })
44
+ expect(result.action).toBe('fail')
45
+ expect(result.offenders.map((offender) => offender.envVar)).toEqual([
46
+ 'CACHE_STRATEGY',
47
+ 'QUEUE_STRATEGY',
48
+ 'RATE_LIMIT_STRATEGY',
49
+ ])
50
+ })
51
+
52
+ it('treats OM_INSTANCE_COUNT > 1 as a multi-instance topology', () => {
53
+ const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
54
+ NODE_ENV: 'production',
55
+ OM_INSTANCE_COUNT: '3',
56
+ })
57
+ expect(result.action).toBe('fail')
58
+ })
59
+
60
+ it('does not treat OM_INSTANCE_COUNT=1 as multi-instance', () => {
61
+ const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
62
+ NODE_ENV: 'production',
63
+ OM_INSTANCE_COUNT: '1',
64
+ })
65
+ expect(result.action).toBe('warn')
66
+ })
67
+
68
+ it('only warns in production when no multi-instance topology is declared', () => {
69
+ const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
70
+ NODE_ENV: 'production',
71
+ })
72
+ expect(result.action).toBe('warn')
73
+ expect(result.multiInstance).toBe(false)
74
+ })
75
+
76
+ it('downgrades a hard failure to a warning when the override is set', () => {
77
+ const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
78
+ NODE_ENV: 'production',
79
+ OM_MULTI_INSTANCE: '1',
80
+ OM_ALLOW_SINGLE_INSTANCE_STRATEGIES: '1',
81
+ })
82
+ expect(result.action).toBe('warn')
83
+ expect(result.overridden).toBe(true)
84
+ })
85
+
86
+ it('flags only the offending strategy when others are safe', () => {
87
+ const result = evaluateSingleInstanceGuard(
88
+ { cacheStrategy: 'redis', queueStrategy: 'local', rateLimitStrategy: 'redis' },
89
+ { NODE_ENV: 'production', OM_MULTI_INSTANCE: '1' },
90
+ )
91
+ expect(result.action).toBe('fail')
92
+ expect(result.offenders).toHaveLength(1)
93
+ expect(result.offenders[0].component).toBe('queue')
94
+ })
95
+ })
96
+
97
+ describe('assertSingleInstanceStrategies', () => {
98
+ function createLogger() {
99
+ return {
100
+ warn: jest.fn(),
101
+ error: jest.fn(),
102
+ }
103
+ }
104
+
105
+ it('throws and logs when the guard fails', () => {
106
+ const logger = createLogger()
107
+ expect(() =>
108
+ assertSingleInstanceStrategies(
109
+ { NODE_ENV: 'production', OM_MULTI_INSTANCE: '1' },
110
+ { snapshot: singleInstanceSnapshot, logger },
111
+ ),
112
+ ).toThrow(SingleInstanceStrategyError)
113
+ expect(logger.error).toHaveBeenCalled()
114
+ expect(logger.warn).not.toHaveBeenCalled()
115
+ })
116
+
117
+ it('logs a warning but does not throw when only warning', () => {
118
+ const logger = createLogger()
119
+ const result = assertSingleInstanceStrategies(
120
+ { NODE_ENV: 'production' },
121
+ { snapshot: singleInstanceSnapshot, logger },
122
+ )
123
+ expect(result.action).toBe('warn')
124
+ expect(logger.warn).toHaveBeenCalled()
125
+ expect(logger.error).not.toHaveBeenCalled()
126
+ })
127
+
128
+ it('stays silent and does not throw when everything is safe', () => {
129
+ const logger = createLogger()
130
+ const result = assertSingleInstanceStrategies(
131
+ { NODE_ENV: 'production', OM_MULTI_INSTANCE: '1' },
132
+ { snapshot: multiInstanceSafeSnapshot, logger },
133
+ )
134
+ expect(result.action).toBe('ok')
135
+ expect(logger.warn).not.toHaveBeenCalled()
136
+ expect(logger.error).not.toHaveBeenCalled()
137
+ })
138
+ })
@@ -0,0 +1,172 @@
1
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
2
+ import { resolveQueueStrategy } from '@open-mercato/queue'
3
+
4
+ /**
5
+ * Boot-time guard that refuses to start (or warns) when an infrastructure
6
+ * strategy that is only safe for a single process/instance is configured under
7
+ * a declared multi-instance topology.
8
+ *
9
+ * Background: `CACHE_STRATEGY`, `RATE_LIMIT_STRATEGY` and `QUEUE_STRATEGY` all
10
+ * default to single-instance modes (`memory`/`memory`/`local`). Running more
11
+ * than one app instance against those defaults silently breaks three
12
+ * correctness invariants at once — stale RBAC caches after a privilege
13
+ * revocation, rate limits multiplied by the instance count, and duplicate job
14
+ * processing from the leaderless local file queue. None of them warn.
15
+ *
16
+ * Only strategies that coordinate across processes via shared external state
17
+ * are considered multi-instance safe. Everything else (the in-process memory
18
+ * strategies, the local file queue, and disk-backed caches that are not shared
19
+ * across hosts) is treated as single-instance.
20
+ */
21
+ const MULTI_INSTANCE_SAFE_STRATEGIES = {
22
+ cache: ['redis'],
23
+ queue: ['async'],
24
+ rateLimit: ['redis'],
25
+ } as const
26
+
27
+ export type SingleInstanceGuardComponent = 'cache' | 'queue' | 'rateLimit'
28
+
29
+ export type SingleInstanceGuardOffender = {
30
+ component: SingleInstanceGuardComponent
31
+ envVar: string
32
+ configured: string
33
+ safeValues: readonly string[]
34
+ }
35
+
36
+ export type SingleInstanceGuardAction = 'ok' | 'warn' | 'fail'
37
+
38
+ export type SingleInstanceGuardResult = {
39
+ action: SingleInstanceGuardAction
40
+ offenders: SingleInstanceGuardOffender[]
41
+ production: boolean
42
+ multiInstance: boolean
43
+ overridden: boolean
44
+ }
45
+
46
+ export type InfraStrategySnapshot = {
47
+ cacheStrategy: string
48
+ queueStrategy: string
49
+ rateLimitStrategy: string
50
+ }
51
+
52
+ const COMPONENT_ENV_VARS: Record<SingleInstanceGuardComponent, string> = {
53
+ cache: 'CACHE_STRATEGY',
54
+ queue: 'QUEUE_STRATEGY',
55
+ rateLimit: 'RATE_LIMIT_STRATEGY',
56
+ }
57
+
58
+ export class SingleInstanceStrategyError extends Error {
59
+ readonly offenders: SingleInstanceGuardOffender[]
60
+
61
+ constructor(result: SingleInstanceGuardResult) {
62
+ super(
63
+ `Refusing to start: single-instance infrastructure strategies (${result.offenders
64
+ .map((offender) => `${offender.envVar}=${offender.configured}`)
65
+ .join(', ')}) are configured under a declared multi-instance topology. ` +
66
+ 'Switch to a multi-instance-safe strategy or set OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 to override.',
67
+ )
68
+ this.name = 'SingleInstanceStrategyError'
69
+ this.offenders = result.offenders
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Read the configured infrastructure strategies. Queue resolution reuses the
75
+ * canonical `resolveQueueStrategy()` so the default stays single-sourced; cache
76
+ * and rate-limit values are read directly with their documented defaults
77
+ * (`packages/cache/src/service.ts`, `packages/shared/src/lib/ratelimit/config.ts`)
78
+ * because the guard's safe-set policy — not the resolver default — decides what
79
+ * counts as single-instance.
80
+ */
81
+ export function readInfraStrategySnapshot(env: NodeJS.ProcessEnv = process.env): InfraStrategySnapshot {
82
+ return {
83
+ cacheStrategy: env.CACHE_STRATEGY?.trim() || 'memory',
84
+ queueStrategy: resolveQueueStrategy(),
85
+ rateLimitStrategy: env.RATE_LIMIT_STRATEGY?.trim() || 'memory',
86
+ }
87
+ }
88
+
89
+ function resolveMultiInstanceHint(env: NodeJS.ProcessEnv): boolean {
90
+ if (parseBooleanWithDefault(env.OM_MULTI_INSTANCE, false)) return true
91
+ const instanceCount = Number.parseInt(env.OM_INSTANCE_COUNT ?? '', 10)
92
+ return Number.isFinite(instanceCount) && instanceCount > 1
93
+ }
94
+
95
+ export function evaluateSingleInstanceGuard(
96
+ snapshot: InfraStrategySnapshot,
97
+ env: NodeJS.ProcessEnv = process.env,
98
+ ): SingleInstanceGuardResult {
99
+ const configured: Record<SingleInstanceGuardComponent, string> = {
100
+ cache: snapshot.cacheStrategy,
101
+ queue: snapshot.queueStrategy,
102
+ rateLimit: snapshot.rateLimitStrategy,
103
+ }
104
+
105
+ const offenders: SingleInstanceGuardOffender[] = []
106
+ for (const component of Object.keys(configured) as SingleInstanceGuardComponent[]) {
107
+ const safeValues: readonly string[] = MULTI_INSTANCE_SAFE_STRATEGIES[component]
108
+ if (!safeValues.includes(configured[component])) {
109
+ offenders.push({
110
+ component,
111
+ envVar: COMPONENT_ENV_VARS[component],
112
+ configured: configured[component],
113
+ safeValues,
114
+ })
115
+ }
116
+ }
117
+
118
+ const production = (env.NODE_ENV ?? '').trim() === 'production'
119
+ const multiInstance = resolveMultiInstanceHint(env)
120
+ const overridden = parseBooleanWithDefault(env.OM_ALLOW_SINGLE_INSTANCE_STRATEGIES, false)
121
+
122
+ let action: SingleInstanceGuardAction = 'ok'
123
+ if (offenders.length > 0 && production) {
124
+ action = multiInstance && !overridden ? 'fail' : 'warn'
125
+ }
126
+
127
+ return { action, offenders, production, multiInstance, overridden }
128
+ }
129
+
130
+ export function formatSingleInstanceGuardMessage(result: SingleInstanceGuardResult): string[] {
131
+ const offenderLines = result.offenders.map(
132
+ (offender) =>
133
+ ` - ${offender.envVar}=${offender.configured} (multi-instance-safe: ${offender.safeValues.join(', ')})`,
134
+ )
135
+ const header =
136
+ result.action === 'fail'
137
+ ? '[server] Refusing to start: single-instance infrastructure strategies under a multi-instance topology.'
138
+ : '[server] WARNING: single-instance infrastructure strategies detected in production.'
139
+ const guidance =
140
+ result.action === 'fail'
141
+ ? '[server] Switch each strategy above to a multi-instance-safe value, or set OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 to override (accepting duplicate jobs, stale ACLs, and weakened rate limits).'
142
+ : result.multiInstance
143
+ ? '[server] OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 is set — proceeding despite the risks above (duplicate jobs, stale ACLs, weakened rate limits).'
144
+ : '[server] Running multiple instances against these strategies will cause duplicate jobs, stale ACLs, and weakened rate limits. Set OM_MULTI_INSTANCE=1 to make this a hard failure once you scale out.'
145
+ return [header, ...offenderLines, guidance]
146
+ }
147
+
148
+ export type SingleInstanceGuardLogger = Pick<Console, 'warn' | 'error'>
149
+
150
+ /**
151
+ * Evaluate the guard and enforce it: throw on `fail`, log prominently on
152
+ * `warn`, and stay silent on `ok`. Safe to call on every `start`; it never
153
+ * fires for dev boots or single-instance production deployments.
154
+ */
155
+ export function assertSingleInstanceStrategies(
156
+ env: NodeJS.ProcessEnv = process.env,
157
+ options?: { snapshot?: InfraStrategySnapshot; logger?: SingleInstanceGuardLogger },
158
+ ): SingleInstanceGuardResult {
159
+ const snapshot = options?.snapshot ?? readInfraStrategySnapshot(env)
160
+ const result = evaluateSingleInstanceGuard(snapshot, env)
161
+ const logger = options?.logger ?? console
162
+
163
+ if (result.action === 'fail') {
164
+ for (const line of formatSingleInstanceGuardMessage(result)) logger.error(line)
165
+ throw new SingleInstanceStrategyError(result)
166
+ }
167
+ if (result.action === 'warn') {
168
+ for (const line of formatSingleInstanceGuardMessage(result)) logger.warn(line)
169
+ }
170
+
171
+ return result
172
+ }
package/src/mercato.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  import { parseModuleInstallArgs } from './lib/module-install-args'
33
33
  import { resolveNextBuildIdCandidate } from './lib/next-build-id'
34
34
  import { acquireServerStartLock } from './lib/server-start-lock'
35
+ import { assertSingleInstanceStrategies } from './lib/single-instance-strategy-guard'
35
36
  import { createDevEnvReloader, watchDevEnvFiles } from './lib/dev-env-reload'
36
37
  // Lazy-imported to avoid pulling in `testcontainers` (devDependency) at startup
37
38
  const lazyIntegration = () => import('./lib/testing/integration')
@@ -2154,6 +2155,9 @@ export async function run(argv = process.argv) {
2154
2155
  const autoSpawnSchedulerMode = resolveAutoSpawnSchedulerMode(process.env)
2155
2156
  const queueStrategy = process.env.QUEUE_STRATEGY || 'local'
2156
2157
  const runtimeEnv = buildServerProcessEnvironment(process.env)
2158
+ // Throws on single-instance strategies under a multi-instance topology,
2159
+ // aborting before the start lock is acquired or any process is spawned.
2160
+ assertSingleInstanceStrategies(runtimeEnv)
2157
2161
  const schedulerCommand = lookupModuleCommand(getCliModules(), 'scheduler', 'start')
2158
2162
  const serverStartLock = acquireServerStartLock(appDir, {
2159
2163
  port: runtimeEnv.PORT ?? process.env.PORT ?? null,