@open-mercato/cli 0.6.4-develop.3929.1.fcf7afece2 → 0.6.4-develop.3944.1.4100aa7fbe

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cli",
3
- "version": "0.6.4-develop.3929.1.fcf7afece2",
3
+ "version": "0.6.4-develop.3944.1.4100aa7fbe",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -59,8 +59,8 @@
59
59
  "@mikro-orm/decorators": "^7.1.1",
60
60
  "@mikro-orm/migrations": "^7.1.1",
61
61
  "@mikro-orm/postgresql": "^7.1.1",
62
- "@open-mercato/queue": "0.6.4-develop.3929.1.fcf7afece2",
63
- "@open-mercato/shared": "0.6.4-develop.3929.1.fcf7afece2",
62
+ "@open-mercato/queue": "0.6.4-develop.3944.1.4100aa7fbe",
63
+ "@open-mercato/shared": "0.6.4-develop.3944.1.4100aa7fbe",
64
64
  "cross-spawn": "^7.0.6",
65
65
  "pg": "8.21.0",
66
66
  "semver": "^7.8.1",
@@ -70,10 +70,10 @@
70
70
  "typescript": "^6.0.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@open-mercato/shared": "0.6.4-develop.3929.1.fcf7afece2"
73
+ "@open-mercato/shared": "0.6.4-develop.3944.1.4100aa7fbe"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.4-develop.3929.1.fcf7afece2",
76
+ "@open-mercato/shared": "0.6.4-develop.3944.1.4100aa7fbe",
77
77
  "@types/jest": "^30.0.0",
78
78
  "jest": "^30.4.2",
79
79
  "ts-jest": "^29.4.11"
@@ -61,7 +61,7 @@ describe('dev env reload helpers', () => {
61
61
  expect(environment.REMOVED_LATER).toBeUndefined()
62
62
  })
63
63
 
64
- it('watches generated runtime files for dev restarts', async () => {
64
+ it('watches generated runtime files when explicitly requested', async () => {
65
65
  const generatedDir = path.join(appDir, '.mercato', 'generated')
66
66
  fs.mkdirSync(generatedDir, { recursive: true })
67
67
  const generatedFile = path.join(generatedDir, 'backend-routes.generated.ts')
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { calculateGenerateWatchStructureChecksum } from '../generate-watch-structure'
5
+
6
+ function write(filePath: string, content: string): void {
7
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
8
+ fs.writeFileSync(filePath, content)
9
+ }
10
+
11
+ describe('calculateGenerateWatchStructureChecksum', () => {
12
+ let root: string
13
+ let appDir: string
14
+ let pkgModule: string
15
+ let appModule: string
16
+
17
+ beforeEach(() => {
18
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'om-generate-watch-'))
19
+ appDir = path.join(root, 'apps', 'mercato')
20
+ pkgModule = path.join(root, 'packages', 'core', 'src', 'modules', 'customers')
21
+ appModule = path.join(appDir, 'src', 'modules', 'customers')
22
+ write(path.join(appDir, 'src', 'modules.ts'), 'export const enabledModules = []\n')
23
+ write(path.join(pkgModule, 'index.ts'), 'export const metadata = { id: "customers" }\n')
24
+ write(path.join(pkgModule, 'backend', 'customers', 'people', 'page.tsx'), 'export default function Page() { return null }\n')
25
+ write(path.join(pkgModule, 'components', 'detail', 'PersonDetailTabs.tsx'), 'export function PersonDetailTabs() { return null }\n')
26
+ })
27
+
28
+ afterEach(() => {
29
+ fs.rmSync(root, { recursive: true, force: true })
30
+ })
31
+
32
+ function currentChecksum(): string {
33
+ return calculateGenerateWatchStructureChecksum({
34
+ modulesFile: path.join(appDir, 'src', 'modules.ts'),
35
+ moduleRoots: [{ appBase: appModule, pkgBase: pkgModule }],
36
+ })
37
+ }
38
+
39
+ it('ignores ordinary component edits outside generator discovery paths', () => {
40
+ const before = currentChecksum()
41
+
42
+ write(path.join(pkgModule, 'components', 'detail', 'PersonDetailTabs.tsx'), 'export function PersonDetailTabs() { return "changed" }\n')
43
+
44
+ expect(currentChecksum()).toBe(before)
45
+ })
46
+
47
+ it('changes when a discovered backend page is added', () => {
48
+ const before = currentChecksum()
49
+
50
+ write(path.join(pkgModule, 'backend', 'customers', 'companies', 'page.tsx'), 'export default function Page() { return null }\n')
51
+
52
+ expect(currentChecksum()).not.toBe(before)
53
+ })
54
+
55
+ it('changes when route metadata changes', () => {
56
+ write(path.join(pkgModule, 'backend', 'customers', 'people', 'page.meta.ts'), 'export const metadata = { nav: { label: "People" } }\n')
57
+ const before = currentChecksum()
58
+
59
+ write(path.join(pkgModule, 'backend', 'customers', 'people', 'page.meta.ts'), 'export const metadata = { nav: { label: "Contacts" } }\n')
60
+
61
+ expect(currentChecksum()).not.toBe(before)
62
+ })
63
+
64
+ it('changes when inline page metadata changes', () => {
65
+ write(path.join(pkgModule, 'backend', 'customers', 'people', 'page.tsx'), 'export const metadata = { nav: { label: "People" } }\nexport default function Page() { return null }\n')
66
+ const before = currentChecksum()
67
+
68
+ write(path.join(pkgModule, 'backend', 'customers', 'people', 'page.tsx'), 'export const metadata = { nav: { label: "Contacts" } }\nexport default function Page() { return null }\n')
69
+
70
+ expect(currentChecksum()).not.toBe(before)
71
+ })
72
+
73
+ it('changes when a convention file changes', () => {
74
+ const before = currentChecksum()
75
+
76
+ write(path.join(pkgModule, 'acl.ts'), 'export const features = [{ id: "customers.view" }]\n')
77
+
78
+ expect(currentChecksum()).not.toBe(before)
79
+ })
80
+ })
@@ -0,0 +1,170 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import {
5
+ MODULE_CODE_EXTENSIONS,
6
+ SCAN_CONFIGS,
7
+ scanModuleDir,
8
+ stripModuleCodeExtension,
9
+ type ModuleRoots,
10
+ } from './generators/scanner'
11
+
12
+ const STRUCTURAL_CONVENTION_FILES = [
13
+ 'index.ts',
14
+ 'cli.ts',
15
+ 'di.ts',
16
+ 'acl.ts',
17
+ 'setup.ts',
18
+ 'encryption.ts',
19
+ 'ce.ts',
20
+ 'search.ts',
21
+ 'events.ts',
22
+ 'notifications.ts',
23
+ 'notifications.client.ts',
24
+ 'notifications.handlers.ts',
25
+ 'translations.ts',
26
+ 'generators.ts',
27
+ 'ai-tools.ts',
28
+ 'ai-agents.ts',
29
+ 'analytics.ts',
30
+ 'workflows.ts',
31
+ 'inbox-actions.ts',
32
+ 'message-types.ts',
33
+ 'message-objects.ts',
34
+ 'integration.ts',
35
+ 'security.mfa-providers.ts',
36
+ 'security.sudo.ts',
37
+ 'data/entities.ts',
38
+ 'data/extensions.ts',
39
+ 'data/fields.ts',
40
+ 'data/enrichers.ts',
41
+ 'data/guards.ts',
42
+ 'api/interceptors.ts',
43
+ 'commands/interceptors.ts',
44
+ 'widgets/components.ts',
45
+ 'widgets/injection-table.ts',
46
+ 'frontend/middleware.ts',
47
+ 'backend/middleware.ts',
48
+ ] as const
49
+
50
+ const CONTENT_SENSITIVE_SCAN_CONFIGS = [
51
+ SCAN_CONFIGS.apiRoutes,
52
+ SCAN_CONFIGS.apiPlainFiles,
53
+ SCAN_CONFIGS.subscribers,
54
+ SCAN_CONFIGS.workers,
55
+ SCAN_CONFIGS.dashboardWidgets,
56
+ SCAN_CONFIGS.injectionWidgets,
57
+ ] as const
58
+
59
+ const ROUTE_SHAPE_SCAN_CONFIGS = [
60
+ SCAN_CONFIGS.frontendPages,
61
+ SCAN_CONFIGS.backendPages,
62
+ ] as const
63
+
64
+ function checksum(value: string): string {
65
+ return crypto.createHash('md5').update(value).digest('hex')
66
+ }
67
+
68
+ function fileRecord(filePath: string, base: string, mode: 'content' | 'shape'): string | null {
69
+ if (!fs.existsSync(filePath)) return null
70
+ let stat: fs.Stats
71
+ try {
72
+ stat = fs.statSync(filePath)
73
+ } catch {
74
+ return null
75
+ }
76
+ if (!stat.isFile()) return null
77
+ const rel = path.relative(base, filePath).replace(/\\/g, '/')
78
+ if (mode === 'shape') {
79
+ return `file:${rel}`
80
+ }
81
+ try {
82
+ return `file:${rel}:${stat.size}:${checksum(fs.readFileSync(filePath, 'utf8'))}`
83
+ } catch {
84
+ return `file:${rel}:unreadable`
85
+ }
86
+ }
87
+
88
+ function resolveCodeFile(base: string, relativePath: string): string | null {
89
+ const stripped = stripModuleCodeExtension(relativePath)
90
+ const candidates = MODULE_CODE_EXTENSIONS.map((extension) => `${stripped}${extension}`)
91
+ for (const candidate of candidates) {
92
+ const filePath = path.join(base, ...candidate.split('/'))
93
+ if (fs.existsSync(filePath)) return filePath
94
+ }
95
+ return null
96
+ }
97
+
98
+ function hasInlinePageMetadata(filePath: string): boolean {
99
+ try {
100
+ const source = fs.readFileSync(filePath, 'utf8')
101
+ return /\bexport\s+(?:const|let|var|function|class)\s+metadata\b/.test(source)
102
+ || /\bexport\s+\{[^}]*\bmetadata\b[^}]*\}/.test(source)
103
+ } catch {
104
+ return false
105
+ }
106
+ }
107
+
108
+ function addConventionRecords(records: string[], roots: ModuleRoots): void {
109
+ for (const base of [roots.pkgBase, roots.appBase]) {
110
+ records.push(`module-root:${base}:${fs.existsSync(base) ? 'present' : 'missing'}`)
111
+ for (const relativePath of STRUCTURAL_CONVENTION_FILES) {
112
+ const filePath = resolveCodeFile(base, relativePath)
113
+ if (!filePath) {
114
+ records.push(`missing:${base}:${relativePath}`)
115
+ continue
116
+ }
117
+ const record = fileRecord(filePath, base, 'content')
118
+ if (record) records.push(record)
119
+ }
120
+ }
121
+ }
122
+
123
+ function addScannedRecords(records: string[], roots: ModuleRoots): void {
124
+ for (const config of CONTENT_SENSITIVE_SCAN_CONFIGS) {
125
+ for (const scanned of scanModuleDir(roots, config)) {
126
+ const base = scanned.fromApp ? roots.appBase : roots.pkgBase
127
+ const filePath = path.join(base, ...config.folder.split('/'), ...scanned.relPath.split('/'))
128
+ const record = fileRecord(filePath, base, 'content')
129
+ if (record) records.push(`${config.folder}:${record}`)
130
+ }
131
+ }
132
+
133
+ for (const config of ROUTE_SHAPE_SCAN_CONFIGS) {
134
+ for (const scanned of scanModuleDir(roots, config)) {
135
+ const base = scanned.fromApp ? roots.appBase : roots.pkgBase
136
+ const folderPath = path.join(base, ...config.folder.split('/'))
137
+ const filePath = path.join(folderPath, ...scanned.relPath.split('/'))
138
+ const pageRecord = fileRecord(filePath, base, hasInlinePageMetadata(filePath) ? 'content' : 'shape')
139
+ if (pageRecord) records.push(`${config.folder}:${pageRecord}`)
140
+
141
+ const dir = path.dirname(filePath)
142
+ const stem = stripModuleCodeExtension(path.basename(filePath))
143
+ const metaCandidates = stem === 'page'
144
+ ? ['page.meta', 'meta']
145
+ : [`${stem}.meta`, 'meta']
146
+ for (const candidate of metaCandidates) {
147
+ const metaPath = resolveCodeFile(dir, candidate)
148
+ if (!metaPath) continue
149
+ const record = fileRecord(metaPath, base, 'content')
150
+ if (record) records.push(`${config.folder}:meta:${record}`)
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ export function calculateGenerateWatchStructureChecksum(options: {
157
+ modulesFile: string
158
+ moduleRoots: ModuleRoots[]
159
+ }): string {
160
+ const records: string[] = []
161
+ const modulesRecord = fileRecord(options.modulesFile, path.dirname(options.modulesFile), 'content')
162
+ records.push(modulesRecord ?? `missing:${options.modulesFile}`)
163
+
164
+ for (const roots of options.moduleRoots) {
165
+ addConventionRecords(records, roots)
166
+ addScannedRecords(records, roots)
167
+ }
168
+
169
+ return checksum(records.sort((a, b) => a.localeCompare(b)).join('\n'))
170
+ }
package/src/mercato.ts CHANGED
@@ -32,7 +32,7 @@ import {
32
32
  import { parseModuleInstallArgs } from './lib/module-install-args'
33
33
  import { resolveNextBuildIdCandidate } from './lib/next-build-id'
34
34
  import { acquireServerStartLock } from './lib/server-start-lock'
35
- import { createDevEnvReloader, watchDevEnvFiles, watchDevRuntimeFiles } from './lib/dev-env-reload'
35
+ import { createDevEnvReloader, watchDevEnvFiles } from './lib/dev-env-reload'
36
36
  // Lazy-imported to avoid pulling in `testcontainers` (devDependency) at startup
37
37
  const lazyIntegration = () => import('./lib/testing/integration')
38
38
  import type { ChildProcess } from 'node:child_process'
@@ -341,6 +341,92 @@ type DevServerRestartResult = {
341
341
 
342
342
  type DevServerExitResult = ManagedProcessExitResult | DevServerRestartResult
343
343
 
344
+ function resolveDevRuntimeBaseUrl(environment: NodeJS.ProcessEnv = process.env): string {
345
+ const configured =
346
+ environment.APP_URL
347
+ ?? environment.NEXT_PUBLIC_APP_URL
348
+ ?? environment.NEXTAUTH_URL
349
+ if (configured?.trim()) {
350
+ return configured.trim().replace(/\/+$/, '')
351
+ }
352
+ return `http://localhost:${environment.PORT?.trim() || '3000'}`
353
+ }
354
+
355
+ function writeDevSplashChildState(state: Record<string, unknown>): void {
356
+ if (process.env.OM_DEV_SPLASH_RUNTIME_WRAPPER === '1') return
357
+ const stateFile = process.env.OM_DEV_SPLASH_CHILD_STATE_FILE
358
+ if (!stateFile?.trim()) return
359
+
360
+ try {
361
+ fs.mkdirSync(path.dirname(stateFile), { recursive: true })
362
+ fs.writeFileSync(stateFile, `${JSON.stringify({
363
+ mode: process.env.OM_DEV_SPLASH_MODE || 'dev',
364
+ failed: false,
365
+ failureLines: [],
366
+ failureCommand: null,
367
+ ...state,
368
+ }, null, 2)}\n`)
369
+ } catch {
370
+ // Splash state is best-effort; terminal logs remain authoritative.
371
+ }
372
+ }
373
+
374
+ function writeDevSplashRuntimeStarting(detail = 'Starting Next.js dev server'): void {
375
+ writeDevSplashChildState({
376
+ phase: 'Preparing app runtime',
377
+ detail,
378
+ ready: false,
379
+ readyUrl: null,
380
+ loginUrl: null,
381
+ progressLabel: 'Launching app runtime',
382
+ activity: detail,
383
+ })
384
+ }
385
+
386
+ function resolveSplashProgressFallback(): { current: number; total: number } {
387
+ const current = Number.parseInt(process.env.OM_DEV_SPLASH_STAGE_CURRENT ?? '', 10)
388
+ const total = Number.parseInt(process.env.OM_DEV_SPLASH_STAGE_TOTAL ?? '', 10)
389
+ if (Number.isFinite(current) && Number.isFinite(total) && total > 0) {
390
+ return { current, total }
391
+ }
392
+ if (process.env.OM_DEV_SPLASH_MODE === 'greenfield' || process.env.OM_DEV_SPLASH_MODE === 'setup') {
393
+ return { current: 5, total: 5 }
394
+ }
395
+ return { current: 3, total: 3 }
396
+ }
397
+
398
+ function writeDevSplashRuntimeRestarting(reason: string): void {
399
+ const progress = resolveSplashProgressFallback()
400
+ writeDevSplashChildState({
401
+ phase: 'App runtime is restarting',
402
+ detail: `Reason: ${reason}`,
403
+ ready: false,
404
+ readyUrl: null,
405
+ loginUrl: null,
406
+ progressCurrent: progress.current,
407
+ progressTotal: progress.total,
408
+ progressLabel: 'Restarting app runtime',
409
+ activity: `App runtime restart: ${reason}`,
410
+ })
411
+ }
412
+
413
+ function writeDevSplashRuntimeReady(reason?: string): void {
414
+ const readyUrl = resolveDevRuntimeBaseUrl()
415
+ const progress = resolveSplashProgressFallback()
416
+ writeDevSplashChildState({
417
+ phase: 'App is ready',
418
+ detail: reason ? `Restart completed after ${reason}` : 'Next.js dev server is ready',
419
+ ready: true,
420
+ readyUrl,
421
+ loginUrl: `${readyUrl}/login`,
422
+ progressCurrent: progress.current,
423
+ progressTotal: progress.total,
424
+ progressPercent: 100,
425
+ progressLabel: 'App is ready',
426
+ activity: reason ? `Restart completed after ${reason}` : 'App runtime is ready',
427
+ })
428
+ }
429
+
344
430
  type ModuleCommandLookupResult =
345
431
  | {
346
432
  status: 'ok'
@@ -601,18 +687,17 @@ async function runGeneratorSuite(quiet: boolean): Promise<void> {
601
687
  function createGenerateWatchChecksumFn(): () => Promise<string> {
602
688
  return async () => {
603
689
  const { createResolver } = await import('./lib/resolver')
604
- const { calculateStructureChecksum } = await import('./lib/utils')
690
+ const { calculateGenerateWatchStructureChecksum } = await import('./lib/generate-watch-structure')
605
691
  const resolver = createResolver()
606
- const tracked = new Set<string>([
607
- path.join(resolver.getAppDir(), 'src', 'modules.ts'),
608
- path.join(resolver.getAppDir(), 'src', 'modules'),
609
- ])
692
+ const moduleRoots = []
610
693
  for (const entry of resolver.loadEnabledModules()) {
611
694
  const roots = resolver.getModulePaths(entry)
612
- tracked.add(roots.appBase)
613
- tracked.add(roots.pkgBase)
695
+ moduleRoots.push({ appBase: roots.appBase, pkgBase: roots.pkgBase })
614
696
  }
615
- return calculateStructureChecksum(Array.from(tracked))
697
+ return calculateGenerateWatchStructureChecksum({
698
+ modulesFile: path.join(resolver.getAppDir(), 'src', 'modules.ts'),
699
+ moduleRoots,
700
+ })
616
701
  }
617
702
  }
618
703
 
@@ -1694,6 +1779,7 @@ export async function run(argv = process.argv) {
1694
1779
  let activeLazySupervisor: ReturnType<typeof startLazyWorkerSupervisor> | null = null
1695
1780
  let activeLazySchedulerSupervisor: ReturnType<typeof startLazySchedulerSupervisor> | null = null
1696
1781
  let activeGenerateWatcher: GenerateWatcherHandle | null = null
1782
+ let lastRestartReason: string | null = null
1697
1783
  const generateWatcherMode: GenerateWatcherMode = resolveGenerateWatcherMode(process.env)
1698
1784
  const envReloader = createDevEnvReloader(appDir, process.env, initialProcessEnvironmentEntries)
1699
1785
 
@@ -1787,14 +1873,6 @@ export async function run(argv = process.argv) {
1787
1873
  filePath,
1788
1874
  })
1789
1875
  })
1790
- const stopRuntimeWatcher = watchDevRuntimeFiles(appDir, (filePath) => {
1791
- devRestartPromiseResolve?.({
1792
- label: 'Runtime graph change',
1793
- restart: true,
1794
- filePath,
1795
- })
1796
- })
1797
-
1798
1876
  const waitForDevRestart = (): Promise<DevServerRestartResult> =>
1799
1877
  new Promise((resolve) => {
1800
1878
  devRestartPromiseResolve = resolve
@@ -1802,6 +1880,11 @@ export async function run(argv = process.argv) {
1802
1880
 
1803
1881
  const startNextDev = (runtimeEnv: NodeJS.ProcessEnv): Promise<ManagedProcessExitResult> =>
1804
1882
  new Promise((resolve) => {
1883
+ writeDevSplashRuntimeStarting(
1884
+ lastRestartReason
1885
+ ? `Restarting Next.js dev server. Reason: ${lastRestartReason}`
1886
+ : 'Starting Next.js dev server',
1887
+ )
1805
1888
  const nextProcess = spawn('node', [nextBin, 'dev', '--turbopack'], {
1806
1889
  stdio: ['inherit', 'pipe', 'pipe'],
1807
1890
  env: runtimeEnv,
@@ -1810,11 +1893,17 @@ export async function run(argv = process.argv) {
1810
1893
  processes.push(nextProcess)
1811
1894
 
1812
1895
  let combinedOutput = ''
1896
+ let reportedReady = false
1813
1897
  const appendOutput = (chunk: string) => {
1814
1898
  combinedOutput += chunk
1815
1899
  if (combinedOutput.length > 32_768) {
1816
1900
  combinedOutput = combinedOutput.slice(-32_768)
1817
1901
  }
1902
+ if (!reportedReady && /\bready in\b/i.test(chunk)) {
1903
+ reportedReady = true
1904
+ writeDevSplashRuntimeReady(lastRestartReason ?? undefined)
1905
+ lastRestartReason = null
1906
+ }
1818
1907
  }
1819
1908
 
1820
1909
  nextProcess.stdout?.on('data', (chunk: Buffer | string) => {
@@ -1831,6 +1920,8 @@ export async function run(argv = process.argv) {
1831
1920
  nextProcess.on('exit', async (code, signal) => {
1832
1921
  if (!didRetryCorruptedTurbopackCache && isTurbopackCacheCorruption(combinedOutput)) {
1833
1922
  didRetryCorruptedTurbopackCache = true
1923
+ lastRestartReason = 'corrupted Turbopack dev cache'
1924
+ writeDevSplashRuntimeRestarting(lastRestartReason)
1834
1925
  console.log('[server] Detected corrupted Turbopack dev cache. Clearing .mercato/next/dev and restarting Next.js once...')
1835
1926
  removeTurbopackDevCache(appDir)
1836
1927
  return resolve(await startNextDev(runtimeEnv))
@@ -1934,6 +2025,10 @@ export async function run(argv = process.argv) {
1934
2025
  }
1935
2026
 
1936
2027
  const firstExit = await Promise.race(managedExitPromises)
2028
+ if (isDevServerRestartResult(firstExit)) {
2029
+ lastRestartReason = `${firstExit.label.toLowerCase()} (${path.basename(firstExit.filePath)})`
2030
+ writeDevSplashRuntimeRestarting(lastRestartReason)
2031
+ }
1937
2032
  await cleanupAndWait()
1938
2033
  devRestartPromiseResolve = null
1939
2034
 
@@ -1950,7 +2045,6 @@ export async function run(argv = process.argv) {
1950
2045
  }
1951
2046
  } finally {
1952
2047
  stopEnvWatcher()
1953
- stopRuntimeWatcher()
1954
2048
  }
1955
2049
  },
1956
2050
  },