@open-mercato/shared 0.6.7-develop.6860.1.f796b22e80 → 0.6.7-develop.6862.1.c11a64ce0a

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.
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.7-develop.6860.1.f796b22e80";
1
+ const APP_VERSION = "0.6.7-develop.6862.1.c11a64ce0a";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.6860.1.f796b22e80';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.6862.1.c11a64ce0a';\nexport const appVersion = APP_VERSION;\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.6.7-develop.6860.1.f796b22e80",
3
+ "version": "0.6.7-develop.6862.1.c11a64ce0a",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -105,7 +105,7 @@
105
105
  "@mikro-orm/core": "^7.1.8",
106
106
  "@mikro-orm/decorators": "^7.1.8",
107
107
  "@mikro-orm/postgresql": "^7.1.8",
108
- "@open-mercato/cache": "0.6.7-develop.6860.1.f796b22e80",
108
+ "@open-mercato/cache": "0.6.7-develop.6862.1.c11a64ce0a",
109
109
  "@types/sanitize-html": "^2.16.1",
110
110
  "dotenv": "^17.4.2",
111
111
  "pino": "^10.3.1",
@@ -29,21 +29,73 @@
29
29
  * and then rejects with the error Node raises for a v6 decorator import, which
30
30
  * is what the import-time recovery is meant to repair.
31
31
  *
32
- * The assertions stop at the recovery boundary on purpose. Jest's module
33
- * registry keeps serving a file it has already evaluated, so the guarded retry
34
- * re-runs the rejecting module from memory no matter what recovery wrote to
35
- * disk — the outcome of the retry is a property of the test runner, not of the
36
- * loader. What #4526 broke, and what this guards, is that the catch runs at
37
- * all: before the fix the rejection escaped the try, so no import-time recovery
38
- * ever happened.
32
+ * Every loadBootstrapData call runs in a child process (#4960). Driving the real
33
+ * loader in-process made this suite flaky at ~30-50%: a load that aborts part way
34
+ * leaves the sibling compileAndImport calls of its Promise.all still running, and
35
+ * those in-flight esbuild builds rewrite the very .mjs files the fixture stages
36
+ * between passes, so priming was a race rather than a loop. A child process also
37
+ * gives each load a real Node module registry, so esbuild's ESM output imports
38
+ * natively and the fixture no longer has to stage CommonJS stand-ins for it.
39
+ *
40
+ * Because the child is a real Node process, the guarded retry now completes for
41
+ * real: recovery deletes the stale cache, the loader recompiles from the .ts
42
+ * sources and the load succeeds. That end state is asserted too — under the old
43
+ * in-process fixture it was unreachable, because the runner kept serving the
44
+ * rejecting module from memory no matter what recovery wrote to disk.
39
45
  */
46
+ import { spawnSync } from 'node:child_process'
40
47
  import crypto from 'node:crypto'
41
48
  import fs from 'node:fs'
49
+ import { createRequire } from 'node:module'
42
50
  import os from 'node:os'
43
51
  import path from 'node:path'
44
- import { loadBootstrapData } from '../dynamicLoader'
45
52
 
46
53
  const STALE_SIBLING_BASENAME = 'stale-entity.generated.mjs'
54
+ const RESULT_MARKER = '__BOOTSTRAP_RUN_RESULT__'
55
+ /**
56
+ * The Jest budget deliberately exceeds the spawn budget, so a wedged child is
57
+ * reported as this file's own "runner produced no result" error — with the
58
+ * child's output attached — rather than as an opaque Jest timeout.
59
+ */
60
+ const CHILD_SPAWN_TIMEOUT_MS = 120_000
61
+ const CHILD_RUN_TIMEOUT_MS = 180_000
62
+
63
+ const LOADER_PATH = path.resolve(__dirname, '..', 'dynamicLoader.ts')
64
+
65
+ /**
66
+ * Resolved through a real `require` rather than Jest's resolver: this path is
67
+ * handed to a child process, so it must be the location Node itself would load.
68
+ */
69
+ const TSX_CLI_PATH = createRequire(__filename).resolve('tsx/cli')
70
+
71
+ /**
72
+ * Runs one loadBootstrapData against the fixture app root and reports the
73
+ * outcome on stdout behind a marker, so the loader's own logging (and the
74
+ * recovery module's console.warn banner) cannot be mistaken for the result.
75
+ */
76
+ const RUNNER_SOURCE = [
77
+ "import { pathToFileURL } from 'node:url'",
78
+ '',
79
+ 'const [loaderPath, appRoot] = process.argv.slice(2)',
80
+ '',
81
+ 'let result',
82
+ 'try {',
83
+ ' const loader = await import(pathToFileURL(loaderPath).href)',
84
+ ' await loader.loadBootstrapData(appRoot)',
85
+ ' result = { loaded: true }',
86
+ '} catch (error) {',
87
+ ' result = { loaded: false, message: error instanceof Error ? error.message : String(error) }',
88
+ '}',
89
+ '',
90
+ `process.stdout.write('\\n' + ${JSON.stringify(RESULT_MARKER)} + JSON.stringify(result))`,
91
+ ].join('\n')
92
+
93
+ /**
94
+ * Split so the literal never appears in this file: the repository's Jest
95
+ * transformer rewrites every `import.meta` occurrence to a CommonJS stub before
96
+ * ts-jest sees the source, including ones inside a string.
97
+ */
98
+ const IMPORT_META_URL_EXPRESSION = ['import', '.meta.url'].join('')
47
99
 
48
100
  /**
49
101
  * The decorator import is assembled at runtime rather than written literally:
@@ -52,35 +104,37 @@ const STALE_SIBLING_BASENAME = 'stale-entity.generated.mjs'
52
104
  * the import-time path under test would never be exercised.
53
105
  */
54
106
  const REJECTING_COMPILED_SOURCE = [
55
- "const nodeFs = require('node:fs')",
56
- "const nodePath = require('node:path')",
57
- "const quote = String.fromCharCode(39)",
107
+ "import nodeFs from 'node:fs'",
108
+ "import nodePath from 'node:path'",
109
+ "import { fileURLToPath } from 'node:url'",
110
+ 'const quote = String.fromCharCode(39)',
58
111
  "const staleImport = 'import { Entity, PrimaryKey } from ' + quote + '@mikro-orm/core' + quote",
59
- `nodeFs.writeFileSync(nodePath.join(__dirname, ${JSON.stringify(STALE_SIBLING_BASENAME)}), staleImport + '\\nexport const stale = true\\n')`,
112
+ `const generatedDirPath = fileURLToPath(new URL('.', ${IMPORT_META_URL_EXPRESSION}))`,
113
+ `nodeFs.writeFileSync(nodePath.join(generatedDirPath, ${JSON.stringify(STALE_SIBLING_BASENAME)}), staleImport + '\\nexport const stale = true\\n')`,
60
114
  `throw new Error('The requested module ' + quote + '@mikro-orm/core' + quote + ' does not provide an export named ' + quote + 'Entity' + quote)`,
61
115
  ].join('\n')
62
116
 
63
117
  /**
64
- * Jest resolves the loader's dynamic import through its own CommonJS registry,
65
- * so a staged .mjs has to be CommonJS to evaluate at all — esbuild's real ESM
66
- * output fails under the runner with "Unexpected token 'export'". The .ts
67
- * sources stay honest ESM: they are what the loader recompiles from once
68
- * recovery has deleted the cache.
118
+ * The generated sources stay honest ESM. The loader compiles them with esbuild
119
+ * and the child process imports that output natively, so nothing here has to
120
+ * model the compiled shape — the loader's real output is what gets imported.
69
121
  */
70
- const GENERATED_MODULES: Record<string, { ts: string; compiled: string }> = {
71
- 'entities.ids.generated': { ts: 'export const E = {}', compiled: 'module.exports = { E: {} }' },
72
- 'modules.cli.generated': { ts: 'export const modules = []', compiled: 'module.exports = { modules: [] }' },
73
- 'entities.generated': { ts: 'export const entities = []', compiled: 'module.exports = { entities: [] }' },
74
- 'di.generated': { ts: 'export const diRegistrars = []', compiled: 'module.exports = { diRegistrars: [] }' },
122
+ const GENERATED_MODULES: Record<string, string> = {
123
+ 'entities.ids.generated': 'export const E = {}',
124
+ 'modules.cli.generated': 'export const modules = []',
125
+ 'entities.generated': 'export const entities = []',
126
+ 'di.generated': 'export const diRegistrars = []',
75
127
  }
76
128
 
129
+ type BootstrapRunResult = { loaded: boolean; message?: string }
130
+
77
131
  function contentHash(content: Buffer | string): string {
78
132
  return crypto.createHash('sha256').update(content).digest('hex')
79
133
  }
80
134
 
81
135
  /**
82
- * Replace a compiled cache entry with staged content the runner can evaluate,
83
- * keeping the loader's own cache metadata authoritative.
136
+ * Replace a compiled cache entry with staged content, keeping the loader's own
137
+ * cache metadata authoritative.
84
138
  *
85
139
  * The loader validates a cache entry by hashing (#4724): it recompiles unless
86
140
  * the sibling `.cache.json` matches both the source and the compiled output.
@@ -99,79 +153,87 @@ function stageCompiledCache(generatedDir: string, baseName: string, compiled: st
99
153
  fs.writeFileSync(metadataPath, JSON.stringify(metadata))
100
154
  }
101
155
 
102
- function hasCacheMetadata(generatedDir: string, baseName: string): boolean {
103
- return fs.existsSync(path.join(generatedDir, `${baseName}.mjs.cache.json`))
104
- }
105
-
106
156
  describe('compileAndImport — generated-cache recovery is reachable (#4526)', () => {
157
+ let runnerDir: string
158
+ let runnerPath: string
159
+ let templateRoot: string
107
160
  let appRoot: string
108
161
  let generatedDir: string
109
- let warnSpy: jest.SpyInstance
110
162
 
111
- /**
112
- * Let the loader compile the generated sources so it writes real cache
113
- * metadata for each of them, replacing each compiled output with the
114
- * runner-evaluable staged content as it appears.
115
- *
116
- * The loader writes a module's metadata before importing it, so an import
117
- * failing here (esbuild's ESM output under Jest's CommonJS registry) is
118
- * expected and irrelevant — the metadata is the only thing this step is
119
- * after. That failure does abort the rest of the load, though, so one pass
120
- * only reaches the modules the loader got to: entities.ids first and alone,
121
- * then the remainder together. Repeating the pass lets each newly staged
122
- * module unblock the next batch, and the loop ends on the first pass that
123
- * loads cleanly, which is precisely when every module is staged.
124
- *
125
- * jest.resetModules() between passes is load-bearing. Jest keys its registry
126
- * on the resolved path and ignores the loader's `?cache=` query, so without
127
- * the reset a module would keep being served from its first (ESM, failing)
128
- * evaluation and no later pass could make progress.
129
- *
130
- * None of these failures carries a decorator-export error, so no recovery
131
- * runs and no marker is written during setup; both tests below would fail
132
- * loudly if one were.
133
- */
134
- async function primeAndStageCompiledCaches() {
135
- const baseNames = Object.keys(GENERATED_MODULES)
136
- for (let pass = 0; pass <= baseNames.length; pass += 1) {
137
- let loadedCleanly = true
138
- await loadBootstrapData(appRoot).catch(() => { loadedCleanly = false })
139
- jest.resetModules()
140
- if (loadedCleanly) return
141
- for (const baseName of baseNames) {
142
- if (!hasCacheMetadata(generatedDir, baseName)) continue
143
- stageCompiledCache(generatedDir, baseName, GENERATED_MODULES[baseName].compiled)
144
- }
163
+ function runLoaderInChildProcess(target: string): BootstrapRunResult {
164
+ const run = spawnSync(process.execPath, [TSX_CLI_PATH, runnerPath, LOADER_PATH, target], {
165
+ encoding: 'utf8',
166
+ timeout: CHILD_SPAWN_TIMEOUT_MS,
167
+ env: { ...process.env, OM_LOG_LEVEL: 'error' },
168
+ })
169
+
170
+ const markerIndex = (run.stdout ?? '').lastIndexOf(RESULT_MARKER)
171
+ if (markerIndex === -1) {
172
+ throw new Error(
173
+ `[internal] bootstrap runner reported no result (status ${run.status})\n`
174
+ + `stdout: ${run.stdout}\nstderr: ${run.stderr}`,
175
+ )
145
176
  }
146
- throw new Error('[internal] loader never reached a fully staged generated cache')
177
+
178
+ return JSON.parse(run.stdout.slice(markerIndex + RESULT_MARKER.length)) as BootstrapRunResult
147
179
  }
148
180
 
149
- beforeEach(async () => {
150
- appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'om-bootstrap-4526-'))
151
- generatedDir = path.join(appRoot, '.mercato', 'generated')
152
- fs.mkdirSync(generatedDir, { recursive: true })
181
+ function createFixtureAppRoot(prefix: string): string {
182
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
183
+ fs.mkdirSync(path.join(root, '.mercato', 'generated'), { recursive: true })
153
184
  fs.writeFileSync(
154
- path.join(appRoot, 'tsconfig.json'),
185
+ path.join(root, 'tsconfig.json'),
155
186
  JSON.stringify({ compilerOptions: { target: 'ES2022', module: 'ESNext' } }),
156
187
  )
157
188
  for (const [baseName, source] of Object.entries(GENERATED_MODULES)) {
158
- fs.writeFileSync(path.join(generatedDir, `${baseName}.ts`), source.ts)
189
+ fs.writeFileSync(path.join(root, '.mercato', 'generated', `${baseName}.ts`), source)
190
+ }
191
+ return root
192
+ }
193
+
194
+ /**
195
+ * Prime once: a single child-process load compiles every generated source and
196
+ * writes the loader's real cache metadata for it. The primed tree is copied
197
+ * per test rather than re-primed — the cache records source and tsconfig
198
+ * hashes plus app-root-relative dependency paths, so it stays valid under a
199
+ * different root.
200
+ */
201
+ beforeAll(() => {
202
+ runnerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'om-bootstrap-4526-runner-'))
203
+ runnerPath = path.join(runnerDir, 'load-bootstrap-data.mjs')
204
+ fs.writeFileSync(runnerPath, RUNNER_SOURCE)
205
+
206
+ templateRoot = createFixtureAppRoot('om-bootstrap-4526-template-')
207
+ const primed = runLoaderInChildProcess(templateRoot)
208
+ if (!primed.loaded) {
209
+ throw new Error(`[internal] fixture priming failed: ${primed.message}`)
210
+ }
211
+ if (fs.existsSync(path.join(templateRoot, '.mercato', 'generated', '.mikro-orm-v7-cache-recovery.json'))) {
212
+ throw new Error('[internal] fixture priming triggered cache recovery')
159
213
  }
160
- warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
214
+ }, CHILD_RUN_TIMEOUT_MS)
161
215
 
162
- await primeAndStageCompiledCaches()
216
+ afterAll(() => {
217
+ for (const directory of [templateRoot, runnerDir]) {
218
+ if (directory) fs.rmSync(directory, { recursive: true, force: true })
219
+ }
220
+ })
221
+
222
+ beforeEach(() => {
223
+ appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'om-bootstrap-4526-'))
224
+ fs.cpSync(templateRoot, appRoot, { recursive: true })
225
+ generatedDir = path.join(appRoot, '.mercato', 'generated')
163
226
  })
164
227
 
165
228
  afterEach(() => {
166
- warnSpy.mockRestore()
167
229
  fs.rmSync(appRoot, { recursive: true, force: true })
168
230
  })
169
231
 
170
- it('runs cache recovery when the compiled cache rejects on import', async () => {
232
+ it('runs cache recovery when the compiled cache rejects on import', () => {
171
233
  const rejectingPath = path.join(generatedDir, 'entities.ids.generated.mjs')
172
234
  stageCompiledCache(generatedDir, 'entities.ids.generated', REJECTING_COMPILED_SOURCE)
173
235
 
174
- await loadBootstrapData(appRoot).catch(() => undefined)
236
+ const run = runLoaderInChildProcess(appRoot)
175
237
 
176
238
  const markerPath = path.join(generatedDir, '.mikro-orm-v7-cache-recovery.json')
177
239
  expect(fs.existsSync(markerPath)).toBe(true)
@@ -182,11 +244,13 @@ describe('compileAndImport — generated-cache recovery is reachable (#4526)', (
182
244
  expect(marker.deletedFiles).toContain(path.join(generatedDir, STALE_SIBLING_BASENAME))
183
245
 
184
246
  expect(fs.readFileSync(rejectingPath, 'utf8')).not.toContain('does not provide an export named')
185
- })
247
+ expect(run).toEqual({ loaded: true })
248
+ }, CHILD_RUN_TIMEOUT_MS)
186
249
 
187
- it('does not run recovery when the compiled cache imports cleanly', async () => {
188
- await loadBootstrapData(appRoot)
250
+ it('does not run recovery when the compiled cache imports cleanly', () => {
251
+ const run = runLoaderInChildProcess(appRoot)
189
252
 
253
+ expect(run).toEqual({ loaded: true })
190
254
  expect(fs.existsSync(path.join(generatedDir, '.mikro-orm-v7-cache-recovery.json'))).toBe(false)
191
- })
255
+ }, CHILD_RUN_TIMEOUT_MS)
192
256
  })