@open-mercato/cli 0.6.7-develop.6670.1.4efa7961c6 → 0.6.7-develop.6673.1.a9bf54a072
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -1
- package/dist/agentic/guides/module-facts.json +54 -54
- package/dist/agentic/guides/modules/ai_assistant.md +1 -1
- package/dist/agentic/guides/modules/api_docs.md +1 -1
- package/dist/agentic/guides/modules/api_keys.md +1 -1
- package/dist/agentic/guides/modules/attachments.md +1 -1
- package/dist/agentic/guides/modules/audit_logs.md +1 -1
- package/dist/agentic/guides/modules/auth.md +1 -1
- package/dist/agentic/guides/modules/business_rules.md +1 -1
- package/dist/agentic/guides/modules/catalog.md +1 -1
- package/dist/agentic/guides/modules/channel_gmail.md +1 -1
- package/dist/agentic/guides/modules/channel_imap.md +1 -1
- package/dist/agentic/guides/modules/checkout.md +1 -1
- package/dist/agentic/guides/modules/communication_channels.md +1 -1
- package/dist/agentic/guides/modules/configs.md +1 -1
- package/dist/agentic/guides/modules/content.md +1 -1
- package/dist/agentic/guides/modules/currencies.md +1 -1
- package/dist/agentic/guides/modules/customer_accounts.md +1 -1
- package/dist/agentic/guides/modules/customers.md +1 -1
- package/dist/agentic/guides/modules/dashboards.md +1 -1
- package/dist/agentic/guides/modules/data_sync.md +1 -1
- package/dist/agentic/guides/modules/dictionaries.md +1 -1
- package/dist/agentic/guides/modules/directory.md +1 -1
- package/dist/agentic/guides/modules/entities.md +1 -1
- package/dist/agentic/guides/modules/events.md +1 -1
- package/dist/agentic/guides/modules/feature_toggles.md +1 -1
- package/dist/agentic/guides/modules/gateway_stripe.md +1 -1
- package/dist/agentic/guides/modules/generators.md +1 -1
- package/dist/agentic/guides/modules/inbox_ops.md +1 -1
- package/dist/agentic/guides/modules/integrations.md +1 -1
- package/dist/agentic/guides/modules/messages.md +1 -1
- package/dist/agentic/guides/modules/notifications.md +1 -1
- package/dist/agentic/guides/modules/onboarding.md +1 -1
- package/dist/agentic/guides/modules/payment_gateways.md +1 -1
- package/dist/agentic/guides/modules/perspectives.md +1 -1
- package/dist/agentic/guides/modules/planner.md +1 -1
- package/dist/agentic/guides/modules/portal.md +1 -1
- package/dist/agentic/guides/modules/progress.md +1 -1
- package/dist/agentic/guides/modules/query_index.md +1 -1
- package/dist/agentic/guides/modules/record_locks.md +1 -1
- package/dist/agentic/guides/modules/resources.md +1 -1
- package/dist/agentic/guides/modules/sales.md +1 -1
- package/dist/agentic/guides/modules/scheduler.md +1 -1
- package/dist/agentic/guides/modules/search.md +1 -1
- package/dist/agentic/guides/modules/security.md +1 -1
- package/dist/agentic/guides/modules/shipping_carriers.md +1 -1
- package/dist/agentic/guides/modules/sso.md +1 -1
- package/dist/agentic/guides/modules/staff.md +1 -1
- package/dist/agentic/guides/modules/storage_s3.md +1 -1
- package/dist/agentic/guides/modules/sync_akeneo.md +1 -1
- package/dist/agentic/guides/modules/sync_excel.md +1 -1
- package/dist/agentic/guides/modules/system_status_overlays.md +1 -1
- package/dist/agentic/guides/modules/translations.md +1 -1
- package/dist/agentic/guides/modules/webhooks.md +1 -1
- package/dist/agentic/guides/modules/wms.md +1 -1
- package/dist/agentic/guides/modules/workflows.md +1 -1
- package/dist/lib/post-generate-invalidation.js +2 -2
- package/dist/lib/post-generate-invalidation.js.map +2 -2
- package/dist/mercato.js +15 -1
- package/dist/mercato.js.map +2 -2
- package/package.json +5 -5
- package/src/__tests__/mercato.test.ts +323 -1
- package/src/lib/__tests__/post-generate-invalidation.test.ts +24 -0
- package/src/lib/post-generate-invalidation.ts +5 -2
- package/src/mercato.ts +31 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/cli",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6673.1.a9bf54a072",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
"@mikro-orm/decorators": "^7.1.5",
|
|
61
61
|
"@mikro-orm/migrations": "^7.1.5",
|
|
62
62
|
"@mikro-orm/postgresql": "^7.1.5",
|
|
63
|
-
"@open-mercato/queue": "0.6.7-develop.
|
|
64
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
63
|
+
"@open-mercato/queue": "0.6.7-develop.6673.1.a9bf54a072",
|
|
64
|
+
"@open-mercato/shared": "0.6.7-develop.6673.1.a9bf54a072",
|
|
65
65
|
"cross-spawn": "^7.0.6",
|
|
66
66
|
"pg": "8.22.0",
|
|
67
67
|
"semver": "^7.8.5",
|
|
@@ -72,10 +72,10 @@
|
|
|
72
72
|
"typescript-js": "npm:typescript@6.0.3"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
75
|
+
"@open-mercato/shared": "0.6.7-develop.6673.1.a9bf54a072"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
78
|
+
"@open-mercato/shared": "0.6.7-develop.6673.1.a9bf54a072",
|
|
79
79
|
"@types/jest": "^30.0.0",
|
|
80
80
|
"jest": "^30.4.2",
|
|
81
81
|
"ts-jest": "^29.4.11"
|
|
@@ -37,7 +37,10 @@ function buildMockChildProcessModule(routeAutoExit: MockChildSpawnRouter) {
|
|
|
37
37
|
if (autoExit) {
|
|
38
38
|
queueMicrotask(() => {
|
|
39
39
|
if (child.exitCode !== null || child.signalCode !== null) return
|
|
40
|
-
|
|
40
|
+
// A routed non-zero exit models a crash during startup, so the child must
|
|
41
|
+
// never announce readiness first — that is what the cold-start retry keys on.
|
|
42
|
+
const crashesBeforeReady = typeof autoExit.code === 'number' && autoExit.code !== 0
|
|
43
|
+
if (!crashesBeforeReady && pathIncludes(spawnargs[1] ?? '', 'next/dist/bin/next') && spawnargs.includes('dev')) {
|
|
41
44
|
child.stdout.emit('data', '✓ Ready in 1ms\n')
|
|
42
45
|
}
|
|
43
46
|
queueMicrotask(() => {
|
|
@@ -868,6 +871,325 @@ describe('server dev managed process exits', () => {
|
|
|
868
871
|
consoleLogSpy.mockRestore()
|
|
869
872
|
})
|
|
870
873
|
|
|
874
|
+
it('retries the Next.js dev server once when it exits before reporting ready', async () => {
|
|
875
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
|
|
876
|
+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation()
|
|
877
|
+
process.env.AUTO_SPAWN_WORKERS = 'false'
|
|
878
|
+
|
|
879
|
+
jest.doMock('node:fs', () => {
|
|
880
|
+
const actual = jest.requireActual('node:fs')
|
|
881
|
+
return {
|
|
882
|
+
...actual,
|
|
883
|
+
existsSync: jest.fn((candidate: string) =>
|
|
884
|
+
pathIncludes(candidate, 'next/dist/bin/next') || pathIncludes(candidate, '@open-mercato/cli/bin/mercato'),
|
|
885
|
+
),
|
|
886
|
+
unlinkSync: jest.fn(),
|
|
887
|
+
}
|
|
888
|
+
})
|
|
889
|
+
jest.doMock('../lib/generators', () => ({
|
|
890
|
+
generateModulePackageSources: jest.fn().mockResolvedValue(undefined),
|
|
891
|
+
}))
|
|
892
|
+
jest.doMock('../lib/resolver', () => ({
|
|
893
|
+
resolveEnvironment: () => ({
|
|
894
|
+
appDir: '/tmp/test-app',
|
|
895
|
+
rootDir: '/tmp/test-root',
|
|
896
|
+
}),
|
|
897
|
+
createResolver: () => ({}),
|
|
898
|
+
}))
|
|
899
|
+
jest.doMock('child_process', () => {
|
|
900
|
+
const { EventEmitter } = jest.requireActual('node:events')
|
|
901
|
+
let nextSpawnCount = 0
|
|
902
|
+
|
|
903
|
+
const createChild = (autoExit?: { code: number | null; signal?: NodeJS.Signals | null; ready?: boolean }) => {
|
|
904
|
+
const child = new EventEmitter() as any
|
|
905
|
+
child.stdout = new EventEmitter()
|
|
906
|
+
child.stderr = new EventEmitter()
|
|
907
|
+
child.killed = false
|
|
908
|
+
child.exitCode = null
|
|
909
|
+
child.signalCode = null
|
|
910
|
+
child.kill = jest.fn(() => true)
|
|
911
|
+
|
|
912
|
+
if (autoExit) {
|
|
913
|
+
queueMicrotask(() => {
|
|
914
|
+
if (autoExit.ready) child.stdout.emit('data', '✓ Ready in 1ms\n')
|
|
915
|
+
queueMicrotask(() => {
|
|
916
|
+
child.exitCode = autoExit.code
|
|
917
|
+
child.signalCode = autoExit.signal ?? null
|
|
918
|
+
child.emit('exit', child.exitCode, child.signalCode)
|
|
919
|
+
})
|
|
920
|
+
})
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
return child
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
return {
|
|
927
|
+
spawn: jest.fn((_command: string, args: string[]) => {
|
|
928
|
+
if (pathIncludes(args[0] ?? '', 'next/dist/bin/next')) {
|
|
929
|
+
nextSpawnCount += 1
|
|
930
|
+
return nextSpawnCount === 1
|
|
931
|
+
? createChild({ code: 1 })
|
|
932
|
+
: createChild({ code: null, signal: 'SIGTERM', ready: true })
|
|
933
|
+
}
|
|
934
|
+
return createChild()
|
|
935
|
+
}),
|
|
936
|
+
}
|
|
937
|
+
})
|
|
938
|
+
|
|
939
|
+
const mercato = await import('../mercato')
|
|
940
|
+
const exitCode = await mercato.run(['node', 'mercato', 'server', 'dev'])
|
|
941
|
+
const { spawn } = await import('child_process')
|
|
942
|
+
const nextSpawns = (spawn as jest.Mock).mock.calls.filter((call) =>
|
|
943
|
+
pathIncludes(call[1]?.[0] ?? '', 'next/dist/bin/next'),
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
expect(exitCode).toBe(0)
|
|
947
|
+
expect(nextSpawns).toHaveLength(2)
|
|
948
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
949
|
+
'[server] Next.js dev server exited before becoming ready (exit code 1). Retrying once...',
|
|
950
|
+
)
|
|
951
|
+
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
|
952
|
+
|
|
953
|
+
consoleErrorSpy.mockRestore()
|
|
954
|
+
consoleLogSpy.mockRestore()
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
it('reports the failure when the retried Next.js dev server also exits before reporting ready', async () => {
|
|
958
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
|
|
959
|
+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation()
|
|
960
|
+
process.env.AUTO_SPAWN_WORKERS = 'false'
|
|
961
|
+
|
|
962
|
+
jest.doMock('node:fs', () => {
|
|
963
|
+
const actual = jest.requireActual('node:fs')
|
|
964
|
+
return {
|
|
965
|
+
...actual,
|
|
966
|
+
existsSync: jest.fn((candidate: string) =>
|
|
967
|
+
pathIncludes(candidate, 'next/dist/bin/next') || pathIncludes(candidate, '@open-mercato/cli/bin/mercato'),
|
|
968
|
+
),
|
|
969
|
+
unlinkSync: jest.fn(),
|
|
970
|
+
}
|
|
971
|
+
})
|
|
972
|
+
jest.doMock('../lib/generators', () => ({
|
|
973
|
+
generateModulePackageSources: jest.fn().mockResolvedValue(undefined),
|
|
974
|
+
}))
|
|
975
|
+
jest.doMock('../lib/resolver', () => ({
|
|
976
|
+
resolveEnvironment: () => ({
|
|
977
|
+
appDir: '/tmp/test-app',
|
|
978
|
+
rootDir: '/tmp/test-root',
|
|
979
|
+
}),
|
|
980
|
+
createResolver: () => ({}),
|
|
981
|
+
}))
|
|
982
|
+
jest.doMock('child_process', () =>
|
|
983
|
+
buildMockChildProcessModule((args) =>
|
|
984
|
+
pathIncludes(args[0] ?? '', 'next/dist/bin/next') ? { code: 1 } : undefined,
|
|
985
|
+
),
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
const mercato = await import('../mercato')
|
|
989
|
+
const exitCode = await mercato.run(['node', 'mercato', 'server', 'dev'])
|
|
990
|
+
const { spawn } = await import('child_process')
|
|
991
|
+
const nextSpawns = (spawn as jest.Mock).mock.calls.filter((call) =>
|
|
992
|
+
pathIncludes(call[1]?.[0] ?? '', 'next/dist/bin/next'),
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
expect(exitCode).toBe(1)
|
|
996
|
+
expect(nextSpawns).toHaveLength(2)
|
|
997
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
998
|
+
'💥 Failed: [server] Next.js dev server exited unexpectedly with exit code 1.',
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
consoleErrorSpy.mockRestore()
|
|
1002
|
+
consoleLogSpy.mockRestore()
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
it.each([
|
|
1006
|
+
['Failed to restore task data (corrupted database or bug)'],
|
|
1007
|
+
['Unable to open static sorted file'],
|
|
1008
|
+
['TurbopackInternalError'],
|
|
1009
|
+
])('clears the Turbopack dev cache and restarts once on the lone signature %s', async (signature) => {
|
|
1010
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
|
|
1011
|
+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation()
|
|
1012
|
+
process.env.AUTO_SPAWN_WORKERS = 'false'
|
|
1013
|
+
const rmSync = jest.fn()
|
|
1014
|
+
|
|
1015
|
+
jest.doMock('node:fs', () => {
|
|
1016
|
+
const actual = jest.requireActual('node:fs')
|
|
1017
|
+
return {
|
|
1018
|
+
...actual,
|
|
1019
|
+
existsSync: jest.fn((candidate: string) =>
|
|
1020
|
+
pathIncludes(candidate, 'next/dist/bin/next') || pathIncludes(candidate, '@open-mercato/cli/bin/mercato'),
|
|
1021
|
+
),
|
|
1022
|
+
unlinkSync: jest.fn(),
|
|
1023
|
+
rmSync,
|
|
1024
|
+
}
|
|
1025
|
+
})
|
|
1026
|
+
jest.doMock('../lib/generators', () => ({
|
|
1027
|
+
generateModulePackageSources: jest.fn().mockResolvedValue(undefined),
|
|
1028
|
+
}))
|
|
1029
|
+
jest.doMock('../lib/resolver', () => ({
|
|
1030
|
+
resolveEnvironment: () => ({
|
|
1031
|
+
appDir: '/tmp/test-app',
|
|
1032
|
+
rootDir: '/tmp/test-root',
|
|
1033
|
+
}),
|
|
1034
|
+
createResolver: () => ({}),
|
|
1035
|
+
}))
|
|
1036
|
+
jest.doMock('child_process', () => {
|
|
1037
|
+
const { EventEmitter } = jest.requireActual('node:events')
|
|
1038
|
+
let nextSpawnCount = 0
|
|
1039
|
+
|
|
1040
|
+
const createChild = (options?: { output?: string; code?: number | null; signal?: NodeJS.Signals | null; ready?: boolean }) => {
|
|
1041
|
+
const child = new EventEmitter() as any
|
|
1042
|
+
child.stdout = new EventEmitter()
|
|
1043
|
+
child.stderr = new EventEmitter()
|
|
1044
|
+
child.killed = false
|
|
1045
|
+
child.exitCode = null
|
|
1046
|
+
child.signalCode = null
|
|
1047
|
+
child.kill = jest.fn(() => true)
|
|
1048
|
+
|
|
1049
|
+
if (options) {
|
|
1050
|
+
queueMicrotask(() => {
|
|
1051
|
+
if (options.output) child.stderr.emit('data', options.output)
|
|
1052
|
+
if (options.ready) child.stdout.emit('data', '✓ Ready in 1ms\n')
|
|
1053
|
+
queueMicrotask(() => {
|
|
1054
|
+
child.exitCode = options.code ?? null
|
|
1055
|
+
child.signalCode = options.signal ?? null
|
|
1056
|
+
child.emit('exit', child.exitCode, child.signalCode)
|
|
1057
|
+
})
|
|
1058
|
+
})
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
return child
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
return {
|
|
1065
|
+
spawn: jest.fn((_command: string, args: string[]) => {
|
|
1066
|
+
if (pathIncludes(args[0] ?? '', 'next/dist/bin/next')) {
|
|
1067
|
+
nextSpawnCount += 1
|
|
1068
|
+
return nextSpawnCount === 1
|
|
1069
|
+
? createChild({ output: `⨯ ${signature}\n`, code: 1 })
|
|
1070
|
+
: createChild({ code: null, signal: 'SIGTERM', ready: true })
|
|
1071
|
+
}
|
|
1072
|
+
return createChild()
|
|
1073
|
+
}),
|
|
1074
|
+
}
|
|
1075
|
+
})
|
|
1076
|
+
|
|
1077
|
+
const mercato = await import('../mercato')
|
|
1078
|
+
const exitCode = await mercato.run(['node', 'mercato', 'server', 'dev'])
|
|
1079
|
+
const { spawn } = await import('child_process')
|
|
1080
|
+
const nextSpawns = (spawn as jest.Mock).mock.calls.filter((call) =>
|
|
1081
|
+
pathIncludes(call[1]?.[0] ?? '', 'next/dist/bin/next'),
|
|
1082
|
+
)
|
|
1083
|
+
const cacheRemovals = rmSync.mock.calls.filter(([target]) =>
|
|
1084
|
+
pathIncludes(String(target), '.mercato/next/dev'),
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
expect(exitCode).toBe(0)
|
|
1088
|
+
expect(nextSpawns).toHaveLength(2)
|
|
1089
|
+
expect(cacheRemovals).toHaveLength(1)
|
|
1090
|
+
expect(cacheRemovals[0][1]).toEqual({ recursive: true, force: true })
|
|
1091
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
1092
|
+
'[server] Detected corrupted Turbopack dev cache. Clearing .mercato/next/dev and restarting Next.js once...',
|
|
1093
|
+
)
|
|
1094
|
+
// Corruption recovery owns this exit; the generic cold-start retry must not also fire.
|
|
1095
|
+
expect(consoleLogSpy).not.toHaveBeenCalledWith(
|
|
1096
|
+
expect.stringContaining('exited before becoming ready'),
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
consoleErrorSpy.mockRestore()
|
|
1100
|
+
consoleLogSpy.mockRestore()
|
|
1101
|
+
})
|
|
1102
|
+
|
|
1103
|
+
it('takes the plain cold-start retry without purging the cache when no corruption signature is present', async () => {
|
|
1104
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
|
|
1105
|
+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation()
|
|
1106
|
+
process.env.AUTO_SPAWN_WORKERS = 'false'
|
|
1107
|
+
const rmSync = jest.fn()
|
|
1108
|
+
|
|
1109
|
+
jest.doMock('node:fs', () => {
|
|
1110
|
+
const actual = jest.requireActual('node:fs')
|
|
1111
|
+
return {
|
|
1112
|
+
...actual,
|
|
1113
|
+
existsSync: jest.fn((candidate: string) =>
|
|
1114
|
+
pathIncludes(candidate, 'next/dist/bin/next') || pathIncludes(candidate, '@open-mercato/cli/bin/mercato'),
|
|
1115
|
+
),
|
|
1116
|
+
unlinkSync: jest.fn(),
|
|
1117
|
+
rmSync,
|
|
1118
|
+
}
|
|
1119
|
+
})
|
|
1120
|
+
jest.doMock('../lib/generators', () => ({
|
|
1121
|
+
generateModulePackageSources: jest.fn().mockResolvedValue(undefined),
|
|
1122
|
+
}))
|
|
1123
|
+
jest.doMock('../lib/resolver', () => ({
|
|
1124
|
+
resolveEnvironment: () => ({
|
|
1125
|
+
appDir: '/tmp/test-app',
|
|
1126
|
+
rootDir: '/tmp/test-root',
|
|
1127
|
+
}),
|
|
1128
|
+
createResolver: () => ({}),
|
|
1129
|
+
}))
|
|
1130
|
+
jest.doMock('child_process', () => {
|
|
1131
|
+
const { EventEmitter } = jest.requireActual('node:events')
|
|
1132
|
+
let nextSpawnCount = 0
|
|
1133
|
+
|
|
1134
|
+
const createChild = (options?: { output?: string; code?: number | null; signal?: NodeJS.Signals | null; ready?: boolean }) => {
|
|
1135
|
+
const child = new EventEmitter() as any
|
|
1136
|
+
child.stdout = new EventEmitter()
|
|
1137
|
+
child.stderr = new EventEmitter()
|
|
1138
|
+
child.killed = false
|
|
1139
|
+
child.exitCode = null
|
|
1140
|
+
child.signalCode = null
|
|
1141
|
+
child.kill = jest.fn(() => true)
|
|
1142
|
+
|
|
1143
|
+
if (options) {
|
|
1144
|
+
queueMicrotask(() => {
|
|
1145
|
+
if (options.output) child.stderr.emit('data', options.output)
|
|
1146
|
+
if (options.ready) child.stdout.emit('data', '✓ Ready in 1ms\n')
|
|
1147
|
+
queueMicrotask(() => {
|
|
1148
|
+
child.exitCode = options.code ?? null
|
|
1149
|
+
child.signalCode = options.signal ?? null
|
|
1150
|
+
child.emit('exit', child.exitCode, child.signalCode)
|
|
1151
|
+
})
|
|
1152
|
+
})
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
return child
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
return {
|
|
1159
|
+
spawn: jest.fn((_command: string, args: string[]) => {
|
|
1160
|
+
if (pathIncludes(args[0] ?? '', 'next/dist/bin/next')) {
|
|
1161
|
+
nextSpawnCount += 1
|
|
1162
|
+
return nextSpawnCount === 1
|
|
1163
|
+
? createChild({ output: 'Another next dev server is already running.\n', code: 1 })
|
|
1164
|
+
: createChild({ code: null, signal: 'SIGTERM', ready: true })
|
|
1165
|
+
}
|
|
1166
|
+
return createChild()
|
|
1167
|
+
}),
|
|
1168
|
+
}
|
|
1169
|
+
})
|
|
1170
|
+
|
|
1171
|
+
const mercato = await import('../mercato')
|
|
1172
|
+
const exitCode = await mercato.run(['node', 'mercato', 'server', 'dev'])
|
|
1173
|
+
const { spawn } = await import('child_process')
|
|
1174
|
+
const nextSpawns = (spawn as jest.Mock).mock.calls.filter((call) =>
|
|
1175
|
+
pathIncludes(call[1]?.[0] ?? '', 'next/dist/bin/next'),
|
|
1176
|
+
)
|
|
1177
|
+
const cacheRemovals = rmSync.mock.calls.filter(([target]) =>
|
|
1178
|
+
pathIncludes(String(target), '.mercato/next/dev'),
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
expect(exitCode).toBe(0)
|
|
1182
|
+
expect(nextSpawns).toHaveLength(2)
|
|
1183
|
+
// A lock conflict is not cache corruption — the dev cache must survive.
|
|
1184
|
+
expect(cacheRemovals).toHaveLength(0)
|
|
1185
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
1186
|
+
'[server] Next.js dev server exited before becoming ready (exit code 1). Retrying once...',
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
consoleErrorSpy.mockRestore()
|
|
1190
|
+
consoleLogSpy.mockRestore()
|
|
1191
|
+
})
|
|
1192
|
+
|
|
871
1193
|
it('starts the lazy worker supervisor instead of `queue worker --all` when OM_AUTO_SPAWN_WORKERS_LAZY=true', async () => {
|
|
872
1194
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
|
|
873
1195
|
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation()
|
|
@@ -47,6 +47,30 @@ describe('runPostGenerateStructuralInvalidation', () => {
|
|
|
47
47
|
}
|
|
48
48
|
})
|
|
49
49
|
|
|
50
|
+
it('advances mtime without reopening generated artifacts for writing', async () => {
|
|
51
|
+
const appDir = fs.mkdtempSync(path.join(os.tmpdir(), 'post-generate-invalidation-mtime-'))
|
|
52
|
+
try {
|
|
53
|
+
const generatedDir = path.join(appDir, '.mercato', 'generated')
|
|
54
|
+
fs.mkdirSync(generatedDir, { recursive: true })
|
|
55
|
+
const generatedTs = path.join(generatedDir, 'modules.generated.ts')
|
|
56
|
+
fs.writeFileSync(generatedTs, 'export const modules = []\n')
|
|
57
|
+
const staleTime = new Date(Date.now() - 60_000)
|
|
58
|
+
fs.utimesSync(generatedTs, staleTime, staleTime)
|
|
59
|
+
const before = fs.statSync(generatedTs)
|
|
60
|
+
|
|
61
|
+
await runPostGenerateStructuralInvalidation(appDir)
|
|
62
|
+
|
|
63
|
+
const after = fs.statSync(generatedTs)
|
|
64
|
+
expect(after.mtimeMs).toBeGreaterThan(before.mtimeMs)
|
|
65
|
+
expect(after.size).toBe(before.size)
|
|
66
|
+
// ctime advances on a metadata-only touch; a rewrite would also bump the
|
|
67
|
+
// inode's write generation, so assert the content survived byte for byte.
|
|
68
|
+
expect(fs.readFileSync(generatedTs, 'utf8')).toBe('export const modules = []\n')
|
|
69
|
+
} finally {
|
|
70
|
+
fs.rmSync(appDir, { recursive: true, force: true })
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
50
74
|
it('still refreshes generated artifacts when cache maintenance fails', async () => {
|
|
51
75
|
const appDir = fs.mkdtempSync(path.join(os.tmpdir(), 'post-generate-invalidation-failure-'))
|
|
52
76
|
const error = new Error('cache unavailable')
|
|
@@ -21,13 +21,16 @@ function touchGeneratedFiles(appDir: string): string[] {
|
|
|
21
21
|
if (!fs.existsSync(generatedDir) || !fs.statSync(generatedDir).isDirectory()) return []
|
|
22
22
|
|
|
23
23
|
const touched: string[] = []
|
|
24
|
+
// `utimes` advances mtime without opening the file for writing. Rewriting the
|
|
25
|
+
// identical bytes would truncate multi-megabyte registries first, and Turbopack
|
|
26
|
+
// reads them concurrently during a dev compile — it can observe the empty window.
|
|
27
|
+
const touchedAt = new Date()
|
|
24
28
|
const entries = fs.readdirSync(generatedDir, { withFileTypes: true })
|
|
25
29
|
.sort((left, right) => left.name.localeCompare(right.name))
|
|
26
30
|
for (const entry of entries) {
|
|
27
31
|
if (!entry.isFile() || !TOUCHABLE_GENERATED_FILE_PATTERN.test(entry.name)) continue
|
|
28
32
|
const filePath = path.join(generatedDir, entry.name)
|
|
29
|
-
|
|
30
|
-
fs.writeFileSync(filePath, contents)
|
|
33
|
+
fs.utimesSync(filePath, touchedAt, touchedAt)
|
|
31
34
|
touched.push(filePath)
|
|
32
35
|
}
|
|
33
36
|
return touched
|
package/src/mercato.ts
CHANGED
|
@@ -100,6 +100,11 @@ const TURBOPACK_CORRUPTION_PATTERNS = [
|
|
|
100
100
|
'TurbopackInternalError',
|
|
101
101
|
]
|
|
102
102
|
|
|
103
|
+
// Next.js gives up on `.mercato/next/dev/lock` after 1000ms, so a dev server
|
|
104
|
+
// that lost a startup race against a still-shutting-down sibling needs a
|
|
105
|
+
// slightly longer pause before the retry can win the lock.
|
|
106
|
+
const DEV_COLD_START_RETRY_DELAY_MS = 1500
|
|
107
|
+
|
|
103
108
|
const BUILTIN_CLI_MODULE_IDS = new Set(['queue', 'generate', 'deploy', 'db', 'server', 'test'])
|
|
104
109
|
|
|
105
110
|
function collectNestedErrors(error: unknown, seen = new Set<unknown>()): ErrorWithCause[] {
|
|
@@ -264,7 +269,7 @@ async function ensureDatabaseExists(dbUrl: string): Promise<boolean> {
|
|
|
264
269
|
}
|
|
265
270
|
|
|
266
271
|
function isTurbopackCacheCorruption(output: string): boolean {
|
|
267
|
-
return TURBOPACK_CORRUPTION_PATTERNS.
|
|
272
|
+
return TURBOPACK_CORRUPTION_PATTERNS.some((pattern) => output.includes(pattern))
|
|
268
273
|
}
|
|
269
274
|
|
|
270
275
|
function removeTurbopackDevCache(appDir: string): void {
|
|
@@ -1960,6 +1965,7 @@ export async function run(argv = process.argv) {
|
|
|
1960
1965
|
|
|
1961
1966
|
let processes: ChildProcess[] = []
|
|
1962
1967
|
let didRetryCorruptedTurbopackCache = false
|
|
1968
|
+
let didRetryFailedColdStart = false
|
|
1963
1969
|
let stopping = false
|
|
1964
1970
|
let devRestartPromiseResolve: ((result: DevServerRestartResult) => void) | null = null
|
|
1965
1971
|
let activeLazySupervisor: ReturnType<typeof startLazyWorkerSupervisor> | null = null
|
|
@@ -2122,6 +2128,30 @@ export async function run(argv = process.argv) {
|
|
|
2122
2128
|
restarted.readyPromise.then(readyResolve)
|
|
2123
2129
|
return resolve(await restarted.exitPromise)
|
|
2124
2130
|
}
|
|
2131
|
+
// A dev server that dies before it ever reported "ready in" lost a
|
|
2132
|
+
// startup race rather than crashing on user code: a sibling instance
|
|
2133
|
+
// still holding the dev lock, a port that is about to free up, or a
|
|
2134
|
+
// bundler init hiccup on a cold cache. Those all clear on their own,
|
|
2135
|
+
// which is why a second `yarn dev` usually succeeds — retry once here
|
|
2136
|
+
// so the first run succeeds too.
|
|
2137
|
+
if (
|
|
2138
|
+
!didRetryFailedColdStart
|
|
2139
|
+
&& !reportedReady
|
|
2140
|
+
&& !stopping
|
|
2141
|
+
&& typeof code === 'number'
|
|
2142
|
+
&& code !== 0
|
|
2143
|
+
) {
|
|
2144
|
+
didRetryFailedColdStart = true
|
|
2145
|
+
lastRestartReason = 'a failed cold start'
|
|
2146
|
+
writeDevSplashRuntimeRestarting(lastRestartReason)
|
|
2147
|
+
console.log(`[server] Next.js dev server exited before becoming ready (exit code ${code}). Retrying once...`)
|
|
2148
|
+
await new Promise((wake) => setTimeout(wake, DEV_COLD_START_RETRY_DELAY_MS))
|
|
2149
|
+
if (!stopping) {
|
|
2150
|
+
const restarted = startNextDev(runtimeEnv)
|
|
2151
|
+
restarted.readyPromise.then(readyResolve)
|
|
2152
|
+
return resolve(await restarted.exitPromise)
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2125
2155
|
resolve({
|
|
2126
2156
|
label: 'Next.js dev server',
|
|
2127
2157
|
code,
|