@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.
@@ -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 mergeIdFilter(existingFilters, parsedIds) {
42
- if (parsedIds.length === 0) return existingFilters;
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
  };
@@ -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,WACe;AACf,MAAI,UAAU,WAAW,EAAG,QAAO;AAEnC,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;",
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
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.7-develop.6597.1.b8f069b7fe";
1
+ const APP_VERSION = "0.6.7-develop.6603.1.78e5b7a65d";
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.6597.1.b8f069b7fe'\nexport const appVersion = APP_VERSION\n"],
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.6597.1.b8f069b7fe",
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.6597.1.b8f069b7fe",
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
  })
@@ -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,
@@ -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) return existingFilters
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)