@open-mercato/cli 0.6.7-develop.6553.1.2ec7246e04 → 0.6.7-develop.6565.1.1026d85fff

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 (87) hide show
  1. package/.turbo/turbo-build.log +3 -3
  2. package/AGENTS.md +1 -0
  3. package/dist/agentic/guides/module-facts.json +53 -53
  4. package/dist/agentic/guides/modules/ai_assistant.md +1 -1
  5. package/dist/agentic/guides/modules/api_docs.md +1 -1
  6. package/dist/agentic/guides/modules/api_keys.md +1 -1
  7. package/dist/agentic/guides/modules/attachments.md +1 -1
  8. package/dist/agentic/guides/modules/audit_logs.md +1 -1
  9. package/dist/agentic/guides/modules/auth.md +1 -1
  10. package/dist/agentic/guides/modules/business_rules.md +1 -1
  11. package/dist/agentic/guides/modules/catalog.md +1 -1
  12. package/dist/agentic/guides/modules/channel_gmail.md +1 -1
  13. package/dist/agentic/guides/modules/channel_imap.md +1 -1
  14. package/dist/agentic/guides/modules/checkout.md +1 -1
  15. package/dist/agentic/guides/modules/communication_channels.md +1 -1
  16. package/dist/agentic/guides/modules/configs.md +1 -1
  17. package/dist/agentic/guides/modules/content.md +1 -1
  18. package/dist/agentic/guides/modules/currencies.md +1 -1
  19. package/dist/agentic/guides/modules/customer_accounts.md +1 -1
  20. package/dist/agentic/guides/modules/customers.md +1 -1
  21. package/dist/agentic/guides/modules/dashboards.md +1 -1
  22. package/dist/agentic/guides/modules/data_sync.md +1 -1
  23. package/dist/agentic/guides/modules/dictionaries.md +1 -1
  24. package/dist/agentic/guides/modules/directory.md +1 -1
  25. package/dist/agentic/guides/modules/entities.md +1 -1
  26. package/dist/agentic/guides/modules/events.md +1 -1
  27. package/dist/agentic/guides/modules/feature_toggles.md +1 -1
  28. package/dist/agentic/guides/modules/gateway_stripe.md +1 -1
  29. package/dist/agentic/guides/modules/generators.md +1 -1
  30. package/dist/agentic/guides/modules/inbox_ops.md +1 -1
  31. package/dist/agentic/guides/modules/integrations.md +1 -1
  32. package/dist/agentic/guides/modules/messages.md +1 -1
  33. package/dist/agentic/guides/modules/notifications.md +1 -1
  34. package/dist/agentic/guides/modules/onboarding.md +1 -1
  35. package/dist/agentic/guides/modules/payment_gateways.md +1 -1
  36. package/dist/agentic/guides/modules/perspectives.md +1 -1
  37. package/dist/agentic/guides/modules/planner.md +1 -1
  38. package/dist/agentic/guides/modules/portal.md +1 -1
  39. package/dist/agentic/guides/modules/progress.md +1 -1
  40. package/dist/agentic/guides/modules/query_index.md +1 -1
  41. package/dist/agentic/guides/modules/record_locks.md +1 -1
  42. package/dist/agentic/guides/modules/resources.md +1 -1
  43. package/dist/agentic/guides/modules/sales.md +1 -1
  44. package/dist/agentic/guides/modules/scheduler.md +1 -1
  45. package/dist/agentic/guides/modules/search.md +1 -1
  46. package/dist/agentic/guides/modules/security.md +1 -1
  47. package/dist/agentic/guides/modules/shipping_carriers.md +1 -1
  48. package/dist/agentic/guides/modules/sso.md +1 -1
  49. package/dist/agentic/guides/modules/staff.md +1 -1
  50. package/dist/agentic/guides/modules/storage_s3.md +1 -1
  51. package/dist/agentic/guides/modules/sync_akeneo.md +1 -1
  52. package/dist/agentic/guides/modules/sync_excel.md +1 -1
  53. package/dist/agentic/guides/modules/system_status_overlays.md +1 -1
  54. package/dist/agentic/guides/modules/translations.md +1 -1
  55. package/dist/agentic/guides/modules/webhooks.md +1 -1
  56. package/dist/agentic/guides/modules/workflows.md +1 -1
  57. package/dist/agentic/shared/AGENTS.md.template +1 -1
  58. package/dist/agentic/shared/ai/skills/tiers.schema.json +15 -0
  59. package/dist/agentic/shared/scripts/install-skills.sh +244 -37
  60. package/dist/lib/__integration__/TC-INT-008.spec.js +9 -11
  61. package/dist/lib/__integration__/TC-INT-008.spec.js.map +2 -2
  62. package/dist/lib/agentic-setup.js +14 -2
  63. package/dist/lib/agentic-setup.js.map +2 -2
  64. package/dist/lib/generate-watch-events.js +106 -0
  65. package/dist/lib/generate-watch-events.js.map +7 -0
  66. package/dist/lib/generators/entity-ids.js +20 -13
  67. package/dist/lib/generators/entity-ids.js.map +2 -2
  68. package/dist/lib/generators/openapi.js +114 -12
  69. package/dist/lib/generators/openapi.js.map +2 -2
  70. package/dist/lib/in-process-generate-watcher.js +13 -2
  71. package/dist/lib/in-process-generate-watcher.js.map +2 -2
  72. package/dist/mercato.js +68 -23
  73. package/dist/mercato.js.map +2 -2
  74. package/package.json +5 -5
  75. package/src/__tests__/mercato.test.ts +53 -2
  76. package/src/lib/__integration__/TC-INT-008.spec.ts +15 -15
  77. package/src/lib/__tests__/generate-watch-events.test.ts +112 -0
  78. package/src/lib/__tests__/generate-watch-structure.test.ts +20 -0
  79. package/src/lib/__tests__/in-process-generate-watcher.test.ts +193 -2
  80. package/src/lib/agentic-setup.ts +23 -4
  81. package/src/lib/generate-watch-events.ts +125 -0
  82. package/src/lib/generators/__tests__/entity-ids.test.ts +98 -1
  83. package/src/lib/generators/__tests__/openapi.test.ts +174 -0
  84. package/src/lib/generators/entity-ids.ts +33 -13
  85. package/src/lib/generators/openapi.ts +167 -14
  86. package/src/lib/in-process-generate-watcher.ts +35 -6
  87. package/src/mercato.ts +75 -27
@@ -8,10 +8,11 @@
8
8
 
9
9
  import * as fs from 'node:fs'
10
10
  import * as path from 'node:path'
11
- import type { PackageResolver } from '../resolver'
11
+ import type { ModuleEntry, PackageResolver } from '../resolver'
12
12
  import { isModuleRouteFile } from './scanner'
13
13
  import {
14
14
  calculateChecksum,
15
+ calculateStructureChecksum,
15
16
  readChecksumRecord,
16
17
  writeChecksumRecord,
17
18
  logGenerationResult,
@@ -32,6 +33,111 @@ interface ApiRouteInfo {
32
33
  openApiPath: string
33
34
  }
34
35
 
36
+ interface OpenApiInputManifest {
37
+ version: 1
38
+ inputPaths: string[]
39
+ }
40
+
41
+ interface OpenApiInputContext {
42
+ enabled: ModuleEntry[]
43
+ apiRoots: string[]
44
+ moduleIdentity: Array<{
45
+ id: string
46
+ from: string
47
+ appBase: string
48
+ pkgBase: string
49
+ }>
50
+ }
51
+
52
+ interface OpenApiBundleResult {
53
+ doc: Record<string, any> | null
54
+ inputPaths: string[]
55
+ }
56
+
57
+ const OPENAPI_INPUT_MANIFEST_VERSION = 1
58
+
59
+ function createOpenApiInputContext(resolver: PackageResolver): OpenApiInputContext {
60
+ const enabled = resolver.loadEnabledModules()
61
+ const apiRoots: string[] = []
62
+ const moduleIdentity = enabled.map((entry) => {
63
+ const roots = resolver.getModulePaths(entry)
64
+ apiRoots.push(path.join(roots.appBase, 'api'), path.join(roots.pkgBase, 'api'))
65
+ return {
66
+ id: entry.id,
67
+ from: entry.from ?? '@open-mercato/core',
68
+ appBase: path.resolve(roots.appBase),
69
+ pkgBase: path.resolve(roots.pkgBase),
70
+ }
71
+ })
72
+ return { enabled, apiRoots, moduleIdentity }
73
+ }
74
+
75
+ function readOpenApiInputManifest(filePath: string): OpenApiInputManifest | null {
76
+ try {
77
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Partial<OpenApiInputManifest>
78
+ if (
79
+ parsed.version !== OPENAPI_INPUT_MANIFEST_VERSION
80
+ || !Array.isArray(parsed.inputPaths)
81
+ || parsed.inputPaths.some((entry) => typeof entry !== 'string')
82
+ ) {
83
+ return null
84
+ }
85
+ return {
86
+ version: OPENAPI_INPUT_MANIFEST_VERSION,
87
+ inputPaths: Array.from(new Set(parsed.inputPaths.map((entry) => path.resolve(entry))))
88
+ .sort((a, b) => a.localeCompare(b)),
89
+ }
90
+ } catch {
91
+ return null
92
+ }
93
+ }
94
+
95
+ function trackedPathRecord(filePath: string): string {
96
+ try {
97
+ const stat = fs.statSync(filePath)
98
+ if (stat.isFile()) {
99
+ return `${path.resolve(filePath)}:${calculateChecksum(fs.readFileSync(filePath, 'utf8'))}`
100
+ }
101
+ return `${path.resolve(filePath)}:${stat.size}:${stat.mtimeMs}`
102
+ } catch {
103
+ return `missing:${path.resolve(filePath)}`
104
+ }
105
+ }
106
+
107
+ function calculateOpenApiInputFingerprint(
108
+ resolver: PackageResolver,
109
+ context: OpenApiInputContext,
110
+ manifest: OpenApiInputManifest,
111
+ ): string {
112
+ const rootDir = resolver.getRootDir()
113
+ const appDir = resolver.getAppDir()
114
+ const configPaths = [
115
+ resolver.getModulesConfigPath(),
116
+ path.join(rootDir, 'package.json'),
117
+ path.join(rootDir, 'yarn.lock'),
118
+ path.join(rootDir, 'package-lock.json'),
119
+ path.join(rootDir, 'pnpm-lock.yaml'),
120
+ path.join(rootDir, 'tsconfig.json'),
121
+ path.join(rootDir, 'tsconfig.base.json'),
122
+ path.join(appDir, 'package.json'),
123
+ path.join(appDir, 'tsconfig.json'),
124
+ ]
125
+ const trackedRecords = [...manifest.inputPaths, ...configPaths]
126
+ .map(trackedPathRecord)
127
+ .sort((a, b) => a.localeCompare(b))
128
+ return calculateChecksum(JSON.stringify({
129
+ version: OPENAPI_INPUT_MANIFEST_VERSION,
130
+ modules: context.moduleIdentity,
131
+ routeStructure: calculateStructureChecksum(context.apiRoots),
132
+ trackedRecords,
133
+ environment: {
134
+ node: process.version,
135
+ nodeEnv: process.env.NODE_ENV ?? '',
136
+ publicAppUrl: process.env.NEXT_PUBLIC_APP_URL ?? '',
137
+ },
138
+ }))
139
+ }
140
+
35
141
  function resolveExistingPath(paths: string[]): string | null {
36
142
  for (const candidate of paths) {
37
143
  if (fs.existsSync(candidate)) return candidate
@@ -42,9 +148,11 @@ function resolveExistingPath(paths: string[]): string | null {
42
148
  /**
43
149
  * Find all API route files and extract their OpenAPI specs.
44
150
  */
45
- async function findApiRoutes(resolver: PackageResolver): Promise<ApiRouteInfo[]> {
151
+ async function findApiRoutes(
152
+ resolver: PackageResolver,
153
+ enabled: ModuleEntry[],
154
+ ): Promise<ApiRouteInfo[]> {
46
155
  const routes: ApiRouteInfo[] = []
47
- const enabled = resolver.loadEnabledModules()
48
156
 
49
157
  for (const entry of enabled) {
50
158
  const modId = entry.id
@@ -198,13 +306,13 @@ async function generateOpenApiViaBundle(
198
306
  routes: ApiRouteInfo[],
199
307
  resolver: PackageResolver,
200
308
  quiet: boolean
201
- ): Promise<Record<string, any> | null> {
309
+ ): Promise<OpenApiBundleResult> {
202
310
  let esbuild: typeof import('esbuild')
203
311
  try {
204
312
  esbuild = await import('esbuild')
205
313
  } catch {
206
314
  if (!quiet) console.log('[OpenAPI] esbuild not available, skipping bundle approach')
207
- return null
315
+ return { doc: null, inputPaths: [] }
208
316
  }
209
317
 
210
318
  const { execFileSync } = await import('node:child_process')
@@ -225,13 +333,14 @@ async function generateOpenApiViaBundle(
225
333
  if (!quiet) {
226
334
  console.log(`[OpenAPI] Generator source not found at ${generatorPath}, skipping bundle approach`)
227
335
  }
228
- return null
336
+ return { doc: null, inputPaths: [] }
229
337
  }
230
338
 
231
339
  const cacheDir = path.join(rootDir, 'node_modules', '.cache')
232
340
  if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true })
233
341
 
234
342
  const bundlePath = path.join(cacheDir, '_openapi-bundle.mjs')
343
+ let inputPaths: string[] = []
235
344
 
236
345
  // Build the entry script that imports all routes and calls buildOpenApiDocument
237
346
  const importLines: string[] = [
@@ -418,7 +527,7 @@ process.stdout.write(JSON.stringify(deepClone(doc), (_, v) =>
418
527
  }
419
528
 
420
529
  try {
421
- await esbuild.build({
530
+ const buildResult = await esbuild.build({
422
531
  stdin: {
423
532
  contents: entryScript,
424
533
  resolveDir: appDir,
@@ -426,6 +535,8 @@ process.stdout.write(JSON.stringify(deepClone(doc), (_, v) =>
426
535
  loader: 'ts',
427
536
  },
428
537
  bundle: true,
538
+ metafile: true,
539
+ absWorkingDir: rootDir,
429
540
  format: 'esm',
430
541
  platform: 'node',
431
542
  target: 'node18',
@@ -437,6 +548,12 @@ process.stdout.write(JSON.stringify(deepClone(doc), (_, v) =>
437
548
  plugins: [stubNextPlugin, resolveProjectImportsPlugin, externalNonWorkspacePlugin],
438
549
  })
439
550
 
551
+ inputPaths = Object.keys(buildResult.metafile?.inputs ?? {})
552
+ .filter((inputPath) => !inputPath.startsWith('<'))
553
+ .map((inputPath) => path.resolve(rootDir, inputPath))
554
+ .filter((inputPath) => fs.existsSync(inputPath))
555
+ .sort((a, b) => a.localeCompare(b))
556
+
440
557
  const stdout = execFileSync(process.execPath, [bundlePath], {
441
558
  timeout: 60_000,
442
559
  maxBuffer: 20 * 1024 * 1024,
@@ -459,7 +576,7 @@ process.stdout.write(JSON.stringify(deepClone(doc), (_, v) =>
459
576
  console.log(`[OpenAPI] Bundle approach: ${pathCount} paths, ${withBody} with requestBody schemas`)
460
577
  }
461
578
 
462
- return doc
579
+ return { doc, inputPaths }
463
580
  } catch (err) {
464
581
  const errMsg = err instanceof Error ? err.message : String(err)
465
582
  const stderr = (err as any)?.stderr
@@ -483,7 +600,7 @@ process.stdout.write(JSON.stringify(deepClone(doc), (_, v) =>
483
600
  }
484
601
  }
485
602
  }
486
- return null
603
+ return { doc: null, inputPaths }
487
604
  } finally {
488
605
  // Shut down esbuild's persistent Go service so it does not deadlock at
489
606
  // process exit when a plugin request is still in flight.
@@ -552,14 +669,33 @@ export async function generateOpenApi(options: GenerateOpenApiOptions): Promise<
552
669
  const outputDir = resolver.getOutputDir()
553
670
  const outFile = path.join(outputDir, 'openapi.generated.json')
554
671
  const checksumFile = path.join(outputDir, 'openapi.generated.checksum')
672
+ const inputManifestFile = path.join(outputDir, 'openapi.generated.inputs.json')
555
673
 
556
674
  // Ensure output directory exists
557
675
  if (!fs.existsSync(outputDir)) {
558
676
  fs.mkdirSync(outputDir, { recursive: true })
559
677
  }
560
678
 
679
+ const inputContext = createOpenApiInputContext(resolver)
680
+ const existingChecksums = readChecksumRecord(checksumFile)
681
+ const existingManifest = readOpenApiInputManifest(inputManifestFile)
682
+
683
+ if (
684
+ existingChecksums?.structure
685
+ && existingManifest
686
+ && fs.existsSync(outFile)
687
+ && calculateChecksum(fs.readFileSync(outFile, 'utf8')) === existingChecksums.content
688
+ && calculateOpenApiInputFingerprint(resolver, inputContext, existingManifest) === existingChecksums.structure
689
+ ) {
690
+ result.filesUnchanged.push(outFile)
691
+ if (!quiet) {
692
+ console.log(`[OpenAPI] Skipped (inputs unchanged): ${outFile}`)
693
+ }
694
+ return result
695
+ }
696
+
561
697
  // Find all API routes
562
- const routes = await findApiRoutes(resolver)
698
+ const routes = await findApiRoutes(resolver, inputContext.enabled)
563
699
 
564
700
  if (!quiet) {
565
701
  console.log(`[OpenAPI] Found ${routes.length} API route files`)
@@ -567,7 +703,8 @@ export async function generateOpenApi(options: GenerateOpenApiOptions): Promise<
567
703
 
568
704
  // Determine project root (cli package is at packages/cli/src/lib/generators/)
569
705
  // Try esbuild bundle approach first — produces full requestBody/response schemas
570
- let doc: Record<string, any> | null = await generateOpenApiViaBundle(routes, resolver, quiet)
706
+ const bundleResult = await generateOpenApiViaBundle(routes, resolver, quiet)
707
+ let doc: Record<string, any> | null = bundleResult.doc
571
708
 
572
709
  // Fallback to static regex approach (extracts operationId/summary/tags but no schemas)
573
710
  if (!doc) {
@@ -601,10 +738,26 @@ export async function generateOpenApi(options: GenerateOpenApiOptions): Promise<
601
738
 
602
739
  const output = JSON.stringify(doc, null, 2)
603
740
  const checksum = calculateChecksum(output)
741
+ let structureChecksum = ''
742
+
743
+ if (bundleResult.doc && bundleResult.inputPaths.length > 0) {
744
+ const manifest: OpenApiInputManifest = {
745
+ version: OPENAPI_INPUT_MANIFEST_VERSION,
746
+ inputPaths: Array.from(new Set(bundleResult.inputPaths)).sort((a, b) => a.localeCompare(b)),
747
+ }
748
+ fs.writeFileSync(inputManifestFile, `${JSON.stringify(manifest, null, 2)}\n`)
749
+ structureChecksum = calculateOpenApiInputFingerprint(resolver, inputContext, manifest)
750
+ } else {
751
+ try { fs.unlinkSync(inputManifestFile) } catch {}
752
+ }
604
753
 
605
754
  // Check if unchanged
606
- const existingChecksums = readChecksumRecord(checksumFile)
607
- if (existingChecksums && existingChecksums.content === checksum && fs.existsSync(outFile)) {
755
+ const existingOutputMatches = fs.existsSync(outFile)
756
+ && calculateChecksum(fs.readFileSync(outFile, 'utf8')) === checksum
757
+ if (existingChecksums && existingChecksums.content === checksum && existingOutputMatches) {
758
+ if (existingChecksums.structure !== structureChecksum) {
759
+ writeChecksumRecord(checksumFile, { content: checksum, structure: structureChecksum })
760
+ }
608
761
  result.filesUnchanged.push(outFile)
609
762
  if (!quiet) {
610
763
  console.log(`[OpenAPI] Skipped (unchanged): ${outFile}`)
@@ -614,7 +767,7 @@ export async function generateOpenApi(options: GenerateOpenApiOptions): Promise<
614
767
 
615
768
  // Write the file
616
769
  fs.writeFileSync(outFile, output)
617
- writeChecksumRecord(checksumFile, { content: checksum, structure: '' })
770
+ writeChecksumRecord(checksumFile, { content: checksum, structure: structureChecksum })
618
771
 
619
772
  result.filesWritten.push(outFile)
620
773
 
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * In-process generate watcher.
3
3
  *
4
- * Polls a structural-fingerprint function on a configurable interval and
5
- * invokes the supplied generator callback whenever the fingerprint changes.
4
+ * Coalesces filesystem events on a configurable interval, then verifies them
5
+ * with the existing structural fingerprint before invoking the generator.
6
+ * If filesystem watching is unavailable, it falls back to fingerprint polling.
6
7
  * The polling loop is identical to the legacy standalone watcher; the only
7
8
  * difference is that it now runs inside whatever process calls it
8
9
  * (typically `mercato server dev`), so the dev runtime no longer needs a
9
10
  * sidecar `mercato generate watch` Node process.
10
11
  *
11
- * Contract preserved 1:1 with the prior standalone watcher:
12
- * - Default poll interval 1000 ms (minimum 250 ms).
12
+ * Contract preserved from the prior standalone watcher:
13
+ * - Default debounce/fallback poll interval 1000 ms (minimum 250 ms).
13
14
  * - One initial generator run unless `skipInitial` is true.
14
15
  * - Concurrent regeneration requests are coalesced (running + pending).
15
16
  * - Generator errors are logged but never crash the watcher.
@@ -18,12 +19,25 @@
18
19
 
19
20
  export type GenerateWatcherLogger = Pick<Console, 'log' | 'error'>
20
21
 
22
+ export type GenerateWatcherChangeSignal = {
23
+ /** Monotonic event generation. Changes indicate that a checksum may be stale. */
24
+ currentVersion(): number
25
+ /** Refresh filesystem subscriptions after module configuration changes. */
26
+ refresh(): Promise<void> | void
27
+ /** When true, preserve the legacy full-checksum-on-every-poll behavior. */
28
+ usesPollingFallback(): boolean
29
+ /** Close filesystem subscriptions. Idempotent. */
30
+ close(): Promise<void> | void
31
+ }
32
+
21
33
  export type GenerateWatcherOptions = {
22
34
  /**
23
35
  * Function that returns the current structural fingerprint of the module
24
36
  * tree. The watcher re-runs `runGenerators` whenever this value changes.
25
37
  */
26
38
  computeStructureChecksum: () => Promise<string> | string
39
+ /** Optional event signal that avoids full checksum work during idle polls. */
40
+ changeSignal?: GenerateWatcherChangeSignal
27
41
  /**
28
42
  * Function that performs the actual regeneration work. Called once on
29
43
  * startup (unless `skipInitial`) and again whenever the checksum changes.
@@ -69,13 +83,14 @@ export function startInProcessGenerateWatcher(
69
83
  const quiet = options.quiet === true
70
84
  const pollMs = resolvePollMs(options.pollMs)
71
85
  const skipInitial = options.skipInitial === true
72
- const { computeStructureChecksum, runGenerators } = options
86
+ const { changeSignal, computeStructureChecksum, runGenerators } = options
73
87
 
74
88
  let stopping = false
75
89
  let pollTimer: ReturnType<typeof setTimeout> | null = null
76
90
  let running = false
77
91
  let pendingReason: string | null = null
78
92
  let previousChecksum = ''
93
+ let observedChangeVersion = -1
79
94
  let doneResolve: (() => void) | null = null
80
95
  const done = new Promise<void>((resolve) => {
81
96
  doneResolve = resolve
@@ -114,7 +129,17 @@ export function startInProcessGenerateWatcher(
114
129
  void (async () => {
115
130
  if (stopping) return
116
131
  try {
132
+ const changeVersion = changeSignal?.currentVersion() ?? 0
133
+ if (
134
+ changeSignal
135
+ && !changeSignal.usesPollingFallback()
136
+ && changeVersion === observedChangeVersion
137
+ ) {
138
+ return
139
+ }
117
140
  const nextChecksum = await computeStructureChecksum()
141
+ observedChangeVersion = changeVersion
142
+ await changeSignal?.refresh()
118
143
  if (nextChecksum !== previousChecksum) {
119
144
  previousChecksum = nextChecksum
120
145
  await runOnce('structure change')
@@ -132,15 +157,18 @@ export function startInProcessGenerateWatcher(
132
157
 
133
158
  void (async () => {
134
159
  try {
160
+ await changeSignal?.refresh()
161
+ const initialChangeVersion = changeSignal?.currentVersion() ?? 0
135
162
  if (!skipInitial) {
136
163
  await runOnce('initial')
137
164
  }
138
165
  previousChecksum = await computeStructureChecksum()
166
+ observedChangeVersion = initialChangeVersion
139
167
  if (!quiet) {
140
168
  if (skipInitial) {
141
169
  logger.log('[generate:watch] Skipping initial regeneration and watching the current generated state.')
142
170
  }
143
- logger.log(`[generate:watch] Watching structural module files every ${pollMs}ms (in-process).`)
171
+ logger.log(`[generate:watch] Watching structural module files with a ${pollMs}ms debounce (in-process).`)
144
172
  }
145
173
  scheduleNext()
146
174
  } catch (error) {
@@ -162,6 +190,7 @@ export function startInProcessGenerateWatcher(
162
190
  clearTimeout(pollTimer)
163
191
  pollTimer = null
164
192
  }
193
+ await changeSignal?.close()
165
194
  if (doneResolve) {
166
195
  doneResolve()
167
196
  doneResolve = null
package/src/mercato.ts CHANGED
@@ -750,7 +750,7 @@ async function runPostGenerateStructuralCachePurge(quiet: boolean): Promise<void
750
750
  * so the watcher embedded in the server lifecycle can reuse the same closure
751
751
  * without re-importing the closure-scoped version inside `buildBaseModules`.
752
752
  */
753
- async function runGeneratorSuite(quiet: boolean): Promise<void> {
753
+ async function runGeneratorSuite(quiet: boolean): Promise<boolean> {
754
754
  const { createResolver } = await import('./lib/resolver')
755
755
  const {
756
756
  generateEntityIds,
@@ -763,35 +763,84 @@ async function runGeneratorSuite(quiet: boolean): Promise<void> {
763
763
  generateOpenApi,
764
764
  } = await import('./lib/generators')
765
765
  const resolver = createResolver()
766
- await generateEntityIds({ resolver, quiet })
767
- await generateModuleRegistry({ resolver, quiet })
768
- await generateModuleRegistryApp({ resolver, quiet })
769
- await generateModuleRegistryCli({ resolver, quiet })
770
- await generateModuleEntities({ resolver, quiet })
771
- await generateModuleDi({ resolver, quiet })
772
- await generateModulePackageSources({ resolver, quiet })
773
- await generateOpenApi({ resolver, quiet })
766
+ const results = [
767
+ await generateEntityIds({ resolver, quiet }),
768
+ await generateModuleRegistry({ resolver, quiet }),
769
+ await generateModuleRegistryApp({ resolver, quiet }),
770
+ await generateModuleRegistryCli({ resolver, quiet }),
771
+ await generateModuleEntities({ resolver, quiet }),
772
+ await generateModuleDi({ resolver, quiet }),
773
+ await generateModulePackageSources({ resolver, quiet }),
774
+ await generateOpenApi({ resolver, quiet }),
775
+ ]
776
+ return results.some((result) => (result?.filesWritten.length ?? 0) > 0)
777
+ }
778
+
779
+ async function runGeneratorSuiteWithStructuralInvalidation(quiet: boolean): Promise<void> {
780
+ const generatedFilesChanged = await runGeneratorSuite(quiet)
781
+ if (!generatedFilesChanged) {
782
+ if (!quiet) {
783
+ console.log('[generate] Generated outputs unchanged; skipping structural cache purge.')
784
+ }
785
+ return
786
+ }
787
+ await runPostGenerateStructuralCachePurge(quiet)
774
788
  }
775
789
 
776
790
  /**
777
- * Builds the structural-fingerprint function used by the in-process generate
778
- * watcher. Walks the same module roots the legacy `mercato generate watch`
779
- * CLI command tracked, so the polling semantics are byte-for-byte identical.
791
+ * Builds the event-gated structural fingerprint runtime used by generate
792
+ * watchers. Filesystem events mark the tree dirty; the existing full content
793
+ * checksum remains the final authority and the fallback when watching fails.
780
794
  */
781
- function createGenerateWatchChecksumFn(): () => Promise<string> {
782
- return async () => {
783
- const { createResolver } = await import('./lib/resolver')
784
- const { calculateGenerateWatchStructureChecksum } = await import('./lib/generate-watch-structure')
795
+ async function createGenerateWatchRuntime() {
796
+ const [
797
+ { createResolver },
798
+ { calculateGenerateWatchStructureChecksum },
799
+ { createGenerateWatchChangeSignal },
800
+ { resolveStandaloneSourceMirrorBase },
801
+ ] = await Promise.all([
802
+ import('./lib/resolver'),
803
+ import('./lib/generate-watch-structure'),
804
+ import('./lib/generate-watch-events'),
805
+ import('./lib/generators/scanner'),
806
+ ])
807
+
808
+ const collectWatchState = () => {
785
809
  const resolver = createResolver()
786
810
  const moduleRoots = []
787
811
  for (const entry of resolver.loadEnabledModules()) {
788
812
  const roots = resolver.getModulePaths(entry)
789
813
  moduleRoots.push({ appBase: roots.appBase, pkgBase: roots.pkgBase })
790
814
  }
791
- return calculateGenerateWatchStructureChecksum({
792
- modulesFile: path.join(resolver.getAppDir(), 'src', 'modules.ts'),
815
+ return {
816
+ modulesFile: resolver.getModulesConfigPath(),
793
817
  moduleRoots,
794
- })
818
+ }
819
+ }
820
+
821
+ return {
822
+ computeStructureChecksum: async () => {
823
+ return calculateGenerateWatchStructureChecksum(collectWatchState())
824
+ },
825
+ changeSignal: createGenerateWatchChangeSignal({
826
+ getWatchTargets: () => {
827
+ const state = collectWatchState()
828
+ const targets: Array<{ directory: string; recursive: boolean; fileName?: string }> = [{
829
+ directory: path.dirname(state.modulesFile),
830
+ recursive: false,
831
+ fileName: path.basename(state.modulesFile),
832
+ }]
833
+ for (const roots of state.moduleRoots) {
834
+ targets.push({ directory: path.dirname(roots.appBase), recursive: true })
835
+ targets.push({ directory: path.dirname(roots.pkgBase), recursive: true })
836
+ const sourceMirror = resolveStandaloneSourceMirrorBase(roots.pkgBase)
837
+ if (sourceMirror) {
838
+ targets.push({ directory: path.dirname(sourceMirror), recursive: true })
839
+ }
840
+ }
841
+ return targets
842
+ },
843
+ }),
795
844
  }
796
845
  }
797
846
 
@@ -1777,8 +1826,7 @@ export async function run(argv = process.argv) {
1777
1826
  const quiet = args.includes('--quiet') || args.includes('-q')
1778
1827
 
1779
1828
  console.log('Running all generators...')
1780
- await runGeneratorSuite(quiet)
1781
- await runPostGenerateStructuralCachePurge(quiet)
1829
+ await runGeneratorSuiteWithStructuralInvalidation(quiet)
1782
1830
  console.log('All generators completed.')
1783
1831
  },
1784
1832
  },
@@ -1791,14 +1839,14 @@ export async function run(argv = process.argv) {
1791
1839
  const parsedInterval = intervalArg ? Number.parseInt(intervalArg.split('=')[1] ?? '', 10) : NaN
1792
1840
  const intervalMs = Number.isFinite(parsedInterval) && parsedInterval >= 250 ? parsedInterval : 1000
1793
1841
 
1842
+ const generateWatchRuntime = await createGenerateWatchRuntime()
1794
1843
  const watcher = startInProcessGenerateWatcher({
1795
1844
  pollMs: intervalMs,
1796
1845
  skipInitial,
1797
1846
  quiet,
1798
- computeStructureChecksum: createGenerateWatchChecksumFn(),
1847
+ ...generateWatchRuntime,
1799
1848
  runGenerators: async () => {
1800
- await runGeneratorSuite(true)
1801
- await runPostGenerateStructuralCachePurge(true)
1849
+ await runGeneratorSuiteWithStructuralInvalidation(true)
1802
1850
  },
1803
1851
  })
1804
1852
 
@@ -2238,6 +2286,7 @@ export async function run(argv = process.argv) {
2238
2286
  // (measured against the legacy sidecar). Opt back into the
2239
2287
  // sidecar with `OM_DEV_GENERATE_WATCH_MODE=legacy` if needed.
2240
2288
  console.log('[server] In-process generate watcher enabled — structural changes will regenerate without a sidecar process.')
2289
+ const generateWatchRuntime = await createGenerateWatchRuntime()
2241
2290
  activeGenerateWatcher = startInProcessGenerateWatcher({
2242
2291
  // `--skip-initial` equivalent: `yarn dev` always runs an
2243
2292
  // initial `mercato generate` before reaching the server
@@ -2246,10 +2295,9 @@ export async function run(argv = process.argv) {
2246
2295
  // twice in a row.
2247
2296
  skipInitial: true,
2248
2297
  quiet: false,
2249
- computeStructureChecksum: createGenerateWatchChecksumFn(),
2298
+ ...generateWatchRuntime,
2250
2299
  runGenerators: async () => {
2251
- await runGeneratorSuite(true)
2252
- await runPostGenerateStructuralCachePurge(true)
2300
+ await runGeneratorSuiteWithStructuralInvalidation(true)
2253
2301
  },
2254
2302
  })
2255
2303
  } else {