create-mercato-app 0.6.3-develop.3894.1.352abf4240 → 0.6.3

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.
@@ -201,6 +201,7 @@ When the user asks to **create a new application** or a **new module**, do not i
201
201
  - Entity classes belong in `src/modules/<module>/data/entities.ts` and use decorators from `@mikro-orm/decorators/legacy`
202
202
  - Standalone apps expose `yarn mercato configs cache ...` because the template enables the `configs` module from `@open-mercato/core`
203
203
  - `yarn generate` automatically runs a best-effort structural cache purge (`yarn mercato configs cache structural --all-tenants`) after successful generation; if the cache command is unavailable, generation still succeeds
204
+ - Use `yarn dev:reset` only as the stale-Turbopack escape hatch; it clears `.mercato/next/dev` plus legacy `.next` caches.
204
205
  - Detail/read-model APIs that expose `customFields` MUST return bare field keys via `normalizeCustomFieldResponse()` (for example `{ priority: 3 }`). Keep `cf_` / `cf:` prefixes for request payloads, filters, and form field IDs only.
205
206
  - Sidebar icons MUST use `lucide-react` components — never inline SVG via `React.createElement`
206
207
  - `page.meta.ts` MUST include `pageGroup`, `pageGroupKey`, and `pageOrder` for sidebar grouping
@@ -201,6 +201,7 @@ When the user asks to **create a new application** or a **new module**, do not i
201
201
  - Entity classes belong in `src/modules/<module>/data/entities.ts` and use decorators from `@mikro-orm/decorators/legacy`
202
202
  - Standalone apps expose `yarn mercato configs cache ...` because the template enables the `configs` module from `@open-mercato/core`
203
203
  - `yarn generate` automatically runs a best-effort structural cache purge (`yarn mercato configs cache structural --all-tenants`) after successful generation; if the cache command is unavailable, generation still succeeds
204
+ - Use `yarn dev:reset` only as the stale-Turbopack escape hatch; it clears `.mercato/next/dev` plus legacy `.next` caches.
204
205
  - Detail/read-model APIs that expose `customFields` MUST return bare field keys via `normalizeCustomFieldResponse()` (for example `{ priority: 3 }`). Keep `cf_` / `cf:` prefixes for request payloads, filters, and form field IDs only.
205
206
  - Sidebar icons MUST use `lucide-react` components — never inline SVG via `React.createElement`
206
207
  - `page.meta.ts` MUST include `pageGroup`, `pageGroupKey`, and `pageOrder` for sidebar grouping
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-mercato-app",
3
- "version": "0.6.3-develop.3894.1.352abf4240",
3
+ "version": "0.6.3",
4
4
  "type": "module",
5
5
  "description": "Create a new Open Mercato application",
6
6
  "main": "./dist/index.js",
@@ -41,6 +41,5 @@
41
41
  "scaffolding",
42
42
  "cli"
43
43
  ],
44
- "license": "MIT",
45
- "stableVersion": "0.6.2"
44
+ "license": "MIT"
46
45
  }
@@ -99,7 +99,7 @@ yarn generate
99
99
  # Manually purge structural caches when needed (Redis nav:* + Turbopack barrel mtimes)
100
100
  yarn mercato configs cache structural --all-tenants
101
101
 
102
- # Escape hatch: clear .next/cache/turbopack when Turbopack still serves a stale chunk
102
+ # Escape hatch: clear .mercato/next/dev and legacy .next caches when Turbopack still serves a stale chunk
103
103
  yarn dev:reset
104
104
 
105
105
  # Database operations
@@ -650,7 +650,7 @@ Notes:
650
650
 
651
651
  The standalone template enables the `configs` module from `@open-mercato/core`, so `yarn mercato configs cache ...` is available here after installation. After structural changes such as enabling or disabling modules, adding or removing backend/frontend pages, or changing sidebar/navigation injections, run `yarn generate`. The generator now performs a best-effort structural cache purge automatically after successful generation; if the cache command is unavailable, generation still succeeds.
652
652
 
653
- The structural cache purge invalidates two layers: Redis `nav:*` cache keys and Turbopack's module-graph fingerprints (it bumps mtimes on every file in `.mercato/generated/` without changing content). When Turbopack still serves a stale compiled chunk after a structural change — typically because its own internal cache pinned a previous compile error — run `yarn dev:reset` to clear `.next/cache/turbopack` and restart `yarn dev`.
653
+ The structural cache purge invalidates two layers: Redis `nav:*` cache keys and Turbopack's module-graph fingerprints (it bumps mtimes on every file in `.mercato/generated/` without changing content). When Turbopack still serves a stale compiled chunk after a structural change — typically because its own internal cache pinned a previous compile error — run `yarn dev:reset` to clear `.mercato/next/dev` plus legacy `.next` caches and restart `yarn dev`.
654
654
 
655
655
  Detail/read-model APIs that expose `customFields` must return bare field keys via `normalizeCustomFieldResponse()` (for example `{ priority: 3 }`). Keep `cf_` / `cf:` prefixes for request payloads, filters, and form field IDs only.
656
656
 
@@ -1,11 +1,20 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
 
4
- // Greenfield must not inherit the prior run's compiler state. Wiping the
5
- // configured Next.js distDir (`.mercato/next`) plus the legacy `.next` location guarantees Turbopack rebuilds the route table
6
- // and middleware manifest from scratch on the next launch.
4
+ // Greenfield must not inherit stale route manifests, but wiping the whole
5
+ // configured Next.js distDir also discards Turbopack's reusable compiler cache
6
+ // and makes first /login warmup much slower. Remove manifests/locks that encode
7
+ // route shape while preserving `.mercato/next/dev/cache/turbopack`.
7
8
  export const GREENFIELD_PURGE_TARGETS = Object.freeze([
8
- Object.freeze(['.mercato', 'next']),
9
+ Object.freeze(['.mercato', 'next', 'dev', 'lock']),
10
+ Object.freeze(['.mercato', 'next', 'dev', 'build-manifest.json']),
11
+ Object.freeze(['.mercato', 'next', 'dev', 'fallback-build-manifest.json']),
12
+ Object.freeze(['.mercato', 'next', 'dev', 'prerender-manifest.json']),
13
+ Object.freeze(['.mercato', 'next', 'dev', 'routes-manifest.json']),
14
+ Object.freeze(['.mercato', 'next', 'dev', 'server', 'app-paths-manifest.json']),
15
+ Object.freeze(['.mercato', 'next', 'dev', 'server', 'middleware-build-manifest.js']),
16
+ Object.freeze(['.mercato', 'next', 'dev', 'server', 'middleware-manifest.json']),
17
+ Object.freeze(['.mercato', 'next', 'dev', 'server', 'pages-manifest.json']),
9
18
  Object.freeze(['.next']),
10
19
  ])
11
20
 
@@ -23,7 +32,7 @@ export function purgeAppBuildCaches({
23
32
  removed.push(segments.join('/'))
24
33
  }
25
34
  if (removed.length === 0) {
26
- logger.log('🧹 [dev:greenfield] no stale Next/Turbopack build directories to purge')
35
+ logger.log('🧹 [dev:greenfield] no stale Next/Turbopack manifest files to purge')
27
36
  } else {
28
37
  for (const relPath of removed) {
29
38
  logger.log(`🧹 [dev:greenfield] removed ${relPath}`)
@@ -6,6 +6,7 @@ import fs from 'node:fs'
6
6
  const here = path.dirname(fileURLToPath(import.meta.url))
7
7
  const appDir = path.resolve(here, '..')
8
8
  const targets = [
9
+ path.join(appDir, '.mercato', 'next', 'dev'),
9
10
  path.join(appDir, '.next', 'cache', 'turbopack'),
10
11
  path.join(appDir, '.next', 'cache', 'webpack'),
11
12
  ]
@@ -19,9 +20,9 @@ for (const target of targets) {
19
20
  }
20
21
 
21
22
  if (removed === 0) {
22
- console.log('🧹 [dev:reset] nothing to clean — .next/cache subdirectories already absent')
23
+ console.log('🧹 [dev:reset] nothing to clean — dev build caches already absent')
23
24
  }
24
25
 
25
26
  console.log('')
26
- console.log('✅ Turbopack/webpack cache cleared.')
27
- console.log(' Stop any running `yarn dev` and start it again to pick up fresh module output.')
27
+ console.log('✅ Next.js dev cache cleared.')
28
+ console.log(' Start `yarn dev` again to pick up fresh module output.')
@@ -50,6 +50,12 @@ function parseEnvBooleanToken(value) {
50
50
  return null
51
51
  }
52
52
 
53
+ function parsePositiveIntegerEnv(value) {
54
+ if (typeof value !== 'string') return null
55
+ const parsed = Number.parseInt(value.trim(), 10)
56
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null
57
+ }
58
+
53
59
  function resolveAutoSpawnEnabled(env, legacyName, aliasedName) {
54
60
  const legacy = parseEnvBooleanToken(env[legacyName])
55
61
  if (legacy !== null) return legacy
@@ -84,12 +90,20 @@ const verbose = !classic && (process.argv.includes('--verbose') || process.env.M
84
90
  const rawPassthrough = classic || verbose
85
91
  const interactiveLogToggle = !rawPassthrough && process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== 'true'
86
92
  const splashChildStateFile = process.env.OM_DEV_SPLASH_CHILD_STATE_FILE?.trim() || null
93
+ const warmupReadyFile = process.env.OM_DEV_WARMUP_READY_FILE?.trim()
94
+ || (splashChildStateFile ? `${splashChildStateFile}.warmup-ready` : null)
87
95
  const splashMode = process.env.OM_DEV_SPLASH_MODE?.trim() || 'dev'
88
96
  const setupSplashMode = splashMode === 'setup'
89
97
  const startupSplashPhase = setupSplashMode ? 'Project setup is in progress...' : 'Installation and first compilation is in progress...'
90
- const runtimeProgressTotal = setupSplashMode ? 5 : 4
91
- const runtimeProgressCurrent = setupSplashMode ? 4 : 0
92
- const runtimeReadyProgressCurrent = setupSplashMode ? 5 : 4
98
+ const configuredRuntimeProgressTotal = parsePositiveIntegerEnv(process.env.OM_DEV_SPLASH_STAGE_TOTAL)
99
+ const configuredRuntimeProgressCurrent = parsePositiveIntegerEnv(process.env.OM_DEV_SPLASH_STAGE_CURRENT)
100
+ const runtimeProgressTotal = configuredRuntimeProgressTotal ?? (setupSplashMode ? 5 : 4)
101
+ const runtimeProgressCurrent = configuredRuntimeProgressCurrent ?? (setupSplashMode ? 4 : 0)
102
+ const runtimeReadyProgressCurrent = Math.max(runtimeProgressCurrent, runtimeProgressTotal)
103
+ const runtimeWarmupProgressCurrent = Math.max(
104
+ runtimeProgressCurrent,
105
+ Math.min(runtimeReadyProgressCurrent, Math.max(0, runtimeProgressTotal - 1)),
106
+ )
93
107
  const children = new Set()
94
108
  let shuttingDown = false
95
109
  let logsVisible = false
@@ -108,6 +122,7 @@ const backgroundServiceModes = {
108
122
  workers: resolveAutoSpawnMode(process.env, 'AUTO_SPAWN_WORKERS', 'OM_AUTO_SPAWN_WORKERS', 'OM_AUTO_SPAWN_WORKERS_LAZY'),
109
123
  scheduler: resolveAutoSpawnMode(process.env, 'AUTO_SPAWN_SCHEDULER', 'OM_AUTO_SPAWN_SCHEDULER', 'OM_AUTO_SPAWN_SCHEDULER_LAZY'),
110
124
  }
125
+ const shutdownNoticeOwnedByParent = process.env.OM_DEV_SHUTDOWN_NOTICE_OWNER === 'parent'
111
126
  const splashState = {
112
127
  mode: splashMode,
113
128
  phase: startupSplashPhase,
@@ -161,11 +176,38 @@ const runtimeWarmupState = {
161
176
  failed: false,
162
177
  promise: null,
163
178
  retryTimer: null,
179
+ abortController: null,
180
+ generation: 0,
164
181
  retryAttempts: 0,
165
182
  tenantId: readNonEmptyEnvValue('OM_DEV_WARMUP_TENANT_ID') ?? null,
166
183
  tenantLookupAttempted: false,
167
184
  }
168
185
 
186
+ function clearWarmupReadyFile() {
187
+ if (!warmupReadyFile) return
188
+ try {
189
+ fs.rmSync(warmupReadyFile, { force: true })
190
+ } catch {
191
+ // Warmup readiness is best-effort; terminal status remains authoritative.
192
+ }
193
+ }
194
+
195
+ function writeWarmupReadyFile(reason) {
196
+ if (!warmupReadyFile) return
197
+ try {
198
+ fs.mkdirSync(path.dirname(warmupReadyFile), { recursive: true })
199
+ fs.writeFileSync(warmupReadyFile, `${JSON.stringify({
200
+ ready: true,
201
+ reason,
202
+ at: new Date().toISOString(),
203
+ }, null, 2)}\n`)
204
+ } catch {
205
+ // Warmup readiness is best-effort; background services can still run without it.
206
+ }
207
+ }
208
+
209
+ clearWarmupReadyFile()
210
+
169
211
  function printCompactSummary(icon, title, lines) {
170
212
  if (!Array.isArray(lines) || lines.length === 0) return
171
213
  console.log(`${icon} ${title}`)
@@ -489,6 +531,8 @@ function spawnMercato(args) {
489
531
  ...process.env,
490
532
  OM_CLI_QUIET: rawPassthrough ? process.env.OM_CLI_QUIET : '1',
491
533
  DOTENV_CONFIG_QUIET: rawPassthrough ? process.env.DOTENV_CONFIG_QUIET : 'true',
534
+ ...(!rawPassthrough ? { OM_DEV_SPLASH_RUNTIME_WRAPPER: '1' } : {}),
535
+ ...(!rawPassthrough && warmupReadyFile ? { OM_DEV_WARMUP_READY_FILE: warmupReadyFile } : {}),
492
536
  },
493
537
  ...resolvedSpawn.spawnOptions,
494
538
  })
@@ -649,10 +693,18 @@ async function resolveWarmupTenantIdFromDatabase(email) {
649
693
  }
650
694
  }
651
695
 
652
- async function fetchWithTimeout(url, init = {}, timeoutMs = 45000) {
696
+ async function fetchWithTimeout(url, init = {}, timeoutMs = 45000, externalSignal = null) {
697
+ if (externalSignal?.aborted) {
698
+ throw externalSignal.reason ?? new Error('warmup request aborted')
699
+ }
700
+
653
701
  const controller = new AbortController()
654
702
  const timer = setTimeout(() => controller.abort(), timeoutMs)
655
703
  timer.unref?.()
704
+ const abortFromExternalSignal = () => {
705
+ controller.abort(externalSignal.reason ?? new Error('warmup request aborted'))
706
+ }
707
+ externalSignal?.addEventListener?.('abort', abortFromExternalSignal, { once: true })
656
708
 
657
709
  try {
658
710
  return await fetch(url, {
@@ -661,6 +713,7 @@ async function fetchWithTimeout(url, init = {}, timeoutMs = 45000) {
661
713
  })
662
714
  } finally {
663
715
  clearTimeout(timer)
716
+ externalSignal?.removeEventListener?.('abort', abortFromExternalSignal)
664
717
  }
665
718
  }
666
719
 
@@ -674,14 +727,14 @@ function isAbortLikeError(error) {
674
727
  return /aborted/i.test(String(error))
675
728
  }
676
729
 
677
- async function fetchWarmupWithRetry(url, init, detailLabel, progressLabel) {
730
+ async function fetchWarmupWithRetry(url, init, detailLabel, progressLabel, signal = null) {
678
731
  let lastError = null
679
732
 
680
733
  for (let index = 0; index < warmupRequestTimeoutsMs.length; index += 1) {
681
734
  const timeoutMs = warmupRequestTimeoutsMs[index]
682
735
 
683
736
  try {
684
- return await fetchWithTimeout(url, init, timeoutMs)
737
+ return await fetchWithTimeout(url, init, timeoutMs, signal)
685
738
  } catch (error) {
686
739
  lastError = error
687
740
 
@@ -766,6 +819,20 @@ function clearWarmupRetryTimer() {
766
819
  runtimeWarmupState.retryTimer = null
767
820
  }
768
821
 
822
+ function resetWarmupForRuntimeRestart(reason) {
823
+ clearWarmupRetryTimer()
824
+ runtimeWarmupState.generation += 1
825
+ runtimeWarmupState.readySignalSeen = false
826
+ runtimeWarmupState.started = false
827
+ runtimeWarmupState.completed = false
828
+ runtimeWarmupState.failed = false
829
+ runtimeWarmupState.promise = null
830
+ runtimeWarmupState.retryAttempts = 0
831
+ runtimeWarmupState.abortController?.abort(new Error(`warmup aborted because ${reason}`))
832
+ runtimeWarmupState.abortController = null
833
+ clearWarmupReadyFile()
834
+ }
835
+
769
836
  function scheduleWarmupRetry(delayMs = 2000) {
770
837
  clearWarmupRetryTimer()
771
838
  runtimeWarmupState.retryTimer = setTimeout(() => {
@@ -782,6 +849,9 @@ async function runTargetedRouteWarmup() {
782
849
 
783
850
  clearWarmupRetryTimer()
784
851
  runtimeWarmupState.started = true
852
+ const generation = runtimeWarmupState.generation
853
+ const abortController = new AbortController()
854
+ runtimeWarmupState.abortController = abortController
785
855
  const startedAt = Date.now()
786
856
  const progressLabel = 'Precompiling login and backend'
787
857
  const introMessage = '🔥 Precompiling /login, login POST, and /backend'
@@ -796,7 +866,9 @@ async function runTargetedRouteWarmup() {
796
866
  { method: 'GET', redirect: 'manual' },
797
867
  '/login',
798
868
  progressLabel,
869
+ abortController.signal,
799
870
  )
871
+ if (generation !== runtimeWarmupState.generation) return
800
872
  if (shouldRetryWarmupStatus(loginPageResponse.status)) {
801
873
  throw createWarmupTransientError(`/login returned HTTP ${loginPageResponse.status}`)
802
874
  }
@@ -825,7 +897,9 @@ async function runTargetedRouteWarmup() {
825
897
  },
826
898
  'POST /api/auth/login',
827
899
  progressLabel,
900
+ abortController.signal,
828
901
  )
902
+ if (generation !== runtimeWarmupState.generation) return
829
903
  const loginPayload = await readResponsePayload(loginResponse)
830
904
  if (!loginResponse.ok || !loginPayload || typeof loginPayload !== 'object' || loginPayload.ok !== true) {
831
905
  const failure = extractWarmupErrorMessage(loginPayload, `HTTP ${loginResponse.status}`)
@@ -864,7 +938,9 @@ async function runTargetedRouteWarmup() {
864
938
  },
865
939
  '/backend',
866
940
  progressLabel,
941
+ abortController.signal,
867
942
  )
943
+ if (generation !== runtimeWarmupState.generation) return
868
944
  if (backendResponse.status >= 300 && backendResponse.status < 400) {
869
945
  const location = backendResponse.headers.get('location') || 'redirect'
870
946
  if (isWarmupRetryableRedirect(location)) {
@@ -888,6 +964,7 @@ async function runTargetedRouteWarmup() {
888
964
  runtimeWarmupState.completed = true
889
965
  runtimeWarmupState.failed = false
890
966
  runtimeWarmupState.promise = null
967
+ runtimeWarmupState.abortController = null
891
968
  const completedMessage = `🚪 Login flow and backend warmed in ${formatDuration(Date.now() - startedAt)}`
892
969
  updateSplashState({
893
970
  phase: 'App is ready',
@@ -902,9 +979,15 @@ async function runTargetedRouteWarmup() {
902
979
  progressLabel: 'App is ready',
903
980
  activity: completedMessage,
904
981
  })
982
+ writeWarmupReadyFile('warmup-complete')
905
983
  console.log(formatStatusOutput(completedMessage, runtimeReadyProgressCurrent, 'App is ready'))
906
984
  } catch (error) {
985
+ if (generation !== runtimeWarmupState.generation) {
986
+ return
987
+ }
988
+
907
989
  runtimeWarmupState.promise = null
990
+ runtimeWarmupState.abortController = null
908
991
 
909
992
  if (isAbortLikeError(error) || isWarmupTransientError(error)) {
910
993
  runtimeWarmupState.started = false
@@ -978,12 +1061,14 @@ async function runTargetedRouteWarmup() {
978
1061
  activity: warmupWarning,
979
1062
  })
980
1063
  if (isCredentialsFailure) {
1064
+ writeWarmupReadyFile('warmup-credentials-failed')
981
1065
  console.log(formatStatusOutput(
982
1066
  '⚠️ Warmup login returned 401 — credentials invalid. Set OM_INIT_SUPERADMIN_EMAIL/PASSWORD in .env or run: yarn initialize',
983
1067
  runtimeReadyProgressCurrent,
984
1068
  'App is ready',
985
1069
  ))
986
1070
  } else {
1071
+ writeWarmupReadyFile('warmup-incomplete')
987
1072
  console.log(formatStatusOutput(warmupWarning, runtimeReadyProgressCurrent, 'App is ready'))
988
1073
  }
989
1074
  }
@@ -1138,6 +1223,18 @@ function shutdown(exitCode = 0) {
1138
1223
  rawModeEnabled = false
1139
1224
  }
1140
1225
 
1226
+ if (!shutdownNoticeOwnedByParent) {
1227
+ const message = 'Shutting down services...'
1228
+ updateSplashState({
1229
+ phase: message,
1230
+ detail: 'Stopping app runtime, workers, and scheduler',
1231
+ ready: false,
1232
+ progressLabel: message,
1233
+ activity: message,
1234
+ })
1235
+ console.log(message)
1236
+ }
1237
+
1141
1238
  const alive = Array.from(children).filter((child) => !child.killed)
1142
1239
  if (alive.length === 0) {
1143
1240
  process.exit(exitCode)
@@ -1582,6 +1679,37 @@ function classifyServerLine(line) {
1582
1679
  }
1583
1680
  }
1584
1681
 
1682
+ const runtimeRestartMatch = line.match(/^\[server\] Detected (.+?)\. Restarting app runtime\.\.\.$/)
1683
+ if (runtimeRestartMatch) {
1684
+ const reason = runtimeRestartMatch[1]
1685
+ resetWarmupForRuntimeRestart(reason)
1686
+ return {
1687
+ type: 'status',
1688
+ message: `🔄 Restarting app runtime: ${reason}`,
1689
+ splashPhase: 'App runtime is restarting',
1690
+ splashDetail: `Reason: ${reason}`,
1691
+ ready: false,
1692
+ activity: `App runtime restart: ${reason}`,
1693
+ progressCurrent: runtimeProgressCurrent,
1694
+ progressLabel: 'Restarting app runtime',
1695
+ }
1696
+ }
1697
+
1698
+ if (line === '[server] Detected corrupted Turbopack dev cache. Clearing .mercato/next/dev and restarting Next.js once...') {
1699
+ const reason = 'corrupted Turbopack dev cache'
1700
+ resetWarmupForRuntimeRestart(reason)
1701
+ return {
1702
+ type: 'status',
1703
+ message: `🔄 Restarting Next.js dev server: ${reason}`,
1704
+ splashPhase: 'App runtime is restarting',
1705
+ splashDetail: `Reason: ${reason}`,
1706
+ ready: false,
1707
+ activity: `Next.js restart: ${reason}`,
1708
+ progressCurrent: runtimeProgressCurrent,
1709
+ progressLabel: 'Restarting app runtime',
1710
+ }
1711
+ }
1712
+
1585
1713
  const localMatch = line.match(/^- Local:\s*(.+)$/)
1586
1714
  if (localMatch) {
1587
1715
  return {
@@ -1592,7 +1720,7 @@ function classifyServerLine(line) {
1592
1720
  readyUrl: localMatch[1],
1593
1721
  loginUrl: `${localMatch[1].replace(/\/$/, '')}/login`,
1594
1722
  activity: `App runtime at ${localMatch[1]}`,
1595
- progressCurrent: 4,
1723
+ progressCurrent: runtimeWarmupProgressCurrent,
1596
1724
  progressLabel: 'Precompiling login page',
1597
1725
  }
1598
1726
  }
@@ -1607,7 +1735,7 @@ function classifyServerLine(line) {
1607
1735
  ready: false,
1608
1736
  runtimeReady: true,
1609
1737
  activity: timing,
1610
- progressCurrent: 4,
1738
+ progressCurrent: runtimeWarmupProgressCurrent,
1611
1739
  progressLabel: 'Precompiling login page',
1612
1740
  }
1613
1741
  }
@@ -1616,7 +1744,7 @@ function classifyServerLine(line) {
1616
1744
  const target = compiledMatch[1]?.trim()
1617
1745
  const detail = target ? ` ${target}` : ''
1618
1746
  const timing = `⚡ Compiled${detail} in ${parseDurationToken(compiledMatch[2])}`
1619
- const progressCurrent = splashState.ready ? runtimeReadyProgressCurrent : 3
1747
+ const progressCurrent = splashState.ready ? runtimeReadyProgressCurrent : runtimeWarmupProgressCurrent
1620
1748
  return {
1621
1749
  type: 'status',
1622
1750
  message: timing,
@@ -1721,9 +1849,10 @@ async function runClassicRuntime() {
1721
1849
  shutdown(initialGenerateExitCode)
1722
1850
  }
1723
1851
 
1724
- // Default ('in-process'): `mercato server dev` runs the structural
1725
- // regeneration watcher in-process. Set OM_DEV_GENERATE_WATCH_MODE=legacy
1726
- // to fall back to spawning the dedicated sidecar Node process.
1852
+ // Default ('in-process'): `mercato server dev` owns the structural
1853
+ // regeneration watcher in-process, so we no longer spawn the dedicated
1854
+ // sidecar. Saves ~190 MB of resident RSS by collapsing one Node process.
1855
+ // Opt back into the sidecar with OM_DEV_GENERATE_WATCH_MODE=legacy.
1727
1856
  const watchers = []
1728
1857
  if (generateWatchMode === 'legacy') {
1729
1858
  watchers.push(['Generator watch (legacy sidecar)', spawnMercato(['generate', 'watch', '--skip-initial'])])
@@ -1751,6 +1880,11 @@ installLogToggle()
1751
1880
  initializeRuntimeSummary()
1752
1881
  printRuntimePackagesSummary()
1753
1882
 
1883
+ // Default ('in-process'): `mercato server dev` runs the structural
1884
+ // regeneration watcher in-process — see packages/cli/src/lib/in-process-generate-watcher.ts
1885
+ // — so the orchestrator no longer spawns a dedicated `mercato generate watch`
1886
+ // sidecar. Saves ~190 MB of resident RSS by eliminating one Node process.
1887
+ // Set OM_DEV_GENERATE_WATCH_MODE=legacy to opt back into the sidecar.
1754
1888
  const sidecarWatch = generateWatchMode === 'legacy'
1755
1889
  ? startFilteredChild(['generate', 'watch', '--skip-initial'], 'Generator watch (legacy sidecar)', classifyWatchLine)
1756
1890
  : null
@@ -202,6 +202,10 @@ const splashEnabled = !classic && !appOnly && splashPortConfig.enabled
202
202
  const autoOpenSplash = splashEnabled && process.stdout.isTTY && process.env.CI !== 'true' && process.env.OM_DEV_AUTO_OPEN !== '0'
203
203
  const splashBindHost = isContainerRuntime() ? '0.0.0.0' : '127.0.0.1'
204
204
  const standaloneRuntimeScript = path.join(process.cwd(), 'scripts', 'dev-runtime.mjs')
205
+ const warmupReadyFilePath = path.join(
206
+ process.cwd(),
207
+ isMonorepo ? 'apps/mercato/.mercato/dev-warmup-ready.json' : '.mercato/dev-warmup-ready.json',
208
+ )
205
209
  const devLogTeeDisabled = process.env.OM_DEV_LOG_TEE === '0' || process.env.OM_DEV_LOG_TEE === 'false'
206
210
 
207
211
  let devLogSessionInstance = null
@@ -492,7 +496,7 @@ function resolveSplashLocaleConfig() {
492
496
  return splashLocaleConfig
493
497
  }
494
498
 
495
- function buildSplashChildEnv() {
499
+ function buildSplashChildEnv(options = {}) {
496
500
  const childEnv = devLogTeeDisabled
497
501
  ? {}
498
502
  : {
@@ -501,18 +505,31 @@ function buildSplashChildEnv() {
501
505
  }
502
506
 
503
507
  if (!splashChildStateFile) {
504
- return Object.keys(childEnv).length > 0 ? childEnv : undefined
508
+ const env = {
509
+ ...childEnv,
510
+ OM_DEV_SHUTDOWN_NOTICE_OWNER: 'parent',
511
+ }
512
+ return Object.keys(env).length > 0 ? env : undefined
505
513
  }
506
514
 
507
515
  return {
508
516
  ...childEnv,
509
517
  OM_DEV_SPLASH_CHILD_STATE_FILE: splashChildStateFile,
518
+ OM_DEV_WARMUP_READY_FILE: warmupReadyFilePath,
510
519
  OM_DEV_SPLASH_MODE: splashMode,
520
+ OM_DEV_SHUTDOWN_NOTICE_OWNER: 'parent',
521
+ ...(Number.isFinite(options.stageCurrent) ? { OM_DEV_SPLASH_STAGE_CURRENT: String(options.stageCurrent) } : {}),
522
+ ...(Number.isFinite(options.stageTotal) ? { OM_DEV_SPLASH_STAGE_TOTAL: String(options.stageTotal) } : {}),
511
523
  }
512
524
  }
513
525
 
514
526
  function applyLocalDevBackgroundServiceDefaults(childEnv) {
515
- const env = childEnv ?? {}
527
+ const env = {
528
+ ...(childEnv ?? {}),
529
+ OM_DEV_WARMUP_READY_FILE: (childEnv && 'OM_DEV_WARMUP_READY_FILE' in childEnv)
530
+ ? childEnv.OM_DEV_WARMUP_READY_FILE
531
+ : warmupReadyFilePath,
532
+ }
516
533
  if (
517
534
  typeof process.env.OM_AUTO_SPAWN_WORKERS_LAZY !== 'string'
518
535
  || process.env.OM_AUTO_SPAWN_WORKERS_LAZY.trim() === ''
@@ -528,8 +545,8 @@ function applyLocalDevBackgroundServiceDefaults(childEnv) {
528
545
  return env
529
546
  }
530
547
 
531
- function buildAppDevEnv() {
532
- return applyLocalDevBackgroundServiceDefaults(buildSplashChildEnv() ?? {})
548
+ function buildAppDevEnv(options = {}) {
549
+ return applyLocalDevBackgroundServiceDefaults(buildSplashChildEnv(options) ?? {})
533
550
  }
534
551
 
535
552
  function launchStandaloneDev(options = {}) {
@@ -566,7 +583,7 @@ function launchStandaloneDev(options = {}) {
566
583
 
567
584
  const app = spawnCommand(process.execPath, runtimeArgs, {
568
585
  stdio: 'inherit',
569
- env: buildAppDevEnv(),
586
+ env: buildAppDevEnv({ stageCurrent, stageTotal }),
570
587
  })
571
588
 
572
589
  app.on('close', (code) => {
@@ -996,6 +1013,18 @@ function closeSplashServer() {
996
1013
  writeSplashChildStateFileClear()
997
1014
  }
998
1015
 
1016
+ function announceShutdown() {
1017
+ const message = 'Shutting down services...'
1018
+ updateSplashState({
1019
+ phase: message,
1020
+ detail: 'Stopping app runtime, watchers, workers, and scheduler',
1021
+ ready: false,
1022
+ progressLabel: message,
1023
+ activity: message,
1024
+ })
1025
+ console.log(message)
1026
+ }
1027
+
999
1028
  function openBrowser(url) {
1000
1029
  try {
1001
1030
  let child
@@ -1014,10 +1043,11 @@ function openBrowser(url) {
1014
1043
  function shutdown(exitCode = 0) {
1015
1044
  if (shuttingDown) return
1016
1045
  shuttingDown = true
1017
- closeSplashServer()
1046
+ announceShutdown()
1018
1047
 
1019
1048
  const alive = Array.from(children).filter((child) => !child.killed)
1020
1049
  if (alive.length === 0) {
1050
+ closeSplashServer()
1021
1051
  closeDevLogSession()
1022
1052
  process.exit(exitCode)
1023
1053
  return
@@ -1033,6 +1063,7 @@ function shutdown(exitCode = 0) {
1033
1063
  killProcessTree(child, 'SIGKILL')
1034
1064
  }
1035
1065
  }
1066
+ closeSplashServer()
1036
1067
  closeDevLogSession()
1037
1068
  process.exit(exitCode)
1038
1069
  }, 3000)
@@ -1615,7 +1646,7 @@ function launchMonorepoAppDev() {
1615
1646
  })
1616
1647
  const app = spawnCommand(yarnCommand, appArgs, {
1617
1648
  stdio: 'inherit',
1618
- env: buildAppDevEnv(),
1649
+ env: buildAppDevEnv({ stageCurrent, stageTotal }),
1619
1650
  })
1620
1651
 
1621
1652
  app.on('close', (code, signal) => {
@@ -1,7 +1,6 @@
1
1
  @import "tailwindcss";
2
2
  @import "tw-animate-css";
3
3
  @import "react-big-calendar/lib/css/react-big-calendar.css";
4
- @import "@xyflow/react/dist/style.css";
5
4
  @import "../../.mercato/generated/module-package-sources.css";
6
5
 
7
6
  /*
@@ -1,51 +1,77 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
- import { injectionWidgetEntries } from '@/.mercato/generated/injection-widgets.generated'
5
- // Side-effect: registers translatable fields for client-side TranslationManager
4
+ // Side-effect imports: these register types/components on import, so they
5
+ // MUST stay top-level to be available during the first paint.
6
6
  import '@/.mercato/generated/translations-fields.generated'
7
- import { injectionTables } from '@/.mercato/generated/injection-tables.generated'
8
- import { enabledModuleIds } from '@/.mercato/generated/enabled-module-ids.generated'
7
+ import '@/.mercato/generated/messages.client.generated'
8
+ import '@/.mercato/generated/payments.client.generated'
9
+
9
10
  import { registerCoreInjectionWidgets, registerCoreInjectionTables, registerEnabledModuleIds } from '@open-mercato/core/modules/widgets/lib/injection'
10
11
  import { registerInjectionWidgets } from '@open-mercato/ui/backend/injection/widgetRegistry'
11
- import { dashboardWidgetEntries } from '@/.mercato/generated/dashboard-widgets.generated'
12
12
  import { registerDashboardWidgets } from '@open-mercato/ui/backend/dashboard/widgetRegistry'
13
- import { notificationHandlerEntries } from '@/.mercato/generated/notification-handlers.generated'
14
13
  import { registerNotificationHandlers } from '@open-mercato/shared/lib/notifications/handler-registry'
15
- // Side-effect: registers translatable fields for client-side TranslationManager
16
- import '@/.mercato/generated/translations-fields.generated'
17
- // Side-effect: configures message UI component and object type registries on the client.
18
- import '@/.mercato/generated/messages.client.generated'
19
- // Side-effect: registers provider-owned payment renderer widgets on the client.
20
- import '@/.mercato/generated/payments.client.generated'
21
14
 
22
15
  let _clientBootstrapped = false
16
+ let _bootstrapPromise: Promise<void> | null = null
23
17
 
24
- function clientBootstrap() {
18
+ async function clientBootstrap(): Promise<void> {
25
19
  if (_clientBootstrapped) return
26
- _clientBootstrapped = true
20
+ if (_bootstrapPromise) return _bootstrapPromise
21
+
22
+ _bootstrapPromise = (async () => {
23
+ try {
24
+ // Defer generated registry barrels to a dynamic import so each barrel
25
+ // becomes its own lazy chunk in Turbopack. Routes that mount this
26
+ // provider but never use injection/dashboard/notification registries
27
+ // still get the chunks compiled, but no longer during initial page
28
+ // parse — the first paint is no longer blocked on registering every
29
+ // module's client widgets.
30
+ const [
31
+ injectionWidgets,
32
+ injectionTables,
33
+ enabledModuleIds,
34
+ dashboardWidgets,
35
+ notificationHandlers,
36
+ ] = await Promise.all([
37
+ import('@/.mercato/generated/injection-widgets.generated'),
38
+ import('@/.mercato/generated/injection-tables.generated'),
39
+ import('@/.mercato/generated/enabled-module-ids.generated'),
40
+ import('@/.mercato/generated/dashboard-widgets.generated'),
41
+ import('@/.mercato/generated/notification-handlers.generated'),
42
+ ])
27
43
 
28
- // Register injection widgets
29
- registerInjectionWidgets(injectionWidgetEntries)
30
- registerCoreInjectionWidgets(injectionWidgetEntries)
31
- registerCoreInjectionTables(injectionTables)
32
- registerEnabledModuleIds(enabledModuleIds)
44
+ registerInjectionWidgets(injectionWidgets.injectionWidgetEntries)
45
+ registerCoreInjectionWidgets(injectionWidgets.injectionWidgetEntries)
46
+ registerCoreInjectionTables(injectionTables.injectionTables)
47
+ registerEnabledModuleIds(enabledModuleIds.enabledModuleIds)
48
+ registerDashboardWidgets(dashboardWidgets.dashboardWidgetEntries)
49
+ registerNotificationHandlers(notificationHandlers.notificationHandlerEntries)
33
50
 
34
- // Register dashboard widgets
35
- registerDashboardWidgets(dashboardWidgetEntries)
51
+ _clientBootstrapped = true
52
+ } catch (err) {
53
+ // A lazy registry chunk failed to load (e.g. a stale chunk after a
54
+ // deploy). Clear the cached promise so the next render retries instead
55
+ // of leaving every client registry empty forever — otherwise dashboard
56
+ // widget cards would wait on registration indefinitely with no error.
57
+ _bootstrapPromise = null
58
+ console.error('[ClientBootstrap] Failed to register client registries; will retry on next render', err)
59
+ }
60
+ })()
36
61
 
37
- // Register notification handlers for client-side reactive effects
38
- registerNotificationHandlers(notificationHandlerEntries)
62
+ return _bootstrapPromise
39
63
  }
40
64
 
41
65
  export function ClientBootstrapProvider({ children }: { children: React.ReactNode }) {
42
66
  React.useEffect(() => {
43
- clientBootstrap()
67
+ void clientBootstrap()
44
68
  }, [])
45
69
 
46
- // Also bootstrap synchronously on first render for SSR hydration
70
+ // Fire-and-forget on the very first client render so any consumer that
71
+ // reads registries during the same paint as this provider mounts still
72
+ // sees them populated by microtask flush. The promise is cached.
47
73
  if (typeof window !== 'undefined' && !_clientBootstrapped) {
48
- clientBootstrap()
74
+ void clientBootstrap()
49
75
  }
50
76
 
51
77
  return <>{children}</>
@@ -107,6 +107,7 @@
107
107
  "common.delete": "Löschen",
108
108
  "common.edit": "Bearbeiten",
109
109
  "common.email": "E-Mail",
110
+ "common.error": "Etwas ist schiefgelaufen.",
110
111
  "common.expand": "Ausklappen",
111
112
  "common.export": "Exportieren",
112
113
  "common.inactive": "Inaktiv",
@@ -107,6 +107,7 @@
107
107
  "common.delete": "Delete",
108
108
  "common.edit": "Edit",
109
109
  "common.email": "Email",
110
+ "common.error": "Something went wrong.",
110
111
  "common.expand": "Expand",
111
112
  "common.export": "Export",
112
113
  "common.inactive": "Inactive",
@@ -107,6 +107,7 @@
107
107
  "common.delete": "Eliminar",
108
108
  "common.edit": "Editar",
109
109
  "common.email": "Correo electrónico",
110
+ "common.error": "Algo salió mal.",
110
111
  "common.expand": "Expandir",
111
112
  "common.export": "Exportar",
112
113
  "common.inactive": "Inactivo",
@@ -107,6 +107,7 @@
107
107
  "common.delete": "Usuń",
108
108
  "common.edit": "Edytuj",
109
109
  "common.email": "E-mail",
110
+ "common.error": "Coś poszło nie tak.",
110
111
  "common.expand": "Rozwiń",
111
112
  "common.export": "Eksportuj",
112
113
  "common.inactive": "Nieaktywny",
@@ -1,3 +1,5 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { DefaultDataEngine, type DataEngine } from '@open-mercato/shared/lib/data/engine'
1
3
  import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
2
4
 
3
5
  export type ExampleCustomersSyncScope = {
@@ -8,6 +10,31 @@ export type ExampleCustomersSyncScope = {
8
10
  export const EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_ORIGIN = 'example_customers_sync:outbound'
9
11
  export const EXAMPLE_CUSTOMERS_SYNC_INBOUND_ORIGIN = 'example_customers_sync:inbound'
10
12
 
13
+ type AnyContainer = { resolve: <T = unknown>(name: string) => T }
14
+
15
+ // Each sync invocation gets its own forked EM + fresh DataEngine so that
16
+ // concurrent or sequential jobs cannot pollute the shared identity map.
17
+ // Without this, two outbound jobs targeting the same interaction.id would
18
+ // both add an INSERT for the same Todo into the shared UoW, and the second
19
+ // flush (inside setRecordCustomFields) would fail with todos_pkey duplicate.
20
+ export function createScopedSyncContainer(container: AnyContainer): AnyContainer {
21
+ const baseEm = container.resolve('em') as EntityManager
22
+ const scopedEm = baseEm.fork({ clear: true })
23
+ let scopedDataEngine: DataEngine | null = null
24
+ return {
25
+ resolve<T = unknown>(name: string): T {
26
+ if (name === 'em') return scopedEm as unknown as T
27
+ if (name === 'dataEngine') {
28
+ if (!scopedDataEngine) {
29
+ scopedDataEngine = new DefaultDataEngine(scopedEm, container as never)
30
+ }
31
+ return scopedDataEngine as unknown as T
32
+ }
33
+ return container.resolve<T>(name)
34
+ },
35
+ }
36
+ }
37
+
11
38
  export function buildExampleCustomersSyncCommandContext(
12
39
  container: { resolve: <T = unknown>(name: string) => T },
13
40
  scope: ExampleCustomersSyncScope,
@@ -30,6 +30,7 @@ import {
30
30
  } from './mappings'
31
31
  import {
32
32
  buildExampleCustomersSyncCommandContext,
33
+ createScopedSyncContainer,
33
34
  EXAMPLE_CUSTOMERS_SYNC_INBOUND_ORIGIN,
34
35
  EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_ORIGIN,
35
36
  type ExampleCustomersSyncScope,
@@ -461,14 +462,24 @@ async function ensureLegacyExampleMapping(
461
462
  }
462
463
 
463
464
  export async function syncCustomerInteractionToExampleTodo(
464
- container: ContainerLike,
465
+ rawContainer: ContainerLike,
465
466
  payload: ExampleCustomersSyncOutboundJobPayload,
466
467
  ): Promise<void> {
467
468
  const scope = { tenantId: payload.tenantId, organizationId: payload.organizationId }
468
- const flags = await resolveExampleCustomersSyncFlags(container, scope.tenantId)
469
+ const flags = await resolveExampleCustomersSyncFlags(rawContainer, scope.tenantId)
469
470
  if (!flags.enabled) return
470
471
 
471
- const em = (container.resolve('em') as EntityManager).fork()
472
+ // Worker's own fork used for reads (findMappingByInteractionId,
473
+ // loadExampleTodoSnapshot, ensureLegacyExampleMapping) and for the
474
+ // error-path mapping write (markMappingError). This preserves the
475
+ // pre-fix behavior of the local em so the catch path keeps working.
476
+ const em = (rawContainer.resolve('em') as EntityManager).fork()
477
+ // Separate scoped container — used ONLY for command-bus calls so each
478
+ // sync invocation gets its own DataEngine.em. This isolates the
479
+ // identity-map pollution that surfaced as a todos_pkey duplicate inside
480
+ // setRecordCustomFields when multiple jobs touched the same interaction.id
481
+ // through the shared request-container DataEngine.
482
+ const container = createScopedSyncContainer(rawContainer)
472
483
  let mapping = await findMappingByInteractionId(em, scope, payload.interactionId)
473
484
 
474
485
  try {
@@ -600,6 +611,10 @@ export async function syncExampleTodoToCanonicalInteraction(
600
611
  const flags = await resolveExampleCustomersSyncFlags(container, scope.tenantId)
601
612
  if (!flags.enabled || !flags.bidirectional) return
602
613
 
614
+ // Inbound sync never hit the outbound duplicate-key bug (it creates/updates
615
+ // canonical interactions, not same-id Todos), so it keeps the original
616
+ // shared-container behavior — scoping it was speculative and regressed the
617
+ // inbound field-clear propagation in TC-CRM-028.
603
618
  const em = (container.resolve('em') as EntityManager).fork()
604
619
  let mapping = await findMappingByTodoId(em, scope, payload.todoId)
605
620
  let todo: ExampleTodoSnapshot | null = null
@@ -900,8 +915,8 @@ export async function reconcileLegacyExampleTodoLinks(
900
915
  ): Promise<ExampleCustomersSyncReconcileResult> {
901
916
  const scope = { tenantId: input.tenantId, organizationId: input.organizationId }
902
917
  const limit = Math.min(Math.max(input.limit ?? 100, 1), 500)
903
- const { rows, nextCursor } = await loadLegacyExampleTodoLinks(container, scope, limit, input.cursor)
904
918
  const em = (container.resolve('em') as EntityManager).fork()
919
+ const { rows, nextCursor } = await loadLegacyExampleTodoLinks(container, scope, limit, input.cursor)
905
920
  const items: ExampleCustomersSyncReconcileItem[] = []
906
921
  let mapped = 0
907
922
  let createdInteractions = 0