@open-mercato/shared 0.9.1-develop.7477.1.64ba865101 → 0.9.1-develop.7496.1.3346415f94
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/dist/lib/bootstrap/dynamicLoader.js +46 -6
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/crud/ids.js +13 -1
- package/dist/lib/crud/ids.js.map +2 -2
- package/dist/lib/openapi/generator.js +84 -67
- package/dist/lib/openapi/generator.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/registry.js.map +2 -2
- package/package.json +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.appModuleOverrides.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.cacheRecovery.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.externalSubpath.test.ts +141 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +2 -2
- package/src/lib/bootstrap/dynamicLoader.ts +63 -5
- package/src/lib/crud/__tests__/ids.test.ts +34 -1
- package/src/lib/crud/ids.ts +24 -0
- package/src/lib/openapi/__tests__/optional-path-params.test.ts +102 -0
- package/src/lib/openapi/generator.ts +106 -77
- package/src/modules/registry.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.9.1-develop.
|
|
3
|
+
"version": "0.9.1-develop.7496.1.3346415f94",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
"@mikro-orm/core": "^7.1.14",
|
|
122
122
|
"@mikro-orm/decorators": "^7.1.14",
|
|
123
123
|
"@mikro-orm/postgresql": "^7.1.14",
|
|
124
|
-
"@open-mercato/cache": "0.9.1-develop.
|
|
124
|
+
"@open-mercato/cache": "0.9.1-develop.7496.1.3346415f94",
|
|
125
125
|
"@types/html-to-text": "^9.0.4",
|
|
126
126
|
"@types/sanitize-html": "^2.16.1",
|
|
127
127
|
"dotenv": "^17.4.2",
|
|
@@ -91,9 +91,9 @@ function writeCompiledPair(
|
|
|
91
91
|
fs.writeFileSync(compiledPath, source.compiled)
|
|
92
92
|
const sourceRelativePath = path.relative(appRoot, sourcePath).split(path.sep).join('/')
|
|
93
93
|
fs.writeFileSync(`${compiledPath}.cache.json`, JSON.stringify({
|
|
94
|
-
version:
|
|
94
|
+
version: 5,
|
|
95
95
|
inputHash: hash(JSON.stringify({
|
|
96
|
-
version:
|
|
96
|
+
version: 5,
|
|
97
97
|
sourceHash: hash(source.ts),
|
|
98
98
|
tsconfigHashes: {
|
|
99
99
|
'tsconfig.json': hash(APP_TSCONFIG),
|
|
@@ -132,9 +132,9 @@ function writeCompiledPair(
|
|
|
132
132
|
fs.writeFileSync(compiledPath, source.compiled)
|
|
133
133
|
const sourceRelativePath = path.relative(appRoot, sourcePath).split(path.sep).join('/')
|
|
134
134
|
fs.writeFileSync(`${compiledPath}.cache.json`, JSON.stringify({
|
|
135
|
-
version:
|
|
135
|
+
version: 5,
|
|
136
136
|
inputHash: hash(JSON.stringify({
|
|
137
|
-
version:
|
|
137
|
+
version: 5,
|
|
138
138
|
sourceHash: hash(source.ts),
|
|
139
139
|
tsconfigHashes: {
|
|
140
140
|
'tsconfig.json': hash(APP_TSCONFIG),
|
|
@@ -77,7 +77,7 @@ function writeGeneratedModule(generatedDir: string, baseName: string, source: {
|
|
|
77
77
|
function writeCompiledSibling(generatedDir: string, baseName: string, compiled: string) {
|
|
78
78
|
const source = fs.readFileSync(path.join(generatedDir, `${baseName}.ts`), 'utf8')
|
|
79
79
|
const inputHash = hash(JSON.stringify({
|
|
80
|
-
version:
|
|
80
|
+
version: 5,
|
|
81
81
|
sourceHash: hash(source),
|
|
82
82
|
tsconfigHashes: {
|
|
83
83
|
'tsconfig.json': hash(APP_TSCONFIG),
|
|
@@ -90,7 +90,7 @@ function writeCompiledSibling(generatedDir: string, baseName: string, compiled:
|
|
|
90
90
|
const compiledPath = path.join(generatedDir, `${baseName}.mjs`)
|
|
91
91
|
fs.writeFileSync(compiledPath, compiled)
|
|
92
92
|
fs.writeFileSync(`${compiledPath}.cache.json`, JSON.stringify({
|
|
93
|
-
version:
|
|
93
|
+
version: 5,
|
|
94
94
|
inputHash,
|
|
95
95
|
outputHash: hash(compiled),
|
|
96
96
|
dependencies: {
|
|
@@ -77,9 +77,9 @@ function writeGeneratedModule(generatedDir: string, baseName: string, source: {
|
|
|
77
77
|
fs.writeFileSync(sourcePath, source.ts)
|
|
78
78
|
fs.writeFileSync(compiledPath, source.compiled)
|
|
79
79
|
fs.writeFileSync(`${compiledPath}.cache.json`, JSON.stringify({
|
|
80
|
-
version:
|
|
80
|
+
version: 5,
|
|
81
81
|
inputHash: hash(JSON.stringify({
|
|
82
|
-
version:
|
|
82
|
+
version: 5,
|
|
83
83
|
sourceHash: hash(source.ts),
|
|
84
84
|
tsconfigHashes: {
|
|
85
85
|
'tsconfig.json': hash(APP_TSCONFIG),
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*
|
|
4
|
+
* The CLI bundle externalizes every bare package import. Node's ESM resolver only maps an
|
|
5
|
+
* extensionless subpath such as `next/link` when the package publishes an `exports` map; for a
|
|
6
|
+
* legacy package (no `exports`, like `next`) it requires the file extension and the bundle
|
|
7
|
+
* fails with ERR_MODULE_NOT_FOUND (#6993). The external plugin therefore rewrites such
|
|
8
|
+
* subpaths to the file `require.resolve` finds, and leaves everything else untouched.
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from 'node:child_process'
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
|
|
15
|
+
import { compileAppSourceFile, createCliBundlePlugins } from '../dynamicLoader'
|
|
16
|
+
|
|
17
|
+
function writePackage(appRoot: string, name: string, manifest: Record<string, unknown>, files: Record<string, string>) {
|
|
18
|
+
const packageDir = path.join(appRoot, 'node_modules', ...name.split('/'))
|
|
19
|
+
fs.mkdirSync(packageDir, { recursive: true })
|
|
20
|
+
fs.writeFileSync(path.join(packageDir, 'package.json'), JSON.stringify({ name, version: '1.0.0', ...manifest }))
|
|
21
|
+
for (const [relativePath, content] of Object.entries(files)) {
|
|
22
|
+
const filePath = path.join(packageDir, relativePath)
|
|
23
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
24
|
+
fs.writeFileSync(filePath, content)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function bundleWithCliPlugins(appRoot: string, source: string): Promise<{ outfile: string; output: string }> {
|
|
29
|
+
const esbuild = await import('esbuild')
|
|
30
|
+
const generatedDir = path.join(appRoot, '.mercato', 'generated')
|
|
31
|
+
fs.mkdirSync(generatedDir, { recursive: true })
|
|
32
|
+
const entry = path.join(generatedDir, 'entry.ts')
|
|
33
|
+
fs.writeFileSync(entry, source)
|
|
34
|
+
const outfile = path.join(generatedDir, 'entry.mjs')
|
|
35
|
+
await esbuild.build({
|
|
36
|
+
entryPoints: [entry],
|
|
37
|
+
outfile,
|
|
38
|
+
absWorkingDir: appRoot,
|
|
39
|
+
bundle: true,
|
|
40
|
+
format: 'esm',
|
|
41
|
+
platform: 'node',
|
|
42
|
+
target: 'node18',
|
|
43
|
+
plugins: createCliBundlePlugins(appRoot),
|
|
44
|
+
})
|
|
45
|
+
return { outfile, output: fs.readFileSync(outfile, 'utf8') }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('createCliBundlePlugins — externalized package subpaths', () => {
|
|
49
|
+
let appRoot: string
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
appRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'om-external-subpath-')))
|
|
53
|
+
fs.writeFileSync(path.join(appRoot, 'package.json'), JSON.stringify({ name: 'app', type: 'module' }))
|
|
54
|
+
writePackage(appRoot, 'legacy-pkg', { main: 'index.js' }, {
|
|
55
|
+
'index.js': "module.exports = 'legacy-root'\n",
|
|
56
|
+
'link.js': "module.exports = 'legacy-link'\n",
|
|
57
|
+
'dist/nested/index.js': "module.exports = 'legacy-nested'\n",
|
|
58
|
+
})
|
|
59
|
+
writePackage(appRoot, '@scope/legacy-scoped', {}, {
|
|
60
|
+
'tools.js': "module.exports = 'scoped-tools'\n",
|
|
61
|
+
})
|
|
62
|
+
writePackage(appRoot, 'exports-pkg', { exports: { './feature': './lib/feature.js' } }, {
|
|
63
|
+
'lib/feature.js': "module.exports = 'exports-feature'\n",
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
fs.rmSync(appRoot, { recursive: true, force: true })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('appends the resolved file to extensionless subpaths of packages without an exports map', async () => {
|
|
72
|
+
const { output } = await bundleWithCliPlugins(appRoot, [
|
|
73
|
+
"import link from 'legacy-pkg/link'",
|
|
74
|
+
"import nested from 'legacy-pkg/dist/nested'",
|
|
75
|
+
"import tools from '@scope/legacy-scoped/tools'",
|
|
76
|
+
'export const values = [link, nested, tools]',
|
|
77
|
+
'',
|
|
78
|
+
].join('\n'))
|
|
79
|
+
|
|
80
|
+
expect(output).toContain('"legacy-pkg/link.js"')
|
|
81
|
+
expect(output).toContain('"legacy-pkg/dist/nested/index.js"')
|
|
82
|
+
expect(output).toContain('"@scope/legacy-scoped/tools.js"')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('keeps package roots, exports-mapped subpaths, builtins and unresolvable specifiers unchanged', async () => {
|
|
86
|
+
const { output } = await bundleWithCliPlugins(appRoot, [
|
|
87
|
+
"import root from 'legacy-pkg'",
|
|
88
|
+
"import feature from 'exports-pkg/feature'",
|
|
89
|
+
"import explicit from 'legacy-pkg/link.js'",
|
|
90
|
+
"import { readFile } from 'fs/promises'",
|
|
91
|
+
"import missing from 'missing-pkg/sub'",
|
|
92
|
+
'export const values = [root, feature, explicit, readFile, missing]',
|
|
93
|
+
'',
|
|
94
|
+
].join('\n'))
|
|
95
|
+
|
|
96
|
+
expect(output).toContain('"legacy-pkg"')
|
|
97
|
+
expect(output).toContain('"exports-pkg/feature"')
|
|
98
|
+
expect(output).toContain('"legacy-pkg/link.js"')
|
|
99
|
+
expect(output).toContain('"fs/promises"')
|
|
100
|
+
expect(output).toContain('"missing-pkg/sub"')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('produces a bundle Node ESM can load', async () => {
|
|
104
|
+
const { outfile } = await bundleWithCliPlugins(appRoot, [
|
|
105
|
+
"import link from 'legacy-pkg/link'",
|
|
106
|
+
"import feature from 'exports-pkg/feature'",
|
|
107
|
+
'console.log(JSON.stringify([link, feature]))',
|
|
108
|
+
'',
|
|
109
|
+
].join('\n'))
|
|
110
|
+
|
|
111
|
+
const result = spawnSync(process.execPath, [outfile], { encoding: 'utf8' })
|
|
112
|
+
|
|
113
|
+
expect(result.stderr).not.toContain('ERR_MODULE_NOT_FOUND')
|
|
114
|
+
expect(result.status).toBe(0)
|
|
115
|
+
expect(JSON.parse(result.stdout.trim())).toEqual(['legacy-link', 'exports-feature'])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('records the rewritten package manifest so a package upgrade invalidates the cached bundle', async () => {
|
|
119
|
+
fs.writeFileSync(path.join(appRoot, 'tsconfig.json'), JSON.stringify({ compilerOptions: { target: 'ES2022' } }))
|
|
120
|
+
const generatedDir = path.join(appRoot, '.mercato', 'generated')
|
|
121
|
+
fs.mkdirSync(generatedDir, { recursive: true })
|
|
122
|
+
const entry = path.join(generatedDir, 'registry.generated.ts')
|
|
123
|
+
fs.writeFileSync(entry, "import link from 'legacy-pkg/link'\nexport const value = link\n")
|
|
124
|
+
const outFile = path.join(generatedDir, 'registry.generated.mjs')
|
|
125
|
+
|
|
126
|
+
await compileAppSourceFile(entry, { appRoot, outFile })
|
|
127
|
+
const metadata = JSON.parse(fs.readFileSync(`${outFile}.cache.json`, 'utf8')) as {
|
|
128
|
+
dependencies: Record<string, string>
|
|
129
|
+
}
|
|
130
|
+
expect(Object.keys(metadata.dependencies)).toContain('node_modules/legacy-pkg/package.json')
|
|
131
|
+
|
|
132
|
+
const firstOutputMtime = fs.statSync(outFile).mtimeMs
|
|
133
|
+
await compileAppSourceFile(entry, { appRoot, outFile })
|
|
134
|
+
expect(fs.statSync(outFile).mtimeMs).toBe(firstOutputMtime)
|
|
135
|
+
|
|
136
|
+
writePackage(appRoot, 'legacy-pkg', { exports: { './link': './link.js' } }, {})
|
|
137
|
+
await compileAppSourceFile(entry, { appRoot, outFile })
|
|
138
|
+
expect(fs.readFileSync(outFile, 'utf8')).toContain('"legacy-pkg/link"')
|
|
139
|
+
expect(fs.readFileSync(outFile, 'utf8')).not.toContain('"legacy-pkg/link.js"')
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -133,7 +133,7 @@ describe('dynamic loader app tsconfig and content-addressed cache', () => {
|
|
|
133
133
|
loadBootstrapDataInNode(appRoot)
|
|
134
134
|
const after = readCacheMetadata(appRoot, 'entities')
|
|
135
135
|
|
|
136
|
-
expect(after.version).toBe(
|
|
136
|
+
expect(after.version).toBe(5)
|
|
137
137
|
expect(after.inputHash).not.toBe(before.inputHash)
|
|
138
138
|
})
|
|
139
139
|
|
|
@@ -287,6 +287,6 @@ describe('dynamic loader app tsconfig and content-addressed cache', () => {
|
|
|
287
287
|
|
|
288
288
|
loadBootstrapDataInNode(appRoot)
|
|
289
289
|
|
|
290
|
-
expect(readCacheMetadata(appRoot, 'entities').version).toBe(
|
|
290
|
+
expect(readCacheMetadata(appRoot, 'entities').version).toBe(5)
|
|
291
291
|
})
|
|
292
292
|
})
|
|
@@ -16,7 +16,7 @@ import { CLIENT_ONLY_STUB_NAMESPACE, createClientOnlyStubPlugin } from './client
|
|
|
16
16
|
import path from 'node:path'
|
|
17
17
|
import fs from 'node:fs'
|
|
18
18
|
import crypto from 'node:crypto'
|
|
19
|
-
import { createRequire } from 'node:module'
|
|
19
|
+
import { createRequire, isBuiltin } from 'node:module'
|
|
20
20
|
import { pathToFileURL } from 'node:url'
|
|
21
21
|
|
|
22
22
|
let activeBootstrapLoads = 0
|
|
@@ -77,6 +77,44 @@ class GeneratedFileNotFoundError extends Error {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function splitBareSpecifier(specifier: string): { packageName: string; subpath: string } | null {
|
|
81
|
+
const segments = specifier.split('/')
|
|
82
|
+
const nameLength = specifier.startsWith('@') ? 2 : 1
|
|
83
|
+
if (segments.length <= nameLength || segments.slice(0, nameLength).some((segment) => !segment)) return null
|
|
84
|
+
return {
|
|
85
|
+
packageName: segments.slice(0, nameLength).join('/'),
|
|
86
|
+
subpath: segments.slice(nameLength).join('/'),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Rewrites an externalized `pkg/subpath` specifier to the file Node's ESM resolver can load
|
|
92
|
+
* when `pkg` publishes no `exports` map (#6993). Builtins, packages with `exports`, subpaths
|
|
93
|
+
* that already carry an extension and anything that fails to resolve keep the original
|
|
94
|
+
* specifier, so the bundle never gets worse than before.
|
|
95
|
+
*/
|
|
96
|
+
function rewriteLegacySubpathSpecifier(
|
|
97
|
+
specifier: string,
|
|
98
|
+
appRequire: NodeRequire,
|
|
99
|
+
onRewrite: (manifestPath: string) => void,
|
|
100
|
+
): string {
|
|
101
|
+
if (isBuiltin(specifier)) return specifier
|
|
102
|
+
const parts = splitBareSpecifier(specifier)
|
|
103
|
+
if (!parts || path.posix.extname(parts.subpath)) return specifier
|
|
104
|
+
try {
|
|
105
|
+
const manifestPath = appRequire.resolve(`${parts.packageName}/package.json`)
|
|
106
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { exports?: unknown }
|
|
107
|
+
if (manifest.exports != null) return specifier
|
|
108
|
+
const resolvedFile = appRequire.resolve(specifier)
|
|
109
|
+
const relativeFile = path.relative(path.dirname(manifestPath), resolvedFile)
|
|
110
|
+
if (!relativeFile || relativeFile.startsWith('..') || path.isAbsolute(relativeFile)) return specifier
|
|
111
|
+
onRewrite(manifestPath)
|
|
112
|
+
return `${parts.packageName}/${relativeFile.split(path.sep).join('/')}`
|
|
113
|
+
} catch {
|
|
114
|
+
return specifier
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
80
118
|
/**
|
|
81
119
|
* esbuild plugins for the CLI bundle, in resolution order. The client-only stub must come
|
|
82
120
|
* first so it wins over the alias and external plugins for `*.client` dynamic imports.
|
|
@@ -85,7 +123,10 @@ class GeneratedFileNotFoundError extends Error {
|
|
|
85
123
|
* `createClientOnlyStubPlugin` in isolation stays green if the plugin is dropped from this
|
|
86
124
|
* list, which would silently reintroduce #4623.
|
|
87
125
|
*/
|
|
88
|
-
export function createCliBundlePlugins(
|
|
126
|
+
export function createCliBundlePlugins(
|
|
127
|
+
appRoot: string,
|
|
128
|
+
options: { onLegacySubpathRewrite?: (manifestPath: string) => void } = {},
|
|
129
|
+
): import('esbuild').Plugin[] {
|
|
89
130
|
// Plugin to resolve the @/ alias the way the app tsconfig maps it:
|
|
90
131
|
// `@/.mercato/*` to the app root, every other `@/*` to the app's src/ directory.
|
|
91
132
|
const aliasPlugin: import('esbuild').Plugin = {
|
|
@@ -113,6 +154,19 @@ export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugi
|
|
|
113
154
|
},
|
|
114
155
|
}
|
|
115
156
|
|
|
157
|
+
// Node's ESM resolver only maps an extensionless package subpath (`next/link`) when the
|
|
158
|
+
// package publishes an `exports` map; legacy packages need the real file path instead.
|
|
159
|
+
const appRequire = createRequire(path.join(appRoot, 'package.json'))
|
|
160
|
+
const externalSpecifierCache = new Map<string, string>()
|
|
161
|
+
const onLegacySubpathRewrite = options.onLegacySubpathRewrite ?? (() => undefined)
|
|
162
|
+
const resolveExternalSpecifier = (specifier: string): string => {
|
|
163
|
+
const cached = externalSpecifierCache.get(specifier)
|
|
164
|
+
if (cached !== undefined) return cached
|
|
165
|
+
const resolved = rewriteLegacySubpathSpecifier(specifier, appRequire, onLegacySubpathRewrite)
|
|
166
|
+
externalSpecifierCache.set(specifier, resolved)
|
|
167
|
+
return resolved
|
|
168
|
+
}
|
|
169
|
+
|
|
116
170
|
// Plugin to mark non-JSON package imports as external
|
|
117
171
|
const externalNonJsonPlugin: import('esbuild').Plugin = {
|
|
118
172
|
name: 'external-non-json',
|
|
@@ -129,7 +183,7 @@ export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugi
|
|
|
129
183
|
return null // Let esbuild handle it
|
|
130
184
|
}
|
|
131
185
|
// Otherwise mark as external
|
|
132
|
-
return { path: args.path, external: true }
|
|
186
|
+
return { path: resolveExternalSpecifier(args.path), external: true }
|
|
133
187
|
})
|
|
134
188
|
},
|
|
135
189
|
}
|
|
@@ -137,7 +191,7 @@ export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugi
|
|
|
137
191
|
return [createClientOnlyStubPlugin(), aliasPlugin, externalNonJsonPlugin]
|
|
138
192
|
}
|
|
139
193
|
|
|
140
|
-
const DYNAMIC_LOADER_CACHE_VERSION =
|
|
194
|
+
const DYNAMIC_LOADER_CACHE_VERSION = 5
|
|
141
195
|
|
|
142
196
|
type DynamicLoaderCacheMetadata = {
|
|
143
197
|
version: number
|
|
@@ -433,6 +487,7 @@ async function compileAppSourceFileWithActiveEsbuild(
|
|
|
433
487
|
// Dynamically import esbuild only when needed
|
|
434
488
|
const esbuild = await getEsbuildRuntime()
|
|
435
489
|
|
|
490
|
+
const rewrittenPackageManifests = new Set<string>()
|
|
436
491
|
// Use esbuild.build with bundling to handle JSON imports
|
|
437
492
|
const result = await esbuild.build({
|
|
438
493
|
entryPoints: [tsPath],
|
|
@@ -444,7 +499,9 @@ async function compileAppSourceFileWithActiveEsbuild(
|
|
|
444
499
|
platform: 'node',
|
|
445
500
|
target: 'node18',
|
|
446
501
|
tsconfig: appTsconfig,
|
|
447
|
-
plugins: createCliBundlePlugins(appRoot
|
|
502
|
+
plugins: createCliBundlePlugins(appRoot, {
|
|
503
|
+
onLegacySubpathRewrite: (manifestPath) => rewrittenPackageManifests.add(manifestPath),
|
|
504
|
+
}),
|
|
448
505
|
// Allow JSON imports
|
|
449
506
|
loader: { '.json': 'json' },
|
|
450
507
|
})
|
|
@@ -455,6 +512,7 @@ async function compileAppSourceFileWithActiveEsbuild(
|
|
|
455
512
|
dependencies: {
|
|
456
513
|
...collectDependencyHashes(appRoot, result.metafile.inputs),
|
|
457
514
|
...hashFilesRelativeTo(appRoot, tsconfigPaths),
|
|
515
|
+
...hashFilesRelativeTo(appRoot, [...rewrittenPackageManifests]),
|
|
458
516
|
},
|
|
459
517
|
}
|
|
460
518
|
fs.writeFileSync(metadataPath, JSON.stringify(metadata))
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
MAX_IDS_PER_REQUEST,
|
|
3
|
+
mergeIdFilter,
|
|
4
|
+
parseIdsParam,
|
|
5
|
+
isIdsParamProvided,
|
|
6
|
+
isUuidListParam,
|
|
7
|
+
splitIdListParam,
|
|
8
|
+
} from '@open-mercato/shared/lib/crud/ids'
|
|
2
9
|
|
|
3
10
|
describe('crud ids helpers', () => {
|
|
4
11
|
const idA = '550e8400-e29b-41d4-a716-446655440001'
|
|
@@ -168,4 +175,30 @@ describe('crud ids helpers', () => {
|
|
|
168
175
|
tenantId: 't1',
|
|
169
176
|
})
|
|
170
177
|
})
|
|
178
|
+
|
|
179
|
+
it('splitIdListParam trims entries and drops empty ones', () => {
|
|
180
|
+
expect(splitIdListParam(` ${idA} ,, ${idB} ,`)).toEqual([idA, idB])
|
|
181
|
+
expect(splitIdListParam(undefined)).toEqual([])
|
|
182
|
+
expect(splitIdListParam(['not', 'a', 'string'])).toEqual([])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('isUuidListParam accepts absent, empty, and all-UUID lists', () => {
|
|
186
|
+
expect(isUuidListParam(undefined)).toBe(true)
|
|
187
|
+
expect(isUuidListParam('')).toBe(true)
|
|
188
|
+
expect(isUuidListParam(` ${idA}, ${idB.toUpperCase()} `)).toBe(true)
|
|
189
|
+
expect(isUuidListParam('00000000-0000-0000-0000-000000000000')).toBe(true)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('isUuidListParam rejects malformed and mixed lists', () => {
|
|
193
|
+
expect(isUuidListParam('not-a-uuid')).toBe(false)
|
|
194
|
+
expect(isUuidListParam(`${idA},not-a-uuid`)).toBe(false)
|
|
195
|
+
expect(isUuidListParam(`${idA}0`)).toBe(false)
|
|
196
|
+
expect(isUuidListParam(`{${idA}}`)).toBe(false)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('isUuidListParam fails closed on non-string values', () => {
|
|
200
|
+
expect(isUuidListParam(['not-a-uuid'])).toBe(false)
|
|
201
|
+
expect(isUuidListParam([idA])).toBe(false)
|
|
202
|
+
expect(isUuidListParam(null)).toBe(false)
|
|
203
|
+
})
|
|
171
204
|
})
|
package/src/lib/crud/ids.ts
CHANGED
|
@@ -51,6 +51,30 @@ export function parseIdsParam(raw: unknown, maxIds: number = MAX_IDS_PER_REQUEST
|
|
|
51
51
|
return parsed.slice(0, safeMax)
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
const UUID_SHAPE_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Splits a comma-separated id list query param into its trimmed, non-empty entries.
|
|
58
|
+
*/
|
|
59
|
+
export function splitIdListParam(raw: unknown): string[] {
|
|
60
|
+
if (typeof raw !== 'string') return []
|
|
61
|
+
return raw
|
|
62
|
+
.split(',')
|
|
63
|
+
.map((value) => value.trim())
|
|
64
|
+
.filter((value) => value.length > 0)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether every entry of a comma-separated id list query param is UUID-shaped, so it can be
|
|
69
|
+
* bound to a uuid column without a database error. Use it to reject a malformed or mixed list
|
|
70
|
+
* with a 400 instead of dropping the bad entries and widening the result set (#7265).
|
|
71
|
+
*/
|
|
72
|
+
export function isUuidListParam(raw: unknown): boolean {
|
|
73
|
+
if (raw === undefined) return true
|
|
74
|
+
if (typeof raw !== 'string') return false
|
|
75
|
+
return splitIdListParam(raw).every((value) => UUID_SHAPE_REGEX.test(value))
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
/**
|
|
55
79
|
* Whether an `?ids=` param was supplied at all (a non-empty string, or repeated
|
|
56
80
|
* `?ids=` occurrences), regardless of whether any value survived UUID
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { buildOpenApiDocument } from '../generator'
|
|
3
|
+
import type { Module } from '../../../modules/registry'
|
|
4
|
+
|
|
5
|
+
type ParameterNode = { name?: string; in?: string; required?: boolean }
|
|
6
|
+
type OperationNode = { operationId?: string; parameters?: ParameterNode[] }
|
|
7
|
+
|
|
8
|
+
const noopHandler = async () => new Response(null)
|
|
9
|
+
|
|
10
|
+
function imageModule(extraApis: unknown[] = [], operationId?: string): Module {
|
|
11
|
+
return {
|
|
12
|
+
id: 'attachments',
|
|
13
|
+
apis: [
|
|
14
|
+
{
|
|
15
|
+
path: '/attachments/image/[id]/[[...slug]]',
|
|
16
|
+
handlers: { GET: noopHandler },
|
|
17
|
+
docs: {
|
|
18
|
+
methods: {
|
|
19
|
+
GET: {
|
|
20
|
+
summary: 'Serve image',
|
|
21
|
+
...(operationId ? { operationId } : {}),
|
|
22
|
+
pathParams: z.object({ id: z.string().uuid() }),
|
|
23
|
+
responses: [{ status: 200, description: 'Image' }],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
...extraApis,
|
|
29
|
+
],
|
|
30
|
+
} as unknown as Module
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function operation(doc: ReturnType<typeof buildOpenApiDocument>, path: string): OperationNode | undefined {
|
|
34
|
+
return (doc.paths[path] as Record<string, OperationNode> | undefined)?.get
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function collectPathParameterViolations(doc: ReturnType<typeof buildOpenApiDocument>): string[] {
|
|
38
|
+
const violations: string[] = []
|
|
39
|
+
const operationIds = new Set<string>()
|
|
40
|
+
for (const [path, item] of Object.entries(doc.paths)) {
|
|
41
|
+
const templateNames = Array.from(path.matchAll(/\{([^}]+)\}/g), (match) => match[1])
|
|
42
|
+
for (const [method, op] of Object.entries(item as Record<string, OperationNode>)) {
|
|
43
|
+
const pathParams = (op.parameters ?? []).filter((param) => param.in === 'path')
|
|
44
|
+
for (const param of pathParams) {
|
|
45
|
+
if (param.required !== true) violations.push(`${method} ${path}: path parameter ${param.name} is not required`)
|
|
46
|
+
if (!templateNames.includes(param.name ?? '')) violations.push(`${method} ${path}: path parameter ${param.name} is not in the template`)
|
|
47
|
+
}
|
|
48
|
+
for (const name of templateNames) {
|
|
49
|
+
if (!pathParams.some((param) => param.name === name)) violations.push(`${method} ${path}: template variable ${name} is not declared`)
|
|
50
|
+
}
|
|
51
|
+
if (op.operationId) {
|
|
52
|
+
if (operationIds.has(op.operationId)) violations.push(`${method} ${path}: duplicate operationId ${op.operationId}`)
|
|
53
|
+
operationIds.add(op.operationId)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return violations
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('buildOpenApiDocument optional catch-all path parameters', () => {
|
|
61
|
+
it('emits id-only and id-plus-slug paths with only required path parameters', () => {
|
|
62
|
+
const doc = buildOpenApiDocument([imageModule()])
|
|
63
|
+
|
|
64
|
+
expect(collectPathParameterViolations(doc)).toEqual([])
|
|
65
|
+
|
|
66
|
+
const withSlug = operation(doc, '/attachments/image/{id}/{slug}')
|
|
67
|
+
const withoutSlug = operation(doc, '/attachments/image/{id}')
|
|
68
|
+
expect(withSlug?.parameters?.filter((param) => param.in === 'path').map((param) => param.name)).toEqual(['id', 'slug'])
|
|
69
|
+
expect(withoutSlug?.parameters?.filter((param) => param.in === 'path').map((param) => param.name)).toEqual(['id'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('keeps the published operationId on the slug path and gives the id-only path a distinct one', () => {
|
|
73
|
+
const doc = buildOpenApiDocument([imageModule()])
|
|
74
|
+
|
|
75
|
+
expect(operation(doc, '/attachments/image/{id}/{slug}')?.operationId).toBe('attachments_get_attachments_image_id_slug')
|
|
76
|
+
expect(operation(doc, '/attachments/image/{id}')?.operationId).toBe('attachments_get_attachments_image_id')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('derives a distinct id-only operationId from an explicit operationId', () => {
|
|
80
|
+
const doc = buildOpenApiDocument([imageModule([], 'serveImage')])
|
|
81
|
+
|
|
82
|
+
expect(operation(doc, '/attachments/image/{id}/{slug}')?.operationId).toBe('serveImage')
|
|
83
|
+
expect(operation(doc, '/attachments/image/{id}')?.operationId).toBe('serveImage_without_slug')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('never replaces a concrete route that owns the id-only path, whichever is registered first', () => {
|
|
87
|
+
const concreteRoute = {
|
|
88
|
+
path: '/attachments/image/[id]',
|
|
89
|
+
handlers: { GET: noopHandler },
|
|
90
|
+
docs: { methods: { GET: { summary: 'Concrete', operationId: 'concreteImage', responses: [{ status: 200, description: 'OK' }] } } },
|
|
91
|
+
}
|
|
92
|
+
const after = buildOpenApiDocument([imageModule([concreteRoute])])
|
|
93
|
+
const before = buildOpenApiDocument([
|
|
94
|
+
{ id: 'other', apis: [concreteRoute] } as unknown as Module,
|
|
95
|
+
imageModule(),
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
expect(operation(after, '/attachments/image/{id}')?.operationId).toBe('concreteImage')
|
|
99
|
+
expect(operation(before, '/attachments/image/{id}')?.operationId).toBe('concreteImage')
|
|
100
|
+
expect(collectPathParameterViolations(after)).toEqual([])
|
|
101
|
+
})
|
|
102
|
+
})
|