@open-mercato/cli 0.6.8-develop.7016.1.4ed7b1e49d → 0.6.8-develop.7020.1.d14d757582

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.
Files changed (79) hide show
  1. package/dist/agentic/guides/module-facts.json +130 -130
  2. package/dist/agentic/guides/module-facts.v2.json +130 -130
  3. package/dist/agentic/guides/modules/ai_assistant/index.md +1 -1
  4. package/dist/agentic/guides/modules/api_docs/index.md +1 -1
  5. package/dist/agentic/guides/modules/api_keys/index.md +1 -1
  6. package/dist/agentic/guides/modules/attachments/index.md +1 -1
  7. package/dist/agentic/guides/modules/audit_logs/index.md +1 -1
  8. package/dist/agentic/guides/modules/auth/index.md +1 -1
  9. package/dist/agentic/guides/modules/business_rules/index.md +1 -1
  10. package/dist/agentic/guides/modules/catalog/index.md +1 -1
  11. package/dist/agentic/guides/modules/channel_apns/index.md +1 -1
  12. package/dist/agentic/guides/modules/channel_expo/index.md +1 -1
  13. package/dist/agentic/guides/modules/channel_fcm/index.md +1 -1
  14. package/dist/agentic/guides/modules/channel_gmail/index.md +1 -1
  15. package/dist/agentic/guides/modules/channel_imap/index.md +1 -1
  16. package/dist/agentic/guides/modules/checkout/index.md +1 -1
  17. package/dist/agentic/guides/modules/communication_channels/index.md +1 -1
  18. package/dist/agentic/guides/modules/configs/index.md +1 -1
  19. package/dist/agentic/guides/modules/content/index.md +1 -1
  20. package/dist/agentic/guides/modules/currencies/index.md +1 -1
  21. package/dist/agentic/guides/modules/customer_accounts/index.md +1 -1
  22. package/dist/agentic/guides/modules/customers/index.md +1 -1
  23. package/dist/agentic/guides/modules/dashboards/index.md +1 -1
  24. package/dist/agentic/guides/modules/data_sync/exact-override-targets.md +2 -2
  25. package/dist/agentic/guides/modules/data_sync/index.md +1 -1
  26. package/dist/agentic/guides/modules/data_sync/workers.md +2 -2
  27. package/dist/agentic/guides/modules/design_system/index.md +1 -1
  28. package/dist/agentic/guides/modules/devices/index.md +1 -1
  29. package/dist/agentic/guides/modules/dictionaries/index.md +1 -1
  30. package/dist/agentic/guides/modules/directory/index.md +1 -1
  31. package/dist/agentic/guides/modules/documents/index.md +1 -1
  32. package/dist/agentic/guides/modules/entities/index.md +1 -1
  33. package/dist/agentic/guides/modules/eudr/index.md +1 -1
  34. package/dist/agentic/guides/modules/events/index.md +1 -1
  35. package/dist/agentic/guides/modules/feature_toggles/index.md +1 -1
  36. package/dist/agentic/guides/modules/gateway_stripe/index.md +1 -1
  37. package/dist/agentic/guides/modules/generators/index.md +1 -1
  38. package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
  39. package/dist/agentic/guides/modules/integrations/index.md +1 -1
  40. package/dist/agentic/guides/modules/messages/index.md +1 -1
  41. package/dist/agentic/guides/modules/notifications/index.md +1 -1
  42. package/dist/agentic/guides/modules/onboarding/index.md +1 -1
  43. package/dist/agentic/guides/modules/payment_gateways/index.md +1 -1
  44. package/dist/agentic/guides/modules/perspectives/index.md +1 -1
  45. package/dist/agentic/guides/modules/planner/index.md +1 -1
  46. package/dist/agentic/guides/modules/portal/index.md +1 -1
  47. package/dist/agentic/guides/modules/progress/index.md +1 -1
  48. package/dist/agentic/guides/modules/push_notifications/index.md +1 -1
  49. package/dist/agentic/guides/modules/query_index/index.md +1 -1
  50. package/dist/agentic/guides/modules/record_locks/index.md +1 -1
  51. package/dist/agentic/guides/modules/resources/index.md +1 -1
  52. package/dist/agentic/guides/modules/sales/index.md +1 -1
  53. package/dist/agentic/guides/modules/scheduler/index.md +1 -1
  54. package/dist/agentic/guides/modules/search/index.md +1 -1
  55. package/dist/agentic/guides/modules/security/index.md +1 -1
  56. package/dist/agentic/guides/modules/shipping_carriers/index.md +1 -1
  57. package/dist/agentic/guides/modules/sso/index.md +1 -1
  58. package/dist/agentic/guides/modules/staff/index.md +1 -1
  59. package/dist/agentic/guides/modules/storage_s3/index.md +1 -1
  60. package/dist/agentic/guides/modules/sync_akeneo/index.md +1 -1
  61. package/dist/agentic/guides/modules/sync_excel/index.md +1 -1
  62. package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
  63. package/dist/agentic/guides/modules/translations/index.md +1 -1
  64. package/dist/agentic/guides/modules/warranty_claims/index.md +1 -1
  65. package/dist/agentic/guides/modules/webhooks/index.md +1 -1
  66. package/dist/agentic/guides/modules/wms/index.md +1 -1
  67. package/dist/agentic/guides/modules/workflows/index.md +1 -1
  68. package/dist/agentic/guides/reference-module-facts.json +1 -1
  69. package/dist/agentic/guides/upstream/manifest.json +1 -1
  70. package/dist/lib/generators/module-registry.js +77 -11
  71. package/dist/lib/generators/module-registry.js.map +2 -2
  72. package/dist/mercato.js +14 -0
  73. package/dist/mercato.js.map +2 -2
  74. package/package.json +6 -6
  75. package/src/__tests__/resolve-queue-abandon-hook.test.ts +39 -0
  76. package/src/lib/generators/__tests__/generated-registry-imports.test.ts +128 -0
  77. package/src/lib/generators/__tests__/metadata-extraction.test.ts +44 -0
  78. package/src/lib/generators/module-registry.ts +118 -5
  79. package/src/mercato.ts +27 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cli",
3
- "version": "0.6.8-develop.7016.1.4ed7b1e49d",
3
+ "version": "0.6.8-develop.7020.1.d14d757582",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -60,9 +60,9 @@
60
60
  "@mikro-orm/decorators": "^7.1.8",
61
61
  "@mikro-orm/migrations": "^7.1.8",
62
62
  "@mikro-orm/postgresql": "^7.1.8",
63
- "@open-mercato/queue": "0.6.8-develop.7016.1.4ed7b1e49d",
64
- "@open-mercato/shared": "0.6.8-develop.7016.1.4ed7b1e49d",
65
- "@open-mercato/telemetry": "0.6.8-develop.7016.1.4ed7b1e49d",
63
+ "@open-mercato/queue": "0.6.8-develop.7020.1.d14d757582",
64
+ "@open-mercato/shared": "0.6.8-develop.7020.1.d14d757582",
65
+ "@open-mercato/telemetry": "0.6.8-develop.7020.1.d14d757582",
66
66
  "cross-spawn": "^7.0.6",
67
67
  "pg": "8.22.0",
68
68
  "semver": "^7.8.5",
@@ -73,10 +73,10 @@
73
73
  "typescript-js": "npm:typescript@6.0.3"
74
74
  },
75
75
  "peerDependencies": {
76
- "@open-mercato/shared": "0.6.8-develop.7016.1.4ed7b1e49d"
76
+ "@open-mercato/shared": "0.6.8-develop.7020.1.d14d757582"
77
77
  },
78
78
  "devDependencies": {
79
- "@open-mercato/shared": "0.6.8-develop.7016.1.4ed7b1e49d",
79
+ "@open-mercato/shared": "0.6.8-develop.7020.1.d14d757582",
80
80
  "@types/jest": "^30.0.0",
81
81
  "jest": "^30.4.2",
82
82
  "ts-jest": "^29.4.12"
@@ -0,0 +1,39 @@
1
+ import { resolveQueueAbandonHook } from '../mercato'
2
+
3
+ /**
4
+ * Covers the CLI's per-queue pick, which is the last link between worker metadata and the queue the
5
+ * worker runs on. The warning exists because two handlers on one queue each expecting to report is a
6
+ * wiring mistake, and a silent first-wins would hide it.
7
+ */
8
+ describe('resolveQueueAbandonHook', () => {
9
+ const hookA = jest.fn(async () => {})
10
+ const hookB = jest.fn(async () => {})
11
+
12
+ it('returns undefined when no worker declares one', () => {
13
+ const warn = jest.fn()
14
+ expect(resolveQueueAbandonHook('q', [{ id: 'a' }, { id: 'b' }], warn)).toBeUndefined()
15
+ expect(warn).not.toHaveBeenCalled()
16
+ })
17
+
18
+ it('returns the single declared callback without warning', () => {
19
+ const warn = jest.fn()
20
+ expect(resolveQueueAbandonHook('q', [{ id: 'a' }, { id: 'b', onJobAbandoned: hookA }], warn)).toBe(hookA)
21
+ expect(warn).not.toHaveBeenCalled()
22
+ })
23
+
24
+ it('keeps the first and warns, naming the queue and every declaring worker', () => {
25
+ const warn = jest.fn()
26
+ const picked = resolveQueueAbandonHook(
27
+ 'data-sync-import',
28
+ [{ id: 'first', onJobAbandoned: hookA }, { id: 'second', onJobAbandoned: hookB }],
29
+ warn,
30
+ )
31
+
32
+ expect(picked).toBe(hookA)
33
+ expect(warn).toHaveBeenCalledTimes(1)
34
+ const message = warn.mock.calls[0][0] as string
35
+ expect(message).toContain('data-sync-import')
36
+ expect(message).toContain('first')
37
+ expect(message).toContain('second')
38
+ })
39
+ })
@@ -0,0 +1,128 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import type { PackageResolver, ModuleEntry } from '../../resolver'
5
+ import { generateModuleRegistry, generateModuleRegistryApp, generateModuleRegistryCli } from '../module-registry'
6
+
7
+ /**
8
+ * The generator's output, not its helpers.
9
+ *
10
+ * A worker declaring `metadata.onJobAbandoned` makes the registry emit
11
+ * `createLazyModuleWorkerAbandonHook(...)`, and the import for it used to be opted into per render
12
+ * site. Forgetting it at one of the six sites produced a file referencing an undeclared name, which
13
+ * type-checks nowhere and surfaces only at `build:app` — every unit test stayed green because no
14
+ * fixture declared such a worker.
15
+ *
16
+ * These assertions are driven off the files the generators actually wrote, so a renderer added later
17
+ * is covered without anyone remembering to extend this test.
18
+ */
19
+
20
+ let tmpDir: string
21
+
22
+ function touchFile(filePath: string, content = ''): void {
23
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
24
+ fs.writeFileSync(filePath, content)
25
+ }
26
+
27
+ function createMockResolver(dir: string, enabled: ModuleEntry[]): PackageResolver {
28
+ const outputDir = path.join(dir, 'output', 'generated')
29
+ fs.mkdirSync(outputDir, { recursive: true })
30
+ return {
31
+ isMonorepo: () => true,
32
+ getRootDir: () => dir,
33
+ getAppDir: () => path.join(dir, 'app'),
34
+ getOutputDir: () => outputDir,
35
+ getModulesConfigPath: () => path.join(dir, 'app', 'src', 'modules.ts'),
36
+ discoverPackages: () => [],
37
+ loadEnabledModules: () => enabled,
38
+ getModulePaths: (entry: ModuleEntry) => ({
39
+ appBase: path.join(dir, 'app', 'src', 'modules', entry.id),
40
+ pkgBase: path.join(dir, 'packages', 'core', 'src', 'modules', entry.id),
41
+ }),
42
+ getModuleImportBase: (entry: ModuleEntry) => ({
43
+ appBase: `@/modules/${entry.id}`,
44
+ pkgBase: `@open-mercato/core/modules/${entry.id}`,
45
+ }),
46
+ getPackageOutputDir: () => outputDir,
47
+ getPackageRoot: () => path.join(dir, 'packages', 'core'),
48
+ }
49
+ }
50
+
51
+ function scaffoldWorkerModule(dir: string, modId: string, workerSource: string): void {
52
+ touchFile(path.join(dir, 'packages', 'core', 'src', 'modules', modId, 'workers', 'sync.ts'), workerSource)
53
+ }
54
+
55
+ function generatedFiles(dir: string): Array<{ name: string; content: string }> {
56
+ const outputDir = path.join(dir, 'output', 'generated')
57
+ if (!fs.existsSync(outputDir)) return []
58
+ return fs.readdirSync(outputDir)
59
+ .filter((name) => name.endsWith('.ts'))
60
+ .map((name) => ({ name, content: fs.readFileSync(path.join(outputDir, name), 'utf8') }))
61
+ }
62
+
63
+ const ABANDON_HOOK_FACTORY = 'createLazyModuleWorkerAbandonHook'
64
+
65
+ const WORKER_WITH_HOOK = `
66
+ import { failAbandonedRun } from '../lib/abandoned-run'
67
+ export const metadata = {
68
+ queue: 'demo-queue',
69
+ id: 'demo:worker',
70
+ concurrency: 2,
71
+ onJobAbandoned: failAbandonedRun,
72
+ }
73
+ export default async function handle() {}
74
+ `
75
+
76
+ const WORKER_WITHOUT_HOOK = `
77
+ export const metadata = {
78
+ queue: 'demo-queue',
79
+ id: 'demo:worker',
80
+ concurrency: 2,
81
+ }
82
+ export default async function handle() {}
83
+ `
84
+
85
+ async function generateAll(resolver: PackageResolver): Promise<void> {
86
+ await generateModuleRegistry({ resolver, quiet: true })
87
+ await generateModuleRegistryApp({ resolver, quiet: true })
88
+ await generateModuleRegistryCli({ resolver, quiet: true })
89
+ }
90
+
91
+ beforeEach(() => {
92
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'generated-registry-imports-'))
93
+ })
94
+
95
+ afterEach(() => {
96
+ fs.rmSync(tmpDir, { recursive: true, force: true })
97
+ })
98
+
99
+ describe('generated registries — emitted identifiers are imported', () => {
100
+ it('imports the abandon-hook helper in every file that calls it', async () => {
101
+ scaffoldWorkerModule(tmpDir, 'demo', WORKER_WITH_HOOK)
102
+ const resolver = createMockResolver(tmpDir, [{ id: 'demo', from: 'pkg' } as ModuleEntry])
103
+
104
+ await generateAll(resolver)
105
+
106
+ const files = generatedFiles(tmpDir)
107
+ const callers = files.filter(({ content }) => content.includes(`${ABANDON_HOOK_FACTORY}(`))
108
+ // The fixture must actually exercise the path, or this test passes vacuously — which is exactly
109
+ // how the missing import survived every existing suite.
110
+ expect(callers.length).toBeGreaterThan(0)
111
+
112
+ for (const { name, content } of callers) {
113
+ expect(`${name}: ${content.includes(`import { `) && new RegExp(`import \\{[^}]*\\b${ABANDON_HOOK_FACTORY}\\b[^}]*\\} from`, 's').test(content)}`)
114
+ .toBe(`${name}: true`)
115
+ }
116
+ })
117
+
118
+ it('leaves output untouched for a module set whose workers declare no hook', async () => {
119
+ scaffoldWorkerModule(tmpDir, 'demo', WORKER_WITHOUT_HOOK)
120
+ const resolver = createMockResolver(tmpDir, [{ id: 'demo', from: 'pkg' } as ModuleEntry])
121
+
122
+ await generateAll(resolver)
123
+
124
+ for (const { name, content } of generatedFiles(tmpDir)) {
125
+ expect(`${name}: ${content.includes(ABANDON_HOOK_FACTORY)}`).toBe(`${name}: false`)
126
+ }
127
+ })
128
+ })
@@ -4,6 +4,7 @@ import os from 'node:os'
4
4
  import {
5
5
  extractNamedObjectLiteralExport,
6
6
  hasNamedExport,
7
+ namedObjectLiteralDeclaresProperty,
7
8
  resolveNamedObjectExport,
8
9
  } from '../module-registry'
9
10
 
@@ -218,3 +219,46 @@ describe('hasNamedExport', () => {
218
219
  expect(hasNamedExport(file, 'metadata')).toBe(false)
219
220
  })
220
221
  })
222
+
223
+ describe('namedObjectLiteralDeclaresProperty', () => {
224
+ // A worker declaring `onJobAbandoned: someImportedFn` is the case that broke: the value resolvers
225
+ // cannot evaluate an imported identifier, so "is it declared" has to be answered from the syntax.
226
+ // Getting this wrong is silent — the generator simply never emits the hook.
227
+ it('sees a property whose value is an unresolvable identifier', () => {
228
+ const file = writeSource('worker.ts', `
229
+ import { failAbandonedRun } from '../lib/abandoned-run'
230
+ export const metadata = {
231
+ queue: 'data-sync-import',
232
+ concurrency: 5,
233
+ onJobAbandoned: failAbandonedRun,
234
+ }
235
+ `)
236
+ expect(namedObjectLiteralDeclaresProperty(file, 'metadata', 'onJobAbandoned')).toBe(true)
237
+ expect(extractNamedObjectLiteralExport(file, 'metadata')?.onJobAbandoned).toBeUndefined()
238
+ })
239
+
240
+ it('sees a property declared as an inline function', () => {
241
+ const file = writeSource('worker.ts', `
242
+ export const metadata = {
243
+ queue: 'q',
244
+ onJobAbandoned: async () => {},
245
+ }
246
+ `)
247
+ expect(namedObjectLiteralDeclaresProperty(file, 'metadata', 'onJobAbandoned')).toBe(true)
248
+ })
249
+
250
+ it('reports absence for a worker that declares no hook', () => {
251
+ const file = writeSource('worker.ts', `
252
+ export const metadata = { queue: 'q', concurrency: 1 }
253
+ `)
254
+ expect(namedObjectLiteralDeclaresProperty(file, 'metadata', 'onJobAbandoned')).toBe(false)
255
+ })
256
+
257
+ it('reports absence for a metadata export that is not an object literal', () => {
258
+ const file = writeSource('worker.ts', `
259
+ const base = buildMetadata()
260
+ export const metadata = base
261
+ `)
262
+ expect(namedObjectLiteralDeclaresProperty(file, 'metadata', 'onJobAbandoned')).toBe(false)
263
+ })
264
+ })
@@ -246,6 +246,15 @@ type SerializableWorkerMetadata = {
246
246
  concurrency?: number
247
247
  lockDuration?: number
248
248
  maxStalledCount?: number
249
+ /**
250
+ * Whether the worker's metadata declares an `onJobAbandoned` callback.
251
+ *
252
+ * A function cannot be serialized into the registry the way the scalar options are, so the flag is
253
+ * resolved here at build time and the callback itself is emitted as a lazy import beside the
254
+ * handler. Recorded only when the source module could actually be loaded — the object-literal
255
+ * fallback below cannot see whether a referenced identifier is a function.
256
+ */
257
+ hasJobAbandonedHook?: boolean
249
258
  }
250
259
 
251
260
  type PageMetadataManifestLoadResult = {
@@ -577,6 +586,55 @@ function extractObjectPropertiesFromAst(sourceFile: string, exportName: string):
577
586
  return Object.keys(result).length > 0 ? result : null
578
587
  }
579
588
 
589
+ /**
590
+ * Whether an exported object literal declares a property, regardless of what it evaluates to.
591
+ *
592
+ * Needed for callbacks: the value resolvers above can read literals and local constants, but a
593
+ * property whose value is an imported function stays unresolvable, so "did the author declare it"
594
+ * cannot be answered by inspecting the extracted value. Answering it from the syntax instead is what
595
+ * lets the generator emit a lazy accessor for a hook it can see but cannot serialize.
596
+ */
597
+ export function namedObjectLiteralDeclaresProperty(sourceFile: string, exportName: string, propertyName: string): boolean {
598
+ let source = ''
599
+ try {
600
+ source = fs.readFileSync(sourceFile, 'utf8')
601
+ } catch {
602
+ return false
603
+ }
604
+
605
+ const parsed = ts.createSourceFile(sourceFile, source, ts.ScriptTarget.Latest, true, inferScriptKind(sourceFile))
606
+ const exportedNames = new Set<string>()
607
+ for (const statement of parsed.statements) {
608
+ if (
609
+ ts.isExportDeclaration(statement)
610
+ && statement.exportClause
611
+ && ts.isNamedExports(statement.exportClause)
612
+ && !statement.moduleSpecifier
613
+ ) {
614
+ for (const element of statement.exportClause.elements) exportedNames.add(element.name.text)
615
+ }
616
+ }
617
+
618
+ for (const statement of parsed.statements) {
619
+ if (!ts.isVariableStatement(statement)) continue
620
+ const exportsDirectly = (ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined)
621
+ ?.some((modifier: ts.Modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
622
+ for (const declaration of statement.declarationList.declarations) {
623
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== exportName) continue
624
+ if (!exportsDirectly && !exportedNames.has(exportName)) continue
625
+ const objectLiteral = unwrapObjectLiteralExpression(declaration.initializer)
626
+ if (!objectLiteral) return false
627
+ return objectLiteral.properties.some((property) => {
628
+ const name = property.name
629
+ if (!name) return false
630
+ const key = ts.isIdentifier(name) ? name.text : ts.isStringLiteral(name) ? name.text : null
631
+ return key === propertyName
632
+ })
633
+ }
634
+ }
635
+ return false
636
+ }
637
+
580
638
  export function extractNamedObjectLiteralExport(sourceFile: string, exportName: string): Record<string, unknown> | null {
581
639
  const literal = extractNamedObjectLiteralSource(sourceFile, exportName)
582
640
  if (literal) {
@@ -1777,6 +1835,7 @@ function normalizeWorkerMetadata(raw: unknown): SerializableWorkerMetadata | nul
1777
1835
  if (typeof source.concurrency === 'number') normalized.concurrency = source.concurrency
1778
1836
  if (typeof source.lockDuration === 'number') normalized.lockDuration = source.lockDuration
1779
1837
  if (typeof source.maxStalledCount === 'number') normalized.maxStalledCount = source.maxStalledCount
1838
+ if (typeof source.onJobAbandoned === 'function') normalized.hasJobAbandonedHook = true
1780
1839
  return Object.keys(normalized).length > 0 ? normalized : null
1781
1840
  }
1782
1841
 
@@ -1788,8 +1847,16 @@ async function loadSubscriberMetadata(sourceFile: string): Promise<SerializableS
1788
1847
 
1789
1848
  async function loadWorkerMetadata(sourceFile: string): Promise<SerializableWorkerMetadata | null> {
1790
1849
  const sourceModule = await loadModuleExportsFromSource<Record<string, unknown>>(sourceFile)
1791
- return normalizeWorkerMetadata(sourceModule?.metadata)
1850
+ const metadata = normalizeWorkerMetadata(sourceModule?.metadata)
1792
1851
  ?? normalizeWorkerMetadata(extractNamedObjectLiteralExport(sourceFile, 'metadata'))
1852
+ if (!metadata) return null
1853
+ // Resolved from the syntax, not the extracted value: a worker module that cannot be imported at
1854
+ // build time falls back to the literal extractor, which cannot tell an imported function from an
1855
+ // unresolvable identifier. Missing this is silent — the hook is simply never installed.
1856
+ if (!metadata.hasJobAbandonedHook && namedObjectLiteralDeclaresProperty(sourceFile, 'metadata', 'onJobAbandoned')) {
1857
+ metadata.hasJobAbandonedHook = true
1858
+ }
1859
+ return metadata
1793
1860
  }
1794
1861
 
1795
1862
  async function discoverSubscribers(
@@ -2326,12 +2393,25 @@ function processSubscribers(discovered: DiscoveredSubscriber[]): string[] {
2326
2393
  return subscribers
2327
2394
  }
2328
2395
 
2396
+ /**
2397
+ * Registry helper emitted for a worker that declares `metadata.onJobAbandoned`.
2398
+ *
2399
+ * Named once because two things must stay in step: the call the worker entry renders, and the import
2400
+ * the file needs for it. A generated file that references this without importing it type-checks
2401
+ * nowhere, and only fails at `build:app` — see `generated-registry-imports.test.ts`.
2402
+ */
2403
+ const ABANDON_HOOK_FACTORY = 'createLazyModuleWorkerAbandonHook'
2404
+
2405
+ function workersDeclareAbandonHook(discovered: DiscoveredWorker[]): boolean {
2406
+ return discovered.some(({ metadata }) => metadata.hasJobAbandonedHook === true)
2407
+ }
2408
+
2329
2409
  function processWorkers(discovered: DiscoveredWorker[]): string[] {
2330
2410
  const workers: string[] = []
2331
2411
  for (const { id, importPath, metadata } of discovered) {
2332
2412
  const workerId = metadata.id ?? id
2333
2413
  workers.push(
2334
- `{ id: ${toLiteral(workerId)}, queue: ${toLiteral(metadata.queue)}, concurrency: ${toLiteral(metadata.concurrency ?? 1)}${metadata.lockDuration === undefined ? '' : `, lockDuration: ${toLiteral(metadata.lockDuration)}`}${metadata.maxStalledCount === undefined ? '' : `, maxStalledCount: ${toLiteral(metadata.maxStalledCount)}`}, handler: createLazyModuleWorker(() => ${buildDynamicImportExpression(importPath)}, ${toLiteral(workerId)}) }`
2414
+ `{ id: ${toLiteral(workerId)}, queue: ${toLiteral(metadata.queue)}, concurrency: ${toLiteral(metadata.concurrency ?? 1)}${metadata.lockDuration === undefined ? '' : `, lockDuration: ${toLiteral(metadata.lockDuration)}`}${metadata.maxStalledCount === undefined ? '' : `, maxStalledCount: ${toLiteral(metadata.maxStalledCount)}`}${metadata.hasJobAbandonedHook ? `, onJobAbandoned: ${ABANDON_HOOK_FACTORY}(() => ${buildDynamicImportExpression(importPath)}, ${toLiteral(workerId)})` : ''}, handler: createLazyModuleWorker(() => ${buildDynamicImportExpression(importPath)}, ${toLiteral(workerId)}) }`
2335
2415
  )
2336
2416
  }
2337
2417
  return workers
@@ -2567,6 +2647,7 @@ function renderAstModuleRegistryFile(options: {
2567
2647
  imports: string[]
2568
2648
  moduleEntries: WriterFunction[]
2569
2649
  includeCreateElementImport?: boolean
2650
+ includeAbandonHookImport?: boolean
2570
2651
  }): string {
2571
2652
  const sourceFile = createGeneratedSourceFile(options.fileName)
2572
2653
  addAutoGeneratedComment(sourceFile, options.generator)
@@ -2582,6 +2663,7 @@ function renderAstModuleRegistryFile(options: {
2582
2663
  namedImports: [
2583
2664
  { name: 'createLazyModuleSubscriber' },
2584
2665
  { name: 'createLazyModuleWorker' },
2666
+ ...(options.includeAbandonHookImport ? [{ name: ABANDON_HOOK_FACTORY }] : []),
2585
2667
  { name: 'Module', isTypeOnly: true },
2586
2668
  ],
2587
2669
  })
@@ -2694,10 +2776,18 @@ function renderAstLegacyModuleRegistryOutput(options: {
2694
2776
  imports: GeneratedImportStatement[]
2695
2777
  moduleEntries: string[]
2696
2778
  includeCreateElementImport?: boolean
2779
+ includeAbandonHookImport?: boolean
2697
2780
  }): string {
2781
+ // Derived from what is actually being written, not from a flag the caller has to remember: an
2782
+ // import that must be opted into at every render site produces a file referencing an undeclared
2783
+ // name the first time someone forgets, and the file only fails at `build:app`.
2784
+ const needsAbandonHookImport = options.includeAbandonHookImport
2785
+ || options.moduleEntries.some((entry) => entry.includes(ABANDON_HOOK_FACTORY))
2698
2786
  const importSection = [
2699
2787
  ...(options.includeCreateElementImport ? ["import { createElement } from 'react'"] : []),
2700
- "import { createLazyModuleSubscriber, createLazyModuleWorker, type Module } from '@open-mercato/shared/modules/registry'",
2788
+ needsAbandonHookImport
2789
+ ? "import { createLazyModuleSubscriber, createLazyModuleWorker, createLazyModuleWorkerAbandonHook, type Module } from '@open-mercato/shared/modules/registry'"
2790
+ : "import { createLazyModuleSubscriber, createLazyModuleWorker, type Module } from '@open-mercato/shared/modules/registry'",
2701
2791
  ...options.imports.map((entry) => serializeGeneratedImport(entry)),
2702
2792
  ].join('\n')
2703
2793
 
@@ -3011,6 +3101,15 @@ function processWorkersAst(discovered: DiscoveredWorker[]): WriterFunction[] {
3011
3101
  { name: 'concurrency', value: metadata.concurrency ?? 1 },
3012
3102
  ...(metadata.lockDuration === undefined ? [] : [{ name: 'lockDuration', value: metadata.lockDuration }]),
3013
3103
  ...(metadata.maxStalledCount === undefined ? [] : [{ name: 'maxStalledCount', value: metadata.maxStalledCount }]),
3104
+ ...(metadata.hasJobAbandonedHook
3105
+ ? [{
3106
+ name: 'onJobAbandoned',
3107
+ value: callExpression(identifier(ABANDON_HOOK_FACTORY), [
3108
+ arrowFunction({ body: importExpression(importPath) }),
3109
+ workerId,
3110
+ ]),
3111
+ }]
3112
+ : []),
3014
3113
  {
3015
3114
  name: 'handler',
3016
3115
  value: callExpression(identifier('createLazyModuleWorker'), [
@@ -3384,6 +3483,7 @@ async function generateModuleRegistryFromDiscovery(options: ModuleRegistryRender
3384
3483
  const requiresByModule = new Map<string, string[]>()
3385
3484
  const commandLoaderEntries: CommandLoaderGenerationEntry[] = []
3386
3485
  let hasRouteComponents = false
3486
+ let hasAbandonHookWorkers = false
3387
3487
 
3388
3488
  // UMES conflict detection: collect file paths during module processing
3389
3489
  const umesConflictSources: Array<{
@@ -3635,7 +3735,9 @@ async function generateModuleRegistryFromDiscovery(options: ModuleRegistryRender
3635
3735
  subscribers.push(...processSubscribers(await discovered.getSubscribers()))
3636
3736
 
3637
3737
  // 18. Workers
3638
- workers.push(...processWorkers(await discovered.getWorkers()))
3738
+ const discoveredWorkersForModule = await discovered.getWorkers()
3739
+ if (workersDeclareAbandonHook(discoveredWorkersForModule)) hasAbandonHookWorkers = true
3740
+ workers.push(...processWorkers(discoveredWorkersForModule))
3639
3741
 
3640
3742
  // Build combined customFieldSets expression
3641
3743
  {
@@ -3801,12 +3903,14 @@ async function generateModuleRegistryFromDiscovery(options: ModuleRegistryRender
3801
3903
  imports,
3802
3904
  moduleEntries: moduleDecls,
3803
3905
  includeCreateElementImport: hasRouteComponents,
3906
+ includeAbandonHookImport: hasAbandonHookWorkers,
3804
3907
  })
3805
3908
  const runtimeOutput = renderAstLegacyModuleRegistryOutput({
3806
3909
  fileName: 'modules.runtime.generated.ts',
3807
3910
  imports: runtimeImports,
3808
3911
  moduleEntries: runtimeModuleDecls,
3809
3912
  includeCreateElementImport: true,
3913
+ includeAbandonHookImport: hasAbandonHookWorkers,
3810
3914
  })
3811
3915
  const frontendRoutesOutput = renderAstLegacyManifestOutput({
3812
3916
  fileName: 'frontend-routes.generated.ts',
@@ -4010,6 +4114,7 @@ async function generateModuleRegistryAppFromDiscovery(options: ModuleRegistryRen
4010
4114
  const requiresByModule = new Map<string, string[]>()
4011
4115
  const commandLoaderEntries: CommandLoaderGenerationEntry[] = []
4012
4116
  let hasRouteComponents = false
4117
+ let hasAbandonHookWorkers = false
4013
4118
  const seenBackendRoutePatterns = new Map<string, string>()
4014
4119
 
4015
4120
  for (const discovered of discovery.modules) {
@@ -4171,7 +4276,9 @@ async function generateModuleRegistryAppFromDiscovery(options: ModuleRegistryRen
4171
4276
 
4172
4277
  subscribers.push(...processSubscribersAst(await discovered.getSubscribers()))
4173
4278
 
4174
- workers.push(...processWorkersAst(await discovered.getWorkers()))
4279
+ const discoveredWorkersForModule = await discovered.getWorkers()
4280
+ if (workersDeclareAbandonHook(discoveredWorkersForModule)) hasAbandonHookWorkers = true
4281
+ workers.push(...processWorkersAst(discoveredWorkersForModule))
4175
4282
 
4176
4283
  {
4177
4284
  dashboardWidgetsValue = buildModuleDashboardWidgetsValue(discovered.dashboardWidgetEntries)
@@ -4295,18 +4402,21 @@ async function generateModuleRegistryAppFromDiscovery(options: ModuleRegistryRen
4295
4402
  imports,
4296
4403
  moduleEntries: moduleDecls,
4297
4404
  includeCreateElementImport: hasRouteComponents,
4405
+ includeAbandonHookImport: hasAbandonHookWorkers,
4298
4406
  })
4299
4407
  const bootstrapOutput = renderAstModuleRegistryFile({
4300
4408
  fileName: 'modules.bootstrap.generated.ts',
4301
4409
  generator: 'registry (bootstrap version)',
4302
4410
  imports: bootstrapImports,
4303
4411
  moduleEntries: bootstrapModuleDecls,
4412
+ includeAbandonHookImport: hasAbandonHookWorkers,
4304
4413
  })
4305
4414
  const i18nOutput = renderAstModuleRegistryFile({
4306
4415
  fileName: 'modules.i18n.generated.ts',
4307
4416
  generator: 'registry (i18n version)',
4308
4417
  imports: i18nImports,
4309
4418
  moduleEntries: i18nModuleDecls,
4419
+ includeAbandonHookImport: hasAbandonHookWorkers,
4310
4420
  })
4311
4421
  const legacyOutput = renderAstLegacyAliasFile({
4312
4422
  fileName: 'bootstrap-modules.generated.ts',
@@ -4397,6 +4507,7 @@ async function generateModuleRegistryAppFromDiscovery(options: ModuleRegistryRen
4397
4507
  * Excludes: frontend routes, backend routes, API handlers, injection widgets
4398
4508
  */
4399
4509
  async function generateModuleRegistryCliFromDiscovery(options: ModuleRegistryRenderOptions): Promise<GeneratorResult> {
4510
+ let hasAbandonHookWorkers = false
4400
4511
  const { resolver, quiet = false } = options
4401
4512
  const { discovery } = options
4402
4513
  const result = createGeneratorResult()
@@ -4548,6 +4659,7 @@ async function generateModuleRegistryCliFromDiscovery(options: ModuleRegistryRen
4548
4659
  // Workers
4549
4660
  const discoveredWorkers = await discovered.getWorkers()
4550
4661
  const workerGenerationEntries = collectWorkerGenerationEntries(discoveredWorkers, modId)
4662
+ if (workersDeclareAbandonHook(discoveredWorkers)) hasAbandonHookWorkers = true
4551
4663
  workers.push(...processWorkersAst(discoveredWorkers))
4552
4664
  devSupervisorWorkers.push(...workerGenerationEntries.map(({ importPath: _importPath, ...worker }) => worker))
4553
4665
 
@@ -4671,6 +4783,7 @@ async function generateModuleRegistryCliFromDiscovery(options: ModuleRegistryRen
4671
4783
  generator: 'registry (CLI version)',
4672
4784
  imports,
4673
4785
  moduleEntries: moduleDecls,
4786
+ includeAbandonHookImport: hasAbandonHookWorkers,
4674
4787
  })
4675
4788
  const legacyOutput = renderAstLegacyAliasFile({
4676
4789
  fileName: 'cli-modules.generated.ts',
package/src/mercato.ts CHANGED
@@ -78,6 +78,29 @@ function getRegisteredCliWorkers(modules: Module[] = getCliModules()): ModuleWor
78
78
  return allWorkers
79
79
  }
80
80
 
81
+ /**
82
+ * Picks the abandoned-job callback for a queue from its workers.
83
+ *
84
+ * The numeric queue options merge across a queue's workers with `Math.max`; two callbacks cannot
85
+ * merge, so the first declared one wins. Silence there would make a genuine wiring mistake — two
86
+ * handlers on one queue each expecting to report its abandoned jobs — look like it works while one of
87
+ * them never runs.
88
+ */
89
+ export function resolveQueueAbandonHook(
90
+ queueName: string,
91
+ queueWorkers: Array<Pick<ModuleWorker, 'id' | 'onJobAbandoned'>>,
92
+ warn: (message: string) => void = console.warn,
93
+ ): ModuleWorker['onJobAbandoned'] {
94
+ const declaring = queueWorkers.filter((worker) => worker.onJobAbandoned)
95
+ if (declaring.length > 1) {
96
+ warn(
97
+ `[worker] Queue "${queueName}" has ${declaring.length} workers declaring onJobAbandoned `
98
+ + `(${declaring.map((worker) => worker.id).join(', ')}); using the first and ignoring the rest.`,
99
+ )
100
+ }
101
+ return declaring[0]?.onJobAbandoned
102
+ }
103
+
81
104
  function shouldEmbedLocalSchedulerInSharedWorker(env: NodeJS.ProcessEnv = process.env): boolean {
82
105
  return parseBooleanToken(env.OM_DEV_EMBED_SCHEDULER_IN_SHARED_WORKER) === true
83
106
  }
@@ -1678,6 +1701,7 @@ export async function run(argv = process.argv) {
1678
1701
  Math.max(...queueWorkers.map((w) => w.concurrency), 1)
1679
1702
  const maxStalledCount = Math.max(...queueWorkers.map((w) => w.maxStalledCount ?? 1), 1)
1680
1703
  const lockDuration = Math.max(...queueWorkers.map((w) => w.lockDuration ?? 0), 0) || undefined
1704
+ const onJobAbandoned = resolveQueueAbandonHook(queue, queueWorkers)
1681
1705
 
1682
1706
  console.log(`[worker] Starting "${queue}" with ${queueWorkers.length} handler(s), concurrency: ${concurrency}`)
1683
1707
 
@@ -1688,6 +1712,7 @@ export async function run(argv = process.argv) {
1688
1712
  concurrency,
1689
1713
  lockDuration,
1690
1714
  maxStalledCount,
1715
+ onJobAbandoned,
1691
1716
  background: true,
1692
1717
  handler: createPerJobWorkerHandler(queueWorkers, createRequestContainer),
1693
1718
  })
@@ -1733,6 +1758,7 @@ export async function run(argv = process.argv) {
1733
1758
  const concurrency = budgetPlan.entries[0]?.effective ?? requested
1734
1759
  const maxStalledCount = Math.max(...queueWorkers.map((w) => w.maxStalledCount ?? 1), 1)
1735
1760
  const lockDuration = Math.max(...queueWorkers.map((w) => w.lockDuration ?? 0), 0) || undefined
1761
+ const onJobAbandoned = resolveQueueAbandonHook(queueName!, queueWorkers)
1736
1762
 
1737
1763
  console.log(`[worker] Found ${queueWorkers.length} worker(s) for queue "${queueName}"`)
1738
1764
 
@@ -1743,6 +1769,7 @@ export async function run(argv = process.argv) {
1743
1769
  concurrency,
1744
1770
  lockDuration,
1745
1771
  maxStalledCount,
1772
+ onJobAbandoned,
1746
1773
  handler: createPerJobWorkerHandler(queueWorkers, createRequestContainer),
1747
1774
  })
1748
1775
  } else {