@open-mercato/cli 0.6.6-develop.5509.1.006f4d4f24 → 0.6.6-develop.5531.1.ab1959dfae

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.
@@ -1,4 +1,4 @@
1
- [build:cli] found 87 entry points
1
+ [build:cli] found 89 entry points
2
2
  Copied create-app/agentic/ → dist/agentic/
3
3
  Discovered 16 standalone guides → dist/agentic/guides/
4
4
  [build:cli] built successfully
@@ -39,7 +39,7 @@ When the developer reports a problem, follow this order:
39
39
 
40
40
  ### Step 2: Check Generated Files
41
41
 
42
- Run these commands first they fix 60%+ of issues:
42
+ These commands fix 60%+ of issues, so they are usually the first fix to propose (they are mutating — propose, then run after confirmation per [Step 4](#step-4-propose-before-fixing)):
43
43
 
44
44
  ```bash
45
45
  yarn generate # Regenerate module discovery files
@@ -61,6 +61,28 @@ ls .mercato/generated/
61
61
  yarn typecheck
62
62
  ```
63
63
 
64
+ ### Step 4: Propose Before Fixing
65
+
66
+ Once you have diagnosed the root cause, **do not apply the fix immediately**. First present:
67
+
68
+ 1. The **root cause** — what is actually broken and why.
69
+ 2. The **proposed fix** — the exact commands and/or code changes you intend to apply.
70
+
71
+ Then **wait for explicit user confirmation** before applying any **mutating** change. This keeps the developer in control and avoids surprise edits, migrations, or restarts.
72
+
73
+ **Read-only diagnostics may run without asking** — they only gather information and change nothing:
74
+
75
+ | Allowed without confirmation (read-only) | Requires confirmation (mutating) |
76
+ |------------------------------------------|----------------------------------|
77
+ | `yarn typecheck` | `yarn generate` |
78
+ | `grep` / file reads / `ls` | `yarn db:generate` |
79
+ | log / browser-console inspection | `yarn db:migrate` |
80
+ | `docker compose ps` | editing files |
81
+ | `curl` against a running endpoint (GET) | restarting the dev server (`yarn dev`) |
82
+ | | `docker compose up` |
83
+
84
+ When in doubt about whether an action mutates state, treat it as mutating and ask first. Once the user confirms, apply the fix and verify it.
85
+
64
86
  ---
65
87
 
66
88
  ## 2. Module Issues
@@ -76,24 +98,24 @@ yarn typecheck
76
98
  // Must have this entry:
77
99
  { id: '<module_id>', from: '@app' }
78
100
  ```
79
- Fix: Add the entry and run `yarn generate`.
101
+ Proposed fix: Add the entry and run `yarn generate`.
80
102
 
81
103
  2. **Did you run `yarn generate`?**
82
104
  Check if `.mercato/generated/` contains your module's entries.
83
- Fix: Run `yarn generate`.
105
+ Proposed fix: Run `yarn generate`.
84
106
 
85
107
  3. **Is the module folder named correctly?**
86
108
  Must be plural, snake_case: `src/modules/<module_id>/`
87
- Fix: Rename folder to match module ID.
109
+ Proposed fix: Rename folder to match module ID.
88
110
 
89
111
  4. **Does `index.ts` export `metadata`?**
90
112
  ```typescript
91
113
  export const metadata: ModuleInfo = { name: '<module_id>', ... }
92
114
  ```
93
- Fix: Add the metadata export.
115
+ Proposed fix: Add the metadata export.
94
116
 
95
117
  5. **Is the dev server running with latest changes?**
96
- Fix: Restart with `yarn dev`.
118
+ Proposed fix: Restart with `yarn dev`.
97
119
 
98
120
  ### Module loads but pages 404
99
121
 
@@ -104,17 +126,17 @@ yarn typecheck
104
126
  1. **Are backend page files in the right location?**
105
127
  - List page: `backend/page.tsx` (not `backend/index.tsx`)
106
128
  - Detail page: `backend/<entities>/[id].tsx` (bracket notation)
107
- Fix: Rename to match auto-discovery convention.
129
+ Proposed fix: Rename to match auto-discovery convention.
108
130
 
109
131
  2. **Do pages export `metadata` with `requireAuth`?**
110
132
  ```typescript
111
133
  export const metadata = { requireAuth: true, features: ['<module_id>.view'] }
112
134
  ```
113
- Fix: Add metadata export.
135
+ Proposed fix: Add metadata export.
114
136
 
115
137
  3. **Does the user have the required ACL features?**
116
138
  Check `setup.ts` has `defaultRoleFeatures` for the user's role.
117
- Fix: Add features to role defaults, re-run setup.
139
+ Proposed fix: Add features to role defaults, re-run setup.
118
140
 
119
141
  ---
120
142
 
@@ -130,22 +152,22 @@ yarn typecheck
130
152
  ```bash
131
153
  yarn db:generate # Probes/creates migration file
132
154
  ```
133
- Fix: Run `yarn db:generate` to inspect the required migration, then keep only the scoped SQL for your module and update `src/modules/<module_id>/migrations/.snapshot-open-mercato.json`.
155
+ Proposed fix: Run `yarn db:generate` to inspect the required migration, then keep only the scoped SQL for your module and update `src/modules/<module_id>/migrations/.snapshot-open-mercato.json`.
134
156
 
135
157
  2. **Is the entity declared in the right file with the right imports?**
136
158
  Entity classes belong in `src/modules/<module_id>/data/entities.ts` and decorators must come from `@mikro-orm/decorators/legacy`.
137
- Fix: move stale `entities/<Entity>.ts` patterns into `data/entities.ts` and fix the imports before regenerating the migration.
159
+ Proposed fix: move stale `entities/<Entity>.ts` patterns into `data/entities.ts` and fix the imports before regenerating the migration.
138
160
 
139
161
  3. **Did you apply the migration?**
140
162
  ```bash
141
163
  yarn db:migrate # Applies pending migrations
142
164
  ```
143
- Fix: Run `yarn db:migrate`.
165
+ Proposed fix: Run `yarn db:migrate`.
144
166
 
145
167
  4. **Is the migration file correct?**
146
168
  Check `src/modules/<module_id>/migrations/` for the latest migration.
147
169
  Verify it has the expected columns and types.
148
- Fix: If wrong, delete the migration file, fix the entity, and regenerate.
170
+ Proposed fix: If wrong, delete the migration file, fix the entity, and regenerate.
149
171
 
150
172
  ### Migration generation creates unexpected changes
151
173
 
@@ -160,11 +182,11 @@ yarn typecheck
160
182
 
161
183
  2. **Did you modify a core module entity without ejecting?**
162
184
  Never edit `node_modules/@open-mercato/*`.
163
- Fix: Revert changes to node_modules. Use UMES extensions instead, or eject the module.
185
+ Proposed fix: Revert changes to node_modules. Use UMES extensions instead, or eject the module.
164
186
 
165
187
  3. **Is a module snapshot stale?**
166
188
  Check whether the generated SQL recreates a table or column that already has a committed migration.
167
- Fix: update that module's `migrations/.snapshot-open-mercato.json` to include the already-migrated schema, then re-run `yarn db:generate` and expect `no changes`.
189
+ Proposed fix: update that module's `migrations/.snapshot-open-mercato.json` to include the already-migrated schema, then re-run `yarn db:generate` and expect `no changes`.
168
190
 
169
191
  ### Entity changes not reflected
170
192
 
@@ -443,9 +465,10 @@ yarn dev # 5. Restart dev server
443
465
 
444
466
  ## Rules
445
467
 
446
- - **ALWAYS** run `yarn generate` as first diagnostic step
468
+ - **ALWAYS** present the diagnosed root cause and the proposed fix (commands/code), then **wait for explicit user confirmation before applying any mutating change** (see [Step 4](#step-4-propose-before-fixing)). Only read-only diagnostics may run without asking.
447
469
  - **ALWAYS** check server logs / browser console for actual error messages
448
470
  - **NEVER** edit files in `.mercato/generated/` or `node_modules/`
449
471
  - **NEVER** assume the issue — verify with actual error output
472
+ - Treat `yarn generate` as the most likely first fix, but propose it before running — it regenerates files and is a mutating action
450
473
  - Fix the root cause, not the symptom — temporary workarounds become permanent bugs
451
- - When suggesting a fix, include the exact command or code change needed
474
+ - When proposing a fix, include the exact command or code change needed
@@ -0,0 +1,89 @@
1
+ import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
2
+ import { resolveQueueStrategy } from "@open-mercato/queue";
3
+ const MULTI_INSTANCE_SAFE_STRATEGIES = {
4
+ cache: ["redis"],
5
+ queue: ["async"],
6
+ rateLimit: ["redis"]
7
+ };
8
+ const COMPONENT_ENV_VARS = {
9
+ cache: "CACHE_STRATEGY",
10
+ queue: "QUEUE_STRATEGY",
11
+ rateLimit: "RATE_LIMIT_STRATEGY"
12
+ };
13
+ class SingleInstanceStrategyError extends Error {
14
+ constructor(result) {
15
+ super(
16
+ `Refusing to start: single-instance infrastructure strategies (${result.offenders.map((offender) => `${offender.envVar}=${offender.configured}`).join(", ")}) are configured under a declared multi-instance topology. Switch to a multi-instance-safe strategy or set OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 to override.`
17
+ );
18
+ this.name = "SingleInstanceStrategyError";
19
+ this.offenders = result.offenders;
20
+ }
21
+ }
22
+ function readInfraStrategySnapshot(env = process.env) {
23
+ return {
24
+ cacheStrategy: env.CACHE_STRATEGY?.trim() || "memory",
25
+ queueStrategy: resolveQueueStrategy(),
26
+ rateLimitStrategy: env.RATE_LIMIT_STRATEGY?.trim() || "memory"
27
+ };
28
+ }
29
+ function resolveMultiInstanceHint(env) {
30
+ if (parseBooleanWithDefault(env.OM_MULTI_INSTANCE, false)) return true;
31
+ const instanceCount = Number.parseInt(env.OM_INSTANCE_COUNT ?? "", 10);
32
+ return Number.isFinite(instanceCount) && instanceCount > 1;
33
+ }
34
+ function evaluateSingleInstanceGuard(snapshot, env = process.env) {
35
+ const configured = {
36
+ cache: snapshot.cacheStrategy,
37
+ queue: snapshot.queueStrategy,
38
+ rateLimit: snapshot.rateLimitStrategy
39
+ };
40
+ const offenders = [];
41
+ for (const component of Object.keys(configured)) {
42
+ const safeValues = MULTI_INSTANCE_SAFE_STRATEGIES[component];
43
+ if (!safeValues.includes(configured[component])) {
44
+ offenders.push({
45
+ component,
46
+ envVar: COMPONENT_ENV_VARS[component],
47
+ configured: configured[component],
48
+ safeValues
49
+ });
50
+ }
51
+ }
52
+ const production = (env.NODE_ENV ?? "").trim() === "production";
53
+ const multiInstance = resolveMultiInstanceHint(env);
54
+ const overridden = parseBooleanWithDefault(env.OM_ALLOW_SINGLE_INSTANCE_STRATEGIES, false);
55
+ let action = "ok";
56
+ if (offenders.length > 0 && production) {
57
+ action = multiInstance && !overridden ? "fail" : "warn";
58
+ }
59
+ return { action, offenders, production, multiInstance, overridden };
60
+ }
61
+ function formatSingleInstanceGuardMessage(result) {
62
+ const offenderLines = result.offenders.map(
63
+ (offender) => ` - ${offender.envVar}=${offender.configured} (multi-instance-safe: ${offender.safeValues.join(", ")})`
64
+ );
65
+ const header = result.action === "fail" ? "[server] Refusing to start: single-instance infrastructure strategies under a multi-instance topology." : "[server] WARNING: single-instance infrastructure strategies detected in production.";
66
+ const guidance = result.action === "fail" ? "[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)." : result.multiInstance ? "[server] OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 is set \u2014 proceeding despite the risks above (duplicate jobs, stale ACLs, weakened rate limits)." : "[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.";
67
+ return [header, ...offenderLines, guidance];
68
+ }
69
+ function assertSingleInstanceStrategies(env = process.env, options) {
70
+ const snapshot = options?.snapshot ?? readInfraStrategySnapshot(env);
71
+ const result = evaluateSingleInstanceGuard(snapshot, env);
72
+ const logger = options?.logger ?? console;
73
+ if (result.action === "fail") {
74
+ for (const line of formatSingleInstanceGuardMessage(result)) logger.error(line);
75
+ throw new SingleInstanceStrategyError(result);
76
+ }
77
+ if (result.action === "warn") {
78
+ for (const line of formatSingleInstanceGuardMessage(result)) logger.warn(line);
79
+ }
80
+ return result;
81
+ }
82
+ export {
83
+ SingleInstanceStrategyError,
84
+ assertSingleInstanceStrategies,
85
+ evaluateSingleInstanceGuard,
86
+ formatSingleInstanceGuardMessage,
87
+ readInfraStrategySnapshot
88
+ };
89
+ //# sourceMappingURL=single-instance-strategy-guard.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/lib/single-instance-strategy-guard.ts"],
4
+ "sourcesContent": ["import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { resolveQueueStrategy } from '@open-mercato/queue'\n\n/**\n * Boot-time guard that refuses to start (or warns) when an infrastructure\n * strategy that is only safe for a single process/instance is configured under\n * a declared multi-instance topology.\n *\n * Background: `CACHE_STRATEGY`, `RATE_LIMIT_STRATEGY` and `QUEUE_STRATEGY` all\n * default to single-instance modes (`memory`/`memory`/`local`). Running more\n * than one app instance against those defaults silently breaks three\n * correctness invariants at once \u2014 stale RBAC caches after a privilege\n * revocation, rate limits multiplied by the instance count, and duplicate job\n * processing from the leaderless local file queue. None of them warn.\n *\n * Only strategies that coordinate across processes via shared external state\n * are considered multi-instance safe. Everything else (the in-process memory\n * strategies, the local file queue, and disk-backed caches that are not shared\n * across hosts) is treated as single-instance.\n */\nconst MULTI_INSTANCE_SAFE_STRATEGIES = {\n cache: ['redis'],\n queue: ['async'],\n rateLimit: ['redis'],\n} as const\n\nexport type SingleInstanceGuardComponent = 'cache' | 'queue' | 'rateLimit'\n\nexport type SingleInstanceGuardOffender = {\n component: SingleInstanceGuardComponent\n envVar: string\n configured: string\n safeValues: readonly string[]\n}\n\nexport type SingleInstanceGuardAction = 'ok' | 'warn' | 'fail'\n\nexport type SingleInstanceGuardResult = {\n action: SingleInstanceGuardAction\n offenders: SingleInstanceGuardOffender[]\n production: boolean\n multiInstance: boolean\n overridden: boolean\n}\n\nexport type InfraStrategySnapshot = {\n cacheStrategy: string\n queueStrategy: string\n rateLimitStrategy: string\n}\n\nconst COMPONENT_ENV_VARS: Record<SingleInstanceGuardComponent, string> = {\n cache: 'CACHE_STRATEGY',\n queue: 'QUEUE_STRATEGY',\n rateLimit: 'RATE_LIMIT_STRATEGY',\n}\n\nexport class SingleInstanceStrategyError extends Error {\n readonly offenders: SingleInstanceGuardOffender[]\n\n constructor(result: SingleInstanceGuardResult) {\n super(\n `Refusing to start: single-instance infrastructure strategies (${result.offenders\n .map((offender) => `${offender.envVar}=${offender.configured}`)\n .join(', ')}) are configured under a declared multi-instance topology. ` +\n 'Switch to a multi-instance-safe strategy or set OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 to override.',\n )\n this.name = 'SingleInstanceStrategyError'\n this.offenders = result.offenders\n }\n}\n\n/**\n * Read the configured infrastructure strategies. Queue resolution reuses the\n * canonical `resolveQueueStrategy()` so the default stays single-sourced; cache\n * and rate-limit values are read directly with their documented defaults\n * (`packages/cache/src/service.ts`, `packages/shared/src/lib/ratelimit/config.ts`)\n * because the guard's safe-set policy \u2014 not the resolver default \u2014 decides what\n * counts as single-instance.\n */\nexport function readInfraStrategySnapshot(env: NodeJS.ProcessEnv = process.env): InfraStrategySnapshot {\n return {\n cacheStrategy: env.CACHE_STRATEGY?.trim() || 'memory',\n queueStrategy: resolveQueueStrategy(),\n rateLimitStrategy: env.RATE_LIMIT_STRATEGY?.trim() || 'memory',\n }\n}\n\nfunction resolveMultiInstanceHint(env: NodeJS.ProcessEnv): boolean {\n if (parseBooleanWithDefault(env.OM_MULTI_INSTANCE, false)) return true\n const instanceCount = Number.parseInt(env.OM_INSTANCE_COUNT ?? '', 10)\n return Number.isFinite(instanceCount) && instanceCount > 1\n}\n\nexport function evaluateSingleInstanceGuard(\n snapshot: InfraStrategySnapshot,\n env: NodeJS.ProcessEnv = process.env,\n): SingleInstanceGuardResult {\n const configured: Record<SingleInstanceGuardComponent, string> = {\n cache: snapshot.cacheStrategy,\n queue: snapshot.queueStrategy,\n rateLimit: snapshot.rateLimitStrategy,\n }\n\n const offenders: SingleInstanceGuardOffender[] = []\n for (const component of Object.keys(configured) as SingleInstanceGuardComponent[]) {\n const safeValues: readonly string[] = MULTI_INSTANCE_SAFE_STRATEGIES[component]\n if (!safeValues.includes(configured[component])) {\n offenders.push({\n component,\n envVar: COMPONENT_ENV_VARS[component],\n configured: configured[component],\n safeValues,\n })\n }\n }\n\n const production = (env.NODE_ENV ?? '').trim() === 'production'\n const multiInstance = resolveMultiInstanceHint(env)\n const overridden = parseBooleanWithDefault(env.OM_ALLOW_SINGLE_INSTANCE_STRATEGIES, false)\n\n let action: SingleInstanceGuardAction = 'ok'\n if (offenders.length > 0 && production) {\n action = multiInstance && !overridden ? 'fail' : 'warn'\n }\n\n return { action, offenders, production, multiInstance, overridden }\n}\n\nexport function formatSingleInstanceGuardMessage(result: SingleInstanceGuardResult): string[] {\n const offenderLines = result.offenders.map(\n (offender) =>\n ` - ${offender.envVar}=${offender.configured} (multi-instance-safe: ${offender.safeValues.join(', ')})`,\n )\n const header =\n result.action === 'fail'\n ? '[server] Refusing to start: single-instance infrastructure strategies under a multi-instance topology.'\n : '[server] WARNING: single-instance infrastructure strategies detected in production.'\n const guidance =\n result.action === 'fail'\n ? '[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).'\n : result.multiInstance\n ? '[server] OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 is set \u2014 proceeding despite the risks above (duplicate jobs, stale ACLs, weakened rate limits).'\n : '[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.'\n return [header, ...offenderLines, guidance]\n}\n\nexport type SingleInstanceGuardLogger = Pick<Console, 'warn' | 'error'>\n\n/**\n * Evaluate the guard and enforce it: throw on `fail`, log prominently on\n * `warn`, and stay silent on `ok`. Safe to call on every `start`; it never\n * fires for dev boots or single-instance production deployments.\n */\nexport function assertSingleInstanceStrategies(\n env: NodeJS.ProcessEnv = process.env,\n options?: { snapshot?: InfraStrategySnapshot; logger?: SingleInstanceGuardLogger },\n): SingleInstanceGuardResult {\n const snapshot = options?.snapshot ?? readInfraStrategySnapshot(env)\n const result = evaluateSingleInstanceGuard(snapshot, env)\n const logger = options?.logger ?? console\n\n if (result.action === 'fail') {\n for (const line of formatSingleInstanceGuardMessage(result)) logger.error(line)\n throw new SingleInstanceStrategyError(result)\n }\n if (result.action === 'warn') {\n for (const line of formatSingleInstanceGuardMessage(result)) logger.warn(line)\n }\n\n return result\n}\n"],
5
+ "mappings": "AAAA,SAAS,+BAA+B;AACxC,SAAS,4BAA4B;AAmBrC,MAAM,iCAAiC;AAAA,EACrC,OAAO,CAAC,OAAO;AAAA,EACf,OAAO,CAAC,OAAO;AAAA,EACf,WAAW,CAAC,OAAO;AACrB;AA2BA,MAAM,qBAAmE;AAAA,EACvE,OAAO;AAAA,EACP,OAAO;AAAA,EACP,WAAW;AACb;AAEO,MAAM,oCAAoC,MAAM;AAAA,EAGrD,YAAY,QAAmC;AAC7C;AAAA,MACE,iEAAiE,OAAO,UACrE,IAAI,CAAC,aAAa,GAAG,SAAS,MAAM,IAAI,SAAS,UAAU,EAAE,EAC7D,KAAK,IAAI,CAAC;AAAA,IAEf;AACA,SAAK,OAAO;AACZ,SAAK,YAAY,OAAO;AAAA,EAC1B;AACF;AAUO,SAAS,0BAA0B,MAAyB,QAAQ,KAA4B;AACrG,SAAO;AAAA,IACL,eAAe,IAAI,gBAAgB,KAAK,KAAK;AAAA,IAC7C,eAAe,qBAAqB;AAAA,IACpC,mBAAmB,IAAI,qBAAqB,KAAK,KAAK;AAAA,EACxD;AACF;AAEA,SAAS,yBAAyB,KAAiC;AACjE,MAAI,wBAAwB,IAAI,mBAAmB,KAAK,EAAG,QAAO;AAClE,QAAM,gBAAgB,OAAO,SAAS,IAAI,qBAAqB,IAAI,EAAE;AACrE,SAAO,OAAO,SAAS,aAAa,KAAK,gBAAgB;AAC3D;AAEO,SAAS,4BACd,UACA,MAAyB,QAAQ,KACN;AAC3B,QAAM,aAA2D;AAAA,IAC/D,OAAO,SAAS;AAAA,IAChB,OAAO,SAAS;AAAA,IAChB,WAAW,SAAS;AAAA,EACtB;AAEA,QAAM,YAA2C,CAAC;AAClD,aAAW,aAAa,OAAO,KAAK,UAAU,GAAqC;AACjF,UAAM,aAAgC,+BAA+B,SAAS;AAC9E,QAAI,CAAC,WAAW,SAAS,WAAW,SAAS,CAAC,GAAG;AAC/C,gBAAU,KAAK;AAAA,QACb;AAAA,QACA,QAAQ,mBAAmB,SAAS;AAAA,QACpC,YAAY,WAAW,SAAS;AAAA,QAChC;AAAA,MACF,CAAC;AAAA,IACH;AAAA,EACF;AAEA,QAAM,cAAc,IAAI,YAAY,IAAI,KAAK,MAAM;AACnD,QAAM,gBAAgB,yBAAyB,GAAG;AAClD,QAAM,aAAa,wBAAwB,IAAI,qCAAqC,KAAK;AAEzF,MAAI,SAAoC;AACxC,MAAI,UAAU,SAAS,KAAK,YAAY;AACtC,aAAS,iBAAiB,CAAC,aAAa,SAAS;AAAA,EACnD;AAEA,SAAO,EAAE,QAAQ,WAAW,YAAY,eAAe,WAAW;AACpE;AAEO,SAAS,iCAAiC,QAA6C;AAC5F,QAAM,gBAAgB,OAAO,UAAU;AAAA,IACrC,CAAC,aACC,OAAO,SAAS,MAAM,IAAI,SAAS,UAAU,0BAA0B,SAAS,WAAW,KAAK,IAAI,CAAC;AAAA,EACzG;AACA,QAAM,SACJ,OAAO,WAAW,SACd,2GACA;AACN,QAAM,WACJ,OAAO,WAAW,SACd,mMACA,OAAO,gBACL,wJACA;AACR,SAAO,CAAC,QAAQ,GAAG,eAAe,QAAQ;AAC5C;AASO,SAAS,+BACd,MAAyB,QAAQ,KACjC,SAC2B;AAC3B,QAAM,WAAW,SAAS,YAAY,0BAA0B,GAAG;AACnE,QAAM,SAAS,4BAA4B,UAAU,GAAG;AACxD,QAAM,SAAS,SAAS,UAAU;AAElC,MAAI,OAAO,WAAW,QAAQ;AAC5B,eAAW,QAAQ,iCAAiC,MAAM,EAAG,QAAO,MAAM,IAAI;AAC9E,UAAM,IAAI,4BAA4B,MAAM;AAAA,EAC9C;AACA,MAAI,OAAO,WAAW,QAAQ;AAC5B,eAAW,QAAQ,iCAAiC,MAAM,EAAG,QAAO,KAAK,IAAI;AAAA,EAC/E;AAEA,SAAO;AACT;",
6
+ "names": []
7
+ }
@@ -0,0 +1,20 @@
1
+ function createPerJobWorkerHandler(workers, createContainer) {
2
+ return async (job, ctx) => {
3
+ const container = await createContainer();
4
+ try {
5
+ for (const worker of workers) {
6
+ await worker.handler(job, { ...ctx, resolve: container.resolve.bind(container) });
7
+ }
8
+ } finally {
9
+ try {
10
+ const em = container.resolve("em");
11
+ em?.clear?.();
12
+ } catch {
13
+ }
14
+ }
15
+ };
16
+ }
17
+ export {
18
+ createPerJobWorkerHandler
19
+ };
20
+ //# sourceMappingURL=worker-job-handler.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/lib/worker-job-handler.ts"],
4
+ "sourcesContent": ["import type { JobContext, JobHandler } from '@open-mercato/queue'\nimport type { ModuleWorker } from '@open-mercato/shared/modules/registry'\n\nexport type WorkerJobContainer = {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport type WorkerJobContainerFactory = () => Promise<WorkerJobContainer>\n\ntype ClearableEntityManager = {\n clear?: () => void\n}\n\n/**\n * Builds a queue job handler that isolates every job in its own request\n * container, instead of sharing a single process-wide `EntityManager` fork\n * across all concurrent jobs.\n *\n * Under the async (BullMQ) strategy jobs run with real concurrency, so a\n * shared EM would interleave unit-of-work flushes between unrelated jobs and\n * never release its identity map. Creating one container per job removes both\n * the cross-job flush race and the unbounded identity-map growth, while\n * keeping the `ctx.resolve` contract and DI keys unchanged. See issue #2970.\n */\nexport function createPerJobWorkerHandler(\n workers: ModuleWorker[],\n createContainer: WorkerJobContainerFactory,\n): JobHandler {\n return async (job, ctx: JobContext) => {\n const container = await createContainer()\n try {\n for (const worker of workers) {\n await worker.handler(job, { ...ctx, resolve: container.resolve.bind(container) })\n }\n } finally {\n try {\n const em = container.resolve('em') as ClearableEntityManager | null\n em?.clear?.()\n } catch {\n // Best-effort: clearing the identity map is a memory optimization and\n // must never mask a job's own outcome.\n }\n }\n }\n}\n"],
5
+ "mappings": "AAwBO,SAAS,0BACd,SACA,iBACY;AACZ,SAAO,OAAO,KAAK,QAAoB;AACrC,UAAM,YAAY,MAAM,gBAAgB;AACxC,QAAI;AACF,iBAAW,UAAU,SAAS;AAC5B,cAAM,OAAO,QAAQ,KAAK,EAAE,GAAG,KAAK,SAAS,UAAU,QAAQ,KAAK,SAAS,EAAE,CAAC;AAAA,MAClF;AAAA,IACF,UAAE;AACA,UAAI;AACF,cAAM,KAAK,UAAU,QAAQ,IAAI;AACjC,YAAI,QAAQ;AAAA,MACd,QAAQ;AAAA,MAGR;AAAA,IACF;AAAA,EACF;AACF;",
6
+ "names": []
7
+ }
package/dist/mercato.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  resolveLazyRestart
11
11
  } from "./lib/auto-spawn-workers.js";
12
12
  import { startLazyWorkerSupervisor } from "./lib/queue-worker-supervisor.js";
13
+ import { createPerJobWorkerHandler } from "./lib/worker-job-handler.js";
13
14
  import {
14
15
  resolveAutoSpawnSchedulerMode,
15
16
  resolveLazySchedulerPollMs,
@@ -25,6 +26,7 @@ import {
25
26
  import { parseModuleInstallArgs } from "./lib/module-install-args.js";
26
27
  import { resolveNextBuildIdCandidate } from "./lib/next-build-id.js";
27
28
  import { acquireServerStartLock } from "./lib/server-start-lock.js";
29
+ import { assertSingleInstanceStrategies } from "./lib/single-instance-strategy-guard.js";
28
30
  import { createDevEnvReloader, watchDevEnvFiles } from "./lib/dev-env-reload.js";
29
31
  const lazyIntegration = () => import("./lib/testing/integration.js");
30
32
  import path from "node:path";
@@ -1224,7 +1226,6 @@ async function run(argv = process.argv) {
1224
1226
  return;
1225
1227
  }
1226
1228
  const { createRequestContainer } = await import("@open-mercato/shared/lib/di/container");
1227
- const container = await createRequestContainer();
1228
1229
  console.log(`[worker] Starting workers for all queues: ${discoveredQueues.join(", ")}`);
1229
1230
  const workerPromises = discoveredQueues.map(async (queue) => {
1230
1231
  const queueWorkers = allWorkers.filter((w) => w.queue === queue);
@@ -1236,11 +1237,7 @@ async function run(argv = process.argv) {
1236
1237
  connection: queueRedisUrl ? { url: queueRedisUrl } : void 0,
1237
1238
  concurrency,
1238
1239
  background: true,
1239
- handler: async (job, ctx) => {
1240
- for (const worker of queueWorkers) {
1241
- await worker.handler(job, { ...ctx, resolve: container.resolve.bind(container) });
1242
- }
1243
- }
1240
+ handler: createPerJobWorkerHandler(queueWorkers, createRequestContainer)
1244
1241
  });
1245
1242
  });
1246
1243
  await Promise.all(workerPromises);
@@ -1251,7 +1248,6 @@ async function run(argv = process.argv) {
1251
1248
  const queueWorkers = allWorkers.filter((w) => w.queue === queueName);
1252
1249
  if (queueWorkers.length > 0) {
1253
1250
  const { createRequestContainer } = await import("@open-mercato/shared/lib/di/container");
1254
- const container = await createRequestContainer();
1255
1251
  const concurrency = concurrencyOverride ?? Math.max(...queueWorkers.map((w) => w.concurrency), 1);
1256
1252
  console.log(`[worker] Found ${queueWorkers.length} worker(s) for queue "${queueName}"`);
1257
1253
  const queueRedisUrl = getRedisUrl("QUEUE");
@@ -1259,11 +1255,7 @@ async function run(argv = process.argv) {
1259
1255
  queueName,
1260
1256
  connection: queueRedisUrl ? { url: queueRedisUrl } : void 0,
1261
1257
  concurrency,
1262
- handler: async (job, ctx) => {
1263
- for (const worker of queueWorkers) {
1264
- await worker.handler(job, { ...ctx, resolve: container.resolve.bind(container) });
1265
- }
1266
- }
1258
+ handler: createPerJobWorkerHandler(queueWorkers, createRequestContainer)
1267
1259
  });
1268
1260
  } else {
1269
1261
  console.error(`No workers found for queue "${queueName}"`);
@@ -1781,6 +1773,7 @@ async function run(argv = process.argv) {
1781
1773
  const autoSpawnSchedulerMode = resolveAutoSpawnSchedulerMode(process.env);
1782
1774
  const queueStrategy = process.env.QUEUE_STRATEGY || "local";
1783
1775
  const runtimeEnv = buildServerProcessEnvironment(process.env);
1776
+ assertSingleInstanceStrategies(runtimeEnv);
1784
1777
  const schedulerCommand = lookupModuleCommand(getCliModules(), "scheduler", "start");
1785
1778
  const serverStartLock = acquireServerStartLock(appDir, {
1786
1779
  port: runtimeEnv.PORT ?? process.env.PORT ?? null