@open-mercato/shared 0.6.7-develop.6597.1.b8f069b7fe → 0.6.7-develop.6603.1.78e5b7a65d
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 +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/crud/factory.js +4 -3
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/ids.js +11 -2
- package/dist/lib/crud/ids.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +76 -0
- package/src/lib/bootstrap/dynamicLoader.ts +8 -0
- package/src/lib/crud/__tests__/ids.test.ts +31 -1
- package/src/lib/crud/factory.ts +4 -3
- package/src/lib/crud/ids.ts +22 -1
package/dist/lib/crud/ids.js
CHANGED
|
@@ -38,8 +38,16 @@ function parseIdsParam(raw, maxIds = MAX_IDS_PER_REQUEST) {
|
|
|
38
38
|
const parsed = normalizeIdList(raw.split(","));
|
|
39
39
|
return parsed.slice(0, safeMax);
|
|
40
40
|
}
|
|
41
|
-
function
|
|
42
|
-
|
|
41
|
+
function isIdsParamProvided(raw) {
|
|
42
|
+
return typeof raw === "string" && raw.trim().length > 0;
|
|
43
|
+
}
|
|
44
|
+
function mergeIdFilter(existingFilters, parsedIds, options) {
|
|
45
|
+
if (parsedIds.length === 0) {
|
|
46
|
+
if (options?.idsParamProvided) {
|
|
47
|
+
return { ...existingFilters, id: { $in: [] } };
|
|
48
|
+
}
|
|
49
|
+
return existingFilters;
|
|
50
|
+
}
|
|
43
51
|
const existingFilter = existingFilters.id;
|
|
44
52
|
const existingIds = readExistingIds(existingFilter);
|
|
45
53
|
if (!existingIds) {
|
|
@@ -57,6 +65,7 @@ function mergeIdFilter(existingFilters, parsedIds) {
|
|
|
57
65
|
}
|
|
58
66
|
export {
|
|
59
67
|
MAX_IDS_PER_REQUEST,
|
|
68
|
+
isIdsParamProvided,
|
|
60
69
|
mergeIdFilter,
|
|
61
70
|
parseIdsParam
|
|
62
71
|
};
|
package/dist/lib/crud/ids.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/crud/ids.ts"],
|
|
4
|
-
"sourcesContent": ["import type { Where } from '@open-mercato/shared/lib/query/types'\n\nexport const MAX_IDS_PER_REQUEST = 200\n\nconst UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i\n\nfunction isUuid(value: unknown): value is string {\n return typeof value === 'string' && UUID_REGEX.test(value)\n}\n\nfunction normalizeIdList(values: string[]): string[] {\n if (values.length === 0) return values\n const deduped = new Set<string>()\n for (const value of values) {\n const trimmed = value.trim()\n if (!trimmed || !isUuid(trimmed)) continue\n deduped.add(trimmed)\n }\n return Array.from(deduped)\n}\n\nfunction readExistingIds(filter: unknown): string[] | null {\n if (typeof filter === 'string') {\n return isUuid(filter) ? [filter] : null\n }\n if (Array.isArray(filter)) {\n return normalizeIdList(\n filter.filter((value): value is string => typeof value === 'string'),\n )\n }\n if (!filter || typeof filter !== 'object') return null\n\n const operators = filter as Record<string, unknown>\n if (isUuid(operators.$eq)) return [operators.$eq]\n\n if (Array.isArray(operators.$in)) {\n return normalizeIdList(\n operators.$in.filter((value): value is string => typeof value === 'string'),\n )\n }\n\n return null\n}\n\nexport function parseIdsParam(raw: unknown, maxIds: number = MAX_IDS_PER_REQUEST): string[] {\n if (typeof raw !== 'string' || raw.trim().length === 0) return []\n const safeMax = Number.isFinite(maxIds) && maxIds > 0 ? Math.floor(maxIds) : MAX_IDS_PER_REQUEST\n const parsed = normalizeIdList(raw.split(','))\n return parsed.slice(0, safeMax)\n}\n\nexport function mergeIdFilter<Fields extends Record<string, unknown>>(\n existingFilters: Where<Fields>,\n parsedIds: string[],\n): Where<Fields> {\n if (parsedIds.length === 0) return existingFilters\n\n const existingFilter = (existingFilters as Record<string, unknown>).id\n const existingIds = readExistingIds(existingFilter)\n\n if (!existingIds) {\n // No existing narrowing \u2014 safe to install the user-supplied `$in`.\n if (existingFilter === undefined || existingFilter === null) {\n return { ...existingFilters, id: { $in: parsedIds } }\n }\n // Existing `id` filter is in a shape we do not recognise. Fail closed:\n // preserve the existing filter instead of widening to `parsedIds`. Adding\n // a new recognised shape to `readExistingIds` is preferable to silently\n // dropping a narrowing that another caller put in place.\n return existingFilters\n }\n\n const allowed = new Set(parsedIds)\n const intersection = existingIds.filter((id) => allowed.has(id))\n return {\n ...existingFilters,\n id: { $in: intersection },\n }\n}\n"],
|
|
5
|
-
"mappings": "AAEO,MAAM,sBAAsB;AAEnC,MAAM,aAAa;AAEnB,SAAS,OAAO,OAAiC;AAC/C,SAAO,OAAO,UAAU,YAAY,WAAW,KAAK,KAAK;AAC3D;AAEA,SAAS,gBAAgB,QAA4B;AACnD,MAAI,OAAO,WAAW,EAAG,QAAO;AAChC,QAAM,UAAU,oBAAI,IAAY;AAChC,aAAW,SAAS,QAAQ;AAC1B,UAAM,UAAU,MAAM,KAAK;AAC3B,QAAI,CAAC,WAAW,CAAC,OAAO,OAAO,EAAG;AAClC,YAAQ,IAAI,OAAO;AAAA,EACrB;AACA,SAAO,MAAM,KAAK,OAAO;AAC3B;AAEA,SAAS,gBAAgB,QAAkC;AACzD,MAAI,OAAO,WAAW,UAAU;AAC9B,WAAO,OAAO,MAAM,IAAI,CAAC,MAAM,IAAI;AAAA,EACrC;AACA,MAAI,MAAM,QAAQ,MAAM,GAAG;AACzB,WAAO;AAAA,MACL,OAAO,OAAO,CAAC,UAA2B,OAAO,UAAU,QAAQ;AAAA,IACrE;AAAA,EACF;AACA,MAAI,CAAC,UAAU,OAAO,WAAW,SAAU,QAAO;AAElD,QAAM,YAAY;AAClB,MAAI,OAAO,UAAU,GAAG,EAAG,QAAO,CAAC,UAAU,GAAG;AAEhD,MAAI,MAAM,QAAQ,UAAU,GAAG,GAAG;AAChC,WAAO;AAAA,MACL,UAAU,IAAI,OAAO,CAAC,UAA2B,OAAO,UAAU,QAAQ;AAAA,IAC5E;AAAA,EACF;AAEA,SAAO;AACT;AAEO,SAAS,cAAc,KAAc,SAAiB,qBAA+B;AAC1F,MAAI,OAAO,QAAQ,YAAY,IAAI,KAAK,EAAE,WAAW,EAAG,QAAO,CAAC;AAChE,QAAM,UAAU,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI,KAAK,MAAM,MAAM,IAAI;AAC7E,QAAM,SAAS,gBAAgB,IAAI,MAAM,GAAG,CAAC;AAC7C,SAAO,OAAO,MAAM,GAAG,OAAO;AAChC;AAEO,SAAS,cACd,iBACA,
|
|
4
|
+
"sourcesContent": ["import type { Where } from '@open-mercato/shared/lib/query/types'\n\nexport const MAX_IDS_PER_REQUEST = 200\n\nconst UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i\n\nfunction isUuid(value: unknown): value is string {\n return typeof value === 'string' && UUID_REGEX.test(value)\n}\n\nfunction normalizeIdList(values: string[]): string[] {\n if (values.length === 0) return values\n const deduped = new Set<string>()\n for (const value of values) {\n const trimmed = value.trim()\n if (!trimmed || !isUuid(trimmed)) continue\n deduped.add(trimmed)\n }\n return Array.from(deduped)\n}\n\nfunction readExistingIds(filter: unknown): string[] | null {\n if (typeof filter === 'string') {\n return isUuid(filter) ? [filter] : null\n }\n if (Array.isArray(filter)) {\n return normalizeIdList(\n filter.filter((value): value is string => typeof value === 'string'),\n )\n }\n if (!filter || typeof filter !== 'object') return null\n\n const operators = filter as Record<string, unknown>\n if (isUuid(operators.$eq)) return [operators.$eq]\n\n if (Array.isArray(operators.$in)) {\n return normalizeIdList(\n operators.$in.filter((value): value is string => typeof value === 'string'),\n )\n }\n\n return null\n}\n\nexport function parseIdsParam(raw: unknown, maxIds: number = MAX_IDS_PER_REQUEST): string[] {\n if (typeof raw !== 'string' || raw.trim().length === 0) return []\n const safeMax = Number.isFinite(maxIds) && maxIds > 0 ? Math.floor(maxIds) : MAX_IDS_PER_REQUEST\n const parsed = normalizeIdList(raw.split(','))\n return parsed.slice(0, safeMax)\n}\n\n/**\n * Whether an `?ids=` param was supplied at all (a non-empty string), regardless\n * of whether any value survived UUID validation. Lets a caller tell \"no ids\n * filter requested\" apart from \"ids filter requested but every value was\n * malformed\" \u2014 the latter must match nothing, not fall back to the full list\n * (#4143 Finding 3).\n */\nexport function isIdsParamProvided(raw: unknown): boolean {\n return typeof raw === 'string' && raw.trim().length > 0\n}\n\nexport function mergeIdFilter<Fields extends Record<string, unknown>>(\n existingFilters: Where<Fields>,\n parsedIds: string[],\n options?: { idsParamProvided?: boolean },\n): Where<Fields> {\n if (parsedIds.length === 0) {\n // The `?ids=` param was supplied but nothing survived UUID validation.\n // Match nothing, mirroring a valid-but-unknown id returning zero rows,\n // rather than silently dropping the filter and returning the full list\n // (record-count side channel \u2014 #4143 Finding 3).\n if (options?.idsParamProvided) {\n return { ...existingFilters, id: { $in: [] } }\n }\n return existingFilters\n }\n\n const existingFilter = (existingFilters as Record<string, unknown>).id\n const existingIds = readExistingIds(existingFilter)\n\n if (!existingIds) {\n // No existing narrowing \u2014 safe to install the user-supplied `$in`.\n if (existingFilter === undefined || existingFilter === null) {\n return { ...existingFilters, id: { $in: parsedIds } }\n }\n // Existing `id` filter is in a shape we do not recognise. Fail closed:\n // preserve the existing filter instead of widening to `parsedIds`. Adding\n // a new recognised shape to `readExistingIds` is preferable to silently\n // dropping a narrowing that another caller put in place.\n return existingFilters\n }\n\n const allowed = new Set(parsedIds)\n const intersection = existingIds.filter((id) => allowed.has(id))\n return {\n ...existingFilters,\n id: { $in: intersection },\n }\n}\n"],
|
|
5
|
+
"mappings": "AAEO,MAAM,sBAAsB;AAEnC,MAAM,aAAa;AAEnB,SAAS,OAAO,OAAiC;AAC/C,SAAO,OAAO,UAAU,YAAY,WAAW,KAAK,KAAK;AAC3D;AAEA,SAAS,gBAAgB,QAA4B;AACnD,MAAI,OAAO,WAAW,EAAG,QAAO;AAChC,QAAM,UAAU,oBAAI,IAAY;AAChC,aAAW,SAAS,QAAQ;AAC1B,UAAM,UAAU,MAAM,KAAK;AAC3B,QAAI,CAAC,WAAW,CAAC,OAAO,OAAO,EAAG;AAClC,YAAQ,IAAI,OAAO;AAAA,EACrB;AACA,SAAO,MAAM,KAAK,OAAO;AAC3B;AAEA,SAAS,gBAAgB,QAAkC;AACzD,MAAI,OAAO,WAAW,UAAU;AAC9B,WAAO,OAAO,MAAM,IAAI,CAAC,MAAM,IAAI;AAAA,EACrC;AACA,MAAI,MAAM,QAAQ,MAAM,GAAG;AACzB,WAAO;AAAA,MACL,OAAO,OAAO,CAAC,UAA2B,OAAO,UAAU,QAAQ;AAAA,IACrE;AAAA,EACF;AACA,MAAI,CAAC,UAAU,OAAO,WAAW,SAAU,QAAO;AAElD,QAAM,YAAY;AAClB,MAAI,OAAO,UAAU,GAAG,EAAG,QAAO,CAAC,UAAU,GAAG;AAEhD,MAAI,MAAM,QAAQ,UAAU,GAAG,GAAG;AAChC,WAAO;AAAA,MACL,UAAU,IAAI,OAAO,CAAC,UAA2B,OAAO,UAAU,QAAQ;AAAA,IAC5E;AAAA,EACF;AAEA,SAAO;AACT;AAEO,SAAS,cAAc,KAAc,SAAiB,qBAA+B;AAC1F,MAAI,OAAO,QAAQ,YAAY,IAAI,KAAK,EAAE,WAAW,EAAG,QAAO,CAAC;AAChE,QAAM,UAAU,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI,KAAK,MAAM,MAAM,IAAI;AAC7E,QAAM,SAAS,gBAAgB,IAAI,MAAM,GAAG,CAAC;AAC7C,SAAO,OAAO,MAAM,GAAG,OAAO;AAChC;AASO,SAAS,mBAAmB,KAAuB;AACxD,SAAO,OAAO,QAAQ,YAAY,IAAI,KAAK,EAAE,SAAS;AACxD;AAEO,SAAS,cACd,iBACA,WACA,SACe;AACf,MAAI,UAAU,WAAW,GAAG;AAK1B,QAAI,SAAS,kBAAkB;AAC7B,aAAO,EAAE,GAAG,iBAAiB,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE;AAAA,IAC/C;AACA,WAAO;AAAA,EACT;AAEA,QAAM,iBAAkB,gBAA4C;AACpE,QAAM,cAAc,gBAAgB,cAAc;AAElD,MAAI,CAAC,aAAa;AAEhB,QAAI,mBAAmB,UAAa,mBAAmB,MAAM;AAC3D,aAAO,EAAE,GAAG,iBAAiB,IAAI,EAAE,KAAK,UAAU,EAAE;AAAA,IACtD;AAKA,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,IAAI,IAAI,SAAS;AACjC,QAAM,eAAe,YAAY,OAAO,CAAC,OAAO,QAAQ,IAAI,EAAE,CAAC;AAC/D,SAAO;AAAA,IACL,GAAG;AAAA,IACH,IAAI,EAAE,KAAK,aAAa;AAAA,EAC1B;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -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.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.6603.1.78e5b7a65d'\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.
|
|
3
|
+
"version": "0.6.7-develop.6603.1.78e5b7a65d",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"@mikro-orm/core": "^7.1.5",
|
|
98
98
|
"@mikro-orm/decorators": "^7.1.5",
|
|
99
99
|
"@mikro-orm/postgresql": "^7.1.5",
|
|
100
|
-
"@open-mercato/cache": "0.6.7-develop.
|
|
100
|
+
"@open-mercato/cache": "0.6.7-develop.6603.1.78e5b7a65d",
|
|
101
101
|
"@types/sanitize-html": "^2.16.1",
|
|
102
102
|
"dotenv": "^17.4.2",
|
|
103
103
|
"pino": "^10.3.1",
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*
|
|
4
|
+
* Regression guard for #4327: the CLI/queue-worker bootstrap path
|
|
5
|
+
* (loadBootstrapData) must load command-interceptors.generated.ts and expose
|
|
6
|
+
* `commandInterceptorEntries` in its BootstrapData. The interceptor registry is
|
|
7
|
+
* per-process, so a worker that omits the field silently no-ops every command
|
|
8
|
+
* interceptor while the Next.js runtime applies them — split behavior between
|
|
9
|
+
* web and queued execution of the same command.
|
|
10
|
+
*
|
|
11
|
+
* The test authors both the generated .ts sources and fresh compiled .mjs
|
|
12
|
+
* siblings, so compileAndImport takes its cache path and never invokes esbuild.
|
|
13
|
+
* The .mjs stubs use module.exports because Jest's CJS runtime handles the
|
|
14
|
+
* dynamic import() and does not transform .mjs files; the loader consumes the
|
|
15
|
+
* named exports identically either way.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs'
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { loadBootstrapData } from '../dynamicLoader'
|
|
21
|
+
|
|
22
|
+
const GENERATED_MODULES: Record<string, { ts: string; compiled: string }> = {
|
|
23
|
+
'entities.ids.generated': { ts: 'export const E = {}', compiled: 'module.exports = { E: {} }' },
|
|
24
|
+
'modules.cli.generated': { ts: 'export const modules = []', compiled: 'module.exports = { modules: [] }' },
|
|
25
|
+
'entities.generated': { ts: 'export const entities = []', compiled: 'module.exports = { entities: [] }' },
|
|
26
|
+
'di.generated': { ts: 'export const diRegistrars = []', compiled: 'module.exports = { diRegistrars: [] }' },
|
|
27
|
+
'command-interceptors.generated': {
|
|
28
|
+
ts: 'export const commandInterceptorEntries = []',
|
|
29
|
+
compiled: `module.exports = { commandInterceptorEntries: [
|
|
30
|
+
{ moduleId: 'example', interceptors: [{ id: 'example.adjust-probability' }] },
|
|
31
|
+
] }`,
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeGeneratedModule(generatedDir: string, baseName: string, source: { ts: string; compiled: string }) {
|
|
36
|
+
fs.writeFileSync(path.join(generatedDir, `${baseName}.ts`), source.ts)
|
|
37
|
+
fs.writeFileSync(path.join(generatedDir, `${baseName}.mjs`), source.compiled)
|
|
38
|
+
const fresh = new Date(Date.now() + 60_000)
|
|
39
|
+
fs.utimesSync(path.join(generatedDir, `${baseName}.mjs`), fresh, fresh)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('loadBootstrapData — command interceptors reach worker/CLI bootstrap (#4327)', () => {
|
|
43
|
+
let appRoot: string
|
|
44
|
+
|
|
45
|
+
beforeAll(() => {
|
|
46
|
+
appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'om-bootstrap-4327-'))
|
|
47
|
+
const generatedDir = path.join(appRoot, '.mercato', 'generated')
|
|
48
|
+
fs.mkdirSync(generatedDir, { recursive: true })
|
|
49
|
+
for (const [baseName, source] of Object.entries(GENERATED_MODULES)) {
|
|
50
|
+
writeGeneratedModule(generatedDir, baseName, source)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
afterAll(() => {
|
|
55
|
+
fs.rmSync(appRoot, { recursive: true, force: true })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('returns commandInterceptorEntries from command-interceptors.generated', async () => {
|
|
59
|
+
const data = await loadBootstrapData(appRoot)
|
|
60
|
+
|
|
61
|
+
expect(data.commandInterceptorEntries).toEqual([
|
|
62
|
+
{ moduleId: 'example', interceptors: [{ id: 'example.adjust-probability' }] },
|
|
63
|
+
])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('falls back to an empty array (not undefined) when the generated file is absent', async () => {
|
|
67
|
+
const generatedDir = path.join(appRoot, '.mercato', 'generated')
|
|
68
|
+
fs.rmSync(path.join(generatedDir, 'command-interceptors.generated.ts'))
|
|
69
|
+
fs.rmSync(path.join(generatedDir, 'command-interceptors.generated.mjs'))
|
|
70
|
+
|
|
71
|
+
const data = await loadBootstrapData(appRoot)
|
|
72
|
+
|
|
73
|
+
expect(data.commandInterceptorEntries).toEqual([])
|
|
74
|
+
expect(data.commandLoaderEntries).toEqual([])
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -155,6 +155,7 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
|
|
|
155
155
|
diModule,
|
|
156
156
|
searchModule,
|
|
157
157
|
commandLoadersModule,
|
|
158
|
+
commandInterceptorsModule,
|
|
158
159
|
workflowsModule,
|
|
159
160
|
] = await Promise.all([
|
|
160
161
|
compileAndImport(path.join(generatedDir, 'modules.cli.generated.ts')),
|
|
@@ -162,6 +163,7 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
|
|
|
162
163
|
compileAndImport(path.join(generatedDir, 'di.generated.ts')),
|
|
163
164
|
compileAndImport(path.join(generatedDir, 'search.generated.ts')).catch(() => ({ searchModuleConfigs: [] })),
|
|
164
165
|
compileAndImport(path.join(generatedDir, 'command-loaders.generated.ts')).catch(() => ({ commandLoaderEntries: [] })),
|
|
166
|
+
compileAndImport(path.join(generatedDir, 'command-interceptors.generated.ts')).catch(() => ({ commandInterceptorEntries: [] })),
|
|
165
167
|
compileAndImport(path.join(generatedDir, 'workflows.generated.ts')).catch(() => ({ allCodeWorkflows: [] })),
|
|
166
168
|
])
|
|
167
169
|
|
|
@@ -173,6 +175,12 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
|
|
|
173
175
|
// Search configs are needed by workers for indexing
|
|
174
176
|
searchModuleConfigs: (searchModule.searchModuleConfigs ?? []) as BootstrapData['searchModuleConfigs'],
|
|
175
177
|
commandLoaderEntries: (commandLoadersModule.commandLoaderEntries ?? []) as BootstrapData['commandLoaderEntries'],
|
|
178
|
+
// Command interceptors must apply in worker/CLI processes too — the
|
|
179
|
+
// interceptor registry is per-process, so relying on the Next.js runtime's
|
|
180
|
+
// registration silently no-ops every interceptor for queued/CLI commands
|
|
181
|
+
// (#4327).
|
|
182
|
+
commandInterceptorEntries: (commandInterceptorsModule.commandInterceptorEntries ??
|
|
183
|
+
[]) as BootstrapData['commandInterceptorEntries'],
|
|
176
184
|
// Code workflow definitions are needed by workers to resume code-defined instances
|
|
177
185
|
codeWorkflows: (workflowsModule.allCodeWorkflows ?? []) as BootstrapData['codeWorkflows'],
|
|
178
186
|
// Empty UI-related data - not needed for CLI
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MAX_IDS_PER_REQUEST, mergeIdFilter, parseIdsParam } from '@open-mercato/shared/lib/crud/ids'
|
|
1
|
+
import { MAX_IDS_PER_REQUEST, mergeIdFilter, parseIdsParam, isIdsParamProvided } from '@open-mercato/shared/lib/crud/ids'
|
|
2
2
|
|
|
3
3
|
describe('crud ids helpers', () => {
|
|
4
4
|
const idA = '550e8400-e29b-41d4-a716-446655440001'
|
|
@@ -109,4 +109,34 @@ describe('crud ids helpers', () => {
|
|
|
109
109
|
id: { $in: [idA] },
|
|
110
110
|
})
|
|
111
111
|
})
|
|
112
|
+
|
|
113
|
+
// #4143 Finding 3: `?ids=<garbage>` parsed to [] and silently dropped the
|
|
114
|
+
// filter, returning the full list — a record-count side channel. A supplied
|
|
115
|
+
// but fully-invalid ids param must match nothing, like a valid-unknown UUID.
|
|
116
|
+
it('isIdsParamProvided distinguishes a supplied param from an absent one', () => {
|
|
117
|
+
expect(isIdsParamProvided('not-a-uuid')).toBe(true)
|
|
118
|
+
expect(isIdsParamProvided(`${idA}`)).toBe(true)
|
|
119
|
+
expect(isIdsParamProvided('')).toBe(false)
|
|
120
|
+
expect(isIdsParamProvided(' ')).toBe(false)
|
|
121
|
+
expect(isIdsParamProvided(undefined)).toBe(false)
|
|
122
|
+
expect(isIdsParamProvided(null)).toBe(false)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('mergeIdFilter matches nothing when ids param was provided but all invalid', () => {
|
|
126
|
+
// Malformed input: parseIdsParam yields [], but the param WAS provided.
|
|
127
|
+
expect(mergeIdFilter({}, parseIdsParam('not-a-uuid'), { idsParamProvided: true })).toEqual({
|
|
128
|
+
id: { $in: [] },
|
|
129
|
+
})
|
|
130
|
+
// Preserves any existing filters alongside the match-nothing id clause.
|
|
131
|
+
expect(
|
|
132
|
+
mergeIdFilter({ tenantId: 't1' } as any, [], { idsParamProvided: true }),
|
|
133
|
+
).toEqual({ tenantId: 't1', id: { $in: [] } })
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('mergeIdFilter still no-ops on empty ids when the param was absent (BC)', () => {
|
|
137
|
+
expect(mergeIdFilter({ tenantId: 't1' } as any, [])).toEqual({ tenantId: 't1' })
|
|
138
|
+
expect(mergeIdFilter({ tenantId: 't1' } as any, [], { idsParamProvided: false })).toEqual({
|
|
139
|
+
tenantId: 't1',
|
|
140
|
+
})
|
|
141
|
+
})
|
|
112
142
|
})
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -66,7 +66,7 @@ import { applyResponseEnrichers, applyResponseEnricherToRecord, resolveListCache
|
|
|
66
66
|
import type { EnricherContext } from './response-enricher'
|
|
67
67
|
import type { ApiInterceptorMethod, InterceptorRequest, InterceptorResponse } from './api-interceptor'
|
|
68
68
|
import { runApiInterceptorsAfter, runApiInterceptorsBefore } from './interceptor-runner'
|
|
69
|
-
import { mergeIdFilter, parseIdsParam } from './ids'
|
|
69
|
+
import { mergeIdFilter, parseIdsParam, isIdsParamProvided } from './ids'
|
|
70
70
|
import { mergeAdvancedFilters } from './advanced-filter-integration'
|
|
71
71
|
import { parseExtensionHeaders } from '../umes/extension-headers'
|
|
72
72
|
import { createGenericOptimisticLockReader } from './optimistic-lock'
|
|
@@ -1445,6 +1445,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1445
1445
|
...(interceptorRequest.query ?? {}),
|
|
1446
1446
|
} as Record<string, unknown>
|
|
1447
1447
|
const parsedIds = parseIdsParam(queryParams.ids)
|
|
1448
|
+
const idsParamProvided = isIdsParamProvided(queryParams.ids)
|
|
1448
1449
|
|
|
1449
1450
|
await opts.hooks?.beforeList?.(validated as any, ctx)
|
|
1450
1451
|
profiler.mark('before_list_hook')
|
|
@@ -1664,7 +1665,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1664
1665
|
const filters = exportFullRequested
|
|
1665
1666
|
? baseFilters
|
|
1666
1667
|
: mergeAdvancedFilters(baseFilters as Record<string, unknown>, validated as Record<string, unknown>) as Where<any>
|
|
1667
|
-
const mergedFilters = exportFullRequested ? filters : mergeIdFilter(filters, parsedIds)
|
|
1668
|
+
const mergedFilters = exportFullRequested ? filters : mergeIdFilter(filters, parsedIds, { idsParamProvided })
|
|
1668
1669
|
const withDeleted = parseBooleanToken((queryParams as any).withDeleted) === true
|
|
1669
1670
|
profiler.mark('filters_ready', { withDeleted })
|
|
1670
1671
|
if (
|
|
@@ -1951,7 +1952,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1951
1952
|
: mergeAdvancedFilters(fallbackBaseFilters as Record<string, unknown>, validated as Record<string, unknown>) as Where<any>
|
|
1952
1953
|
const mergedFallbackFilters = exportFullRequested
|
|
1953
1954
|
? fallbackFilters
|
|
1954
|
-
: mergeIdFilter(fallbackFilters, parsedIds)
|
|
1955
|
+
: mergeIdFilter(fallbackFilters, parsedIds, { idsParamProvided })
|
|
1955
1956
|
const ormFilters = translateFiltersForOrm(
|
|
1956
1957
|
mergedFallbackFilters as Record<string, any>,
|
|
1957
1958
|
em,
|
package/src/lib/crud/ids.ts
CHANGED
|
@@ -49,11 +49,32 @@ export function parseIdsParam(raw: unknown, maxIds: number = MAX_IDS_PER_REQUEST
|
|
|
49
49
|
return parsed.slice(0, safeMax)
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Whether an `?ids=` param was supplied at all (a non-empty string), regardless
|
|
54
|
+
* of whether any value survived UUID validation. Lets a caller tell "no ids
|
|
55
|
+
* filter requested" apart from "ids filter requested but every value was
|
|
56
|
+
* malformed" — the latter must match nothing, not fall back to the full list
|
|
57
|
+
* (#4143 Finding 3).
|
|
58
|
+
*/
|
|
59
|
+
export function isIdsParamProvided(raw: unknown): boolean {
|
|
60
|
+
return typeof raw === 'string' && raw.trim().length > 0
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
export function mergeIdFilter<Fields extends Record<string, unknown>>(
|
|
53
64
|
existingFilters: Where<Fields>,
|
|
54
65
|
parsedIds: string[],
|
|
66
|
+
options?: { idsParamProvided?: boolean },
|
|
55
67
|
): Where<Fields> {
|
|
56
|
-
if (parsedIds.length === 0)
|
|
68
|
+
if (parsedIds.length === 0) {
|
|
69
|
+
// The `?ids=` param was supplied but nothing survived UUID validation.
|
|
70
|
+
// Match nothing, mirroring a valid-but-unknown id returning zero rows,
|
|
71
|
+
// rather than silently dropping the filter and returning the full list
|
|
72
|
+
// (record-count side channel — #4143 Finding 3).
|
|
73
|
+
if (options?.idsParamProvided) {
|
|
74
|
+
return { ...existingFilters, id: { $in: [] } }
|
|
75
|
+
}
|
|
76
|
+
return existingFilters
|
|
77
|
+
}
|
|
57
78
|
|
|
58
79
|
const existingFilter = (existingFilters as Record<string, unknown>).id
|
|
59
80
|
const existingIds = readExistingIds(existingFilter)
|