@open-mercato/shared 0.7.1-develop.7102.1.b41f7e3e51 → 0.7.1-develop.7103.1.41ff100d93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +8 -0
- package/dist/lib/crud/factory.js +5 -4
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/ids.js +6 -3
- package/dist/lib/crud/ids.js.map +2 -2
- package/dist/lib/crud/query-params.js +33 -0
- package/dist/lib/crud/query-params.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +71 -0
- package/src/lib/crud/__tests__/ids.test.ts +29 -0
- package/src/lib/crud/__tests__/query-params.test.ts +98 -0
- package/src/lib/crud/factory.ts +5 -4
- package/src/lib/crud/ids.ts +11 -8
- package/src/lib/crud/query-params.ts +75 -0
package/dist/lib/crud/ids.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toQueryValueList } from "./query-params.js";
|
|
1
2
|
const MAX_IDS_PER_REQUEST = 200;
|
|
2
3
|
const 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;
|
|
3
4
|
function isUuid(value) {
|
|
@@ -33,13 +34,15 @@ function readExistingIds(filter) {
|
|
|
33
34
|
return null;
|
|
34
35
|
}
|
|
35
36
|
function parseIdsParam(raw, maxIds = MAX_IDS_PER_REQUEST) {
|
|
36
|
-
|
|
37
|
+
const values = toQueryValueList(raw);
|
|
38
|
+
if (values.length === 0) return [];
|
|
37
39
|
const safeMax = Number.isFinite(maxIds) && maxIds > 0 ? Math.floor(maxIds) : MAX_IDS_PER_REQUEST;
|
|
38
|
-
const parsed = normalizeIdList(
|
|
40
|
+
const parsed = normalizeIdList(values);
|
|
39
41
|
return parsed.slice(0, safeMax);
|
|
40
42
|
}
|
|
41
43
|
function isIdsParamProvided(raw) {
|
|
42
|
-
|
|
44
|
+
const occurrences = Array.isArray(raw) ? raw : [raw];
|
|
45
|
+
return occurrences.some((value) => typeof value === "string" && value.trim().length > 0);
|
|
43
46
|
}
|
|
44
47
|
function mergeIdFilter(existingFilters, parsedIds, options) {
|
|
45
48
|
if (parsedIds.length === 0) {
|
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
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { Where } from '@open-mercato/shared/lib/query/types'\nimport { toQueryValueList } from './query-params'\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 const values = toQueryValueList(raw)\n if (values.length === 0) return []\n const safeMax = Number.isFinite(maxIds) && maxIds > 0 ? Math.floor(maxIds) : MAX_IDS_PER_REQUEST\n const parsed = normalizeIdList(values)\n return parsed.slice(0, safeMax)\n}\n\n/**\n * Whether an `?ids=` param was supplied at all (a non-empty string, or repeated\n * `?ids=` occurrences), regardless of whether any value survived UUID\n * validation. Lets a caller tell \"no ids filter requested\" apart from \"ids\n * filter requested but every value was malformed\" \u2014 the latter must match\n * nothing, not fall back to the full list (#4143 Finding 3).\n */\nexport function isIdsParamProvided(raw: unknown): boolean {\n const occurrences = Array.isArray(raw) ? raw : [raw]\n return occurrences.some((value) => typeof value === 'string' && value.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": "AACA,SAAS,wBAAwB;AAE1B,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,QAAM,SAAS,iBAAiB,GAAG;AACnC,MAAI,OAAO,WAAW,EAAG,QAAO,CAAC;AACjC,QAAM,UAAU,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI,KAAK,MAAM,MAAM,IAAI;AAC7E,QAAM,SAAS,gBAAgB,MAAM;AACrC,SAAO,OAAO,MAAM,GAAG,OAAO;AAChC;AASO,SAAS,mBAAmB,KAAuB;AACxD,QAAM,cAAc,MAAM,QAAQ,GAAG,IAAI,MAAM,CAAC,GAAG;AACnD,SAAO,YAAY,KAAK,CAAC,UAAU,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,CAAC;AACzF;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
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { parseCommaSeparatedList } from "@open-mercato/shared/lib/string";
|
|
2
|
+
function buildQueryParams(searchParams) {
|
|
3
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
4
|
+
searchParams.forEach((value, key) => {
|
|
5
|
+
const existing = grouped.get(key);
|
|
6
|
+
if (existing) existing.push(value);
|
|
7
|
+
else grouped.set(key, [value]);
|
|
8
|
+
});
|
|
9
|
+
return Object.fromEntries(
|
|
10
|
+
Array.from(grouped, ([key, values]) => [
|
|
11
|
+
key,
|
|
12
|
+
values.length === 1 ? values[0] : values
|
|
13
|
+
])
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
function toQueryValueList(raw) {
|
|
17
|
+
const candidates = Array.isArray(raw) ? raw : [raw];
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const candidate of candidates) {
|
|
20
|
+
if (typeof candidate !== "string") continue;
|
|
21
|
+
out.push(...parseCommaSeparatedList(candidate));
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
function readQueryParamList(searchParams, key) {
|
|
26
|
+
return toQueryValueList(searchParams.getAll(key));
|
|
27
|
+
}
|
|
28
|
+
export {
|
|
29
|
+
buildQueryParams,
|
|
30
|
+
readQueryParamList,
|
|
31
|
+
toQueryValueList
|
|
32
|
+
};
|
|
33
|
+
//# sourceMappingURL=query-params.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/crud/query-params.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Query-string parsing helpers shared by every `makeCrudRoute` handler and by\n * routes that read `URLSearchParams` directly.\n *\n * `Object.fromEntries(url.searchParams.entries())` keeps only the last value of\n * a repeated key, so `?status=win&status=loose` reached route schemas as\n * `'loose'` and every earlier selection was dropped before validation ran\n * (#5548). `buildQueryParams` groups repeats instead.\n */\n\nimport { parseCommaSeparatedList } from '@open-mercato/shared/lib/string'\n\nexport type QueryParamValue = string | string[]\n\n/**\n * Group a query string into a plain object, preserving repeated keys.\n *\n * A key that occurs once keeps its raw string value \u2014 that is what today's\n * `z.string()` schemas expect and nothing about them has to change. A key that\n * occurs two or more times becomes the array of its values, which is what a\n * `z.array(z.string())` (or `z.union([z.string(), z.array(z.string())])`)\n * branch has always advertised.\n *\n * Values are never split on commas here: `?ids=a,b` and `?search=foo,bar` carry\n * comma semantics that belong to the individual route, not to the generic\n * parser. Use `readQueryParamList` / `toQueryValueList` where a field's contract\n * says a comma separates values.\n *\n * Repeated values are treated as a set by the list response cache: its key\n * serializer sorts them, so `?k=a&k=b` and `?k=b&k=a` share one entry even\n * though the schema now receives `['a','b']` and `['b','a']` respectively. Do\n * not declare a repeated param whose order carries meaning.\n *\n * The result is assembled with `Object.fromEntries`, which defines own data\n * properties. Assigning into an object literal instead would run the\n * `__proto__` setter, so `?__proto__=a&__proto__=b` would replace the returned\n * object's prototype and drop the key rather than carrying it to the schema.\n */\nexport function buildQueryParams(searchParams: URLSearchParams): Record<string, QueryParamValue> {\n const grouped = new Map<string, string[]>()\n searchParams.forEach((value, key) => {\n const existing = grouped.get(key)\n if (existing) existing.push(value)\n else grouped.set(key, [value])\n })\n return Object.fromEntries(\n Array.from(grouped, ([key, values]): [string, QueryParamValue] => [\n key,\n values.length === 1 ? values[0] : values,\n ]),\n )\n}\n\n/**\n * Normalize a raw query value \u2014 a single string, an array of repeated values,\n * or nothing \u2014 into the list it stands for. Comma-separated and repeated forms\n * are equivalent here, so `?k=a,b&k=c` yields `['a', 'b', 'c']`.\n */\nexport function toQueryValueList(raw: unknown): string[] {\n const candidates = Array.isArray(raw) ? raw : [raw]\n const out: string[] = []\n for (const candidate of candidates) {\n if (typeof candidate !== 'string') continue\n out.push(...parseCommaSeparatedList(candidate))\n }\n return out\n}\n\n/**\n * Read every value supplied for `key`, accepting both the repeated\n * (`?k=a&k=b`) and the comma-separated (`?k=a,b`) form.\n */\nexport function readQueryParamList(searchParams: URLSearchParams, key: string): string[] {\n return toQueryValueList(searchParams.getAll(key))\n}\n"],
|
|
5
|
+
"mappings": "AAUA,SAAS,+BAA+B;AA4BjC,SAAS,iBAAiB,cAAgE;AAC/F,QAAM,UAAU,oBAAI,IAAsB;AAC1C,eAAa,QAAQ,CAAC,OAAO,QAAQ;AACnC,UAAM,WAAW,QAAQ,IAAI,GAAG;AAChC,QAAI,SAAU,UAAS,KAAK,KAAK;AAAA,QAC5B,SAAQ,IAAI,KAAK,CAAC,KAAK,CAAC;AAAA,EAC/B,CAAC;AACD,SAAO,OAAO;AAAA,IACZ,MAAM,KAAK,SAAS,CAAC,CAAC,KAAK,MAAM,MAAiC;AAAA,MAChE;AAAA,MACA,OAAO,WAAW,IAAI,OAAO,CAAC,IAAI;AAAA,IACpC,CAAC;AAAA,EACH;AACF;AAOO,SAAS,iBAAiB,KAAwB;AACvD,QAAM,aAAa,MAAM,QAAQ,GAAG,IAAI,MAAM,CAAC,GAAG;AAClD,QAAM,MAAgB,CAAC;AACvB,aAAW,aAAa,YAAY;AAClC,QAAI,OAAO,cAAc,SAAU;AACnC,QAAI,KAAK,GAAG,wBAAwB,SAAS,CAAC;AAAA,EAChD;AACA,SAAO;AACT;AAMO,SAAS,mBAAmB,cAA+B,KAAuB;AACvF,SAAO,iBAAiB,aAAa,OAAO,GAAG,CAAC;AAClD;",
|
|
6
|
+
"names": []
|
|
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.7.1-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.7.1-develop.7103.1.41ff100d93';\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.7.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7103.1.41ff100d93",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"@mikro-orm/core": "^7.1.8",
|
|
110
110
|
"@mikro-orm/decorators": "^7.1.8",
|
|
111
111
|
"@mikro-orm/postgresql": "^7.1.8",
|
|
112
|
-
"@open-mercato/cache": "0.7.1-develop.
|
|
112
|
+
"@open-mercato/cache": "0.7.1-develop.7103.1.41ff100d93",
|
|
113
113
|
"@types/html-to-text": "^9.0.4",
|
|
114
114
|
"@types/sanitize-html": "^2.16.1",
|
|
115
115
|
"dotenv": "^17.4.2",
|
|
@@ -370,6 +370,77 @@ describe('CRUD Factory', () => {
|
|
|
370
370
|
})
|
|
371
371
|
})
|
|
372
372
|
|
|
373
|
+
describe('repeated query parameters (#5548)', () => {
|
|
374
|
+
const makeFilterRoute = () => {
|
|
375
|
+
const seen: { status?: string | string[]; search?: string | string[] }[] = []
|
|
376
|
+
const route = makeCrudRoute({
|
|
377
|
+
metadata: { GET: { requireAuth: true } },
|
|
378
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
379
|
+
indexer: { entityType: 'example.todo' },
|
|
380
|
+
list: {
|
|
381
|
+
schema: querySchema.extend({
|
|
382
|
+
status: z.union([z.string(), z.array(z.string())]).optional(),
|
|
383
|
+
search: z.string().optional(),
|
|
384
|
+
}),
|
|
385
|
+
entityId: 'example.todo',
|
|
386
|
+
fields: ['id', 'title'],
|
|
387
|
+
buildFilters: (query) => {
|
|
388
|
+
seen.push({ status: (query as any).status, search: (query as any).search })
|
|
389
|
+
return {} as any
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
})
|
|
393
|
+
return { route, seen }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
it('hands the list schema every value of a repeated key', async () => {
|
|
397
|
+
const { route, seen } = makeFilterRoute()
|
|
398
|
+
await route.GET(new Request('http://x/api/example/todos?status=win&status=loose'))
|
|
399
|
+
expect(seen.at(-1)?.status).toEqual(['win', 'loose'])
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('still hands a plain string to a key that occurs once', async () => {
|
|
403
|
+
const { route, seen } = makeFilterRoute()
|
|
404
|
+
await route.GET(new Request('http://x/api/example/todos?status=win'))
|
|
405
|
+
expect(seen.at(-1)?.status).toBe('win')
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('leaves a comma-bearing scalar untouched so free-text filters survive', async () => {
|
|
409
|
+
const { route, seen } = makeFilterRoute()
|
|
410
|
+
await route.GET(new Request(`http://x/api/example/todos?search=${encodeURIComponent('Smith, John')}&status=win`))
|
|
411
|
+
expect(seen.at(-1)?.search).toBe('Smith, John')
|
|
412
|
+
expect(seen.at(-1)?.status).toBe('win')
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('rejects a repeated occurrence of a single-valued param with 400 instead of silently keeping one value', async () => {
|
|
416
|
+
const { route, seen } = makeFilterRoute()
|
|
417
|
+
const res = await route.GET(new Request('http://x/api/example/todos?search=Smith&search=John'))
|
|
418
|
+
expect(res.status).toBe(400)
|
|
419
|
+
const body = await res.json()
|
|
420
|
+
expect(body.error).toBe('Invalid input')
|
|
421
|
+
expect(
|
|
422
|
+
(body.details as { path: (string | number)[] }[]).some((issue) => issue.path.includes('search')),
|
|
423
|
+
).toBe(true)
|
|
424
|
+
expect(seen).toHaveLength(0)
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it('resolves each ordering of the same repeated filter to the values that ordering sent', async () => {
|
|
428
|
+
const { route, seen } = makeFilterRoute()
|
|
429
|
+
await route.GET(new Request('http://x/api/example/todos?status=win&status=loose'))
|
|
430
|
+
await route.GET(new Request('http://x/api/example/todos?status=loose&status=win'))
|
|
431
|
+
expect(seen.at(-2)?.status).toEqual(['win', 'loose'])
|
|
432
|
+
expect(seen.at(-1)?.status).toEqual(['loose', 'win'])
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('keeps a repeated ids filter instead of dropping it entirely', async () => {
|
|
436
|
+
const idA = '550e8400-e29b-41d4-a716-446655440001'
|
|
437
|
+
const idB = '550e8400-e29b-41d4-a716-446655440002'
|
|
438
|
+
await route.GET(new Request(`http://x/api/example/todos?ids=${idA}&ids=${idB}`))
|
|
439
|
+
const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
|
|
440
|
+
expect(queryArgs?.filters).toEqual({ id: { $in: [idA, idB] } })
|
|
441
|
+
})
|
|
442
|
+
})
|
|
443
|
+
|
|
373
444
|
it('GET resolves a function-form list.fields projection per request (#2233)', async () => {
|
|
374
445
|
const fieldsResolver = jest.fn((query: any) =>
|
|
375
446
|
query?.id ? ['id', 'title', 'is_done', 'snapshot'] : ['id', 'title'],
|
|
@@ -122,6 +122,35 @@ describe('crud ids helpers', () => {
|
|
|
122
122
|
expect(isIdsParamProvided(null)).toBe(false)
|
|
123
123
|
})
|
|
124
124
|
|
|
125
|
+
// #5548: once the factory groups repeated params, `?ids=a&ids=b` reaches these
|
|
126
|
+
// helpers as an array. Treating that as "not supplied" would silently drop the
|
|
127
|
+
// filter and return the full list — the same side channel #4143 closed.
|
|
128
|
+
it('parseIdsParam accepts the repeated-parameter form', () => {
|
|
129
|
+
expect(parseIdsParam([idA, idB])).toEqual([idA, idB])
|
|
130
|
+
expect(parseIdsParam([`${idA},${idB}`, idC])).toEqual([idA, idB, idC])
|
|
131
|
+
expect(parseIdsParam([idA, idA])).toEqual([idA])
|
|
132
|
+
expect(parseIdsParam([idA, idB, idC], 2)).toEqual([idA, idB])
|
|
133
|
+
expect(parseIdsParam([])).toEqual([])
|
|
134
|
+
expect(parseIdsParam(['invalid', 'also-invalid'])).toEqual([])
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('isIdsParamProvided recognizes the repeated-parameter form', () => {
|
|
138
|
+
expect(isIdsParamProvided([idA, idB])).toBe(true)
|
|
139
|
+
expect(isIdsParamProvided(['not-a-uuid'])).toBe(true)
|
|
140
|
+
expect(isIdsParamProvided([])).toBe(false)
|
|
141
|
+
expect(isIdsParamProvided(['', ' '])).toBe(false)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// "Supplied" is about the raw occurrence, not about what survives parsing:
|
|
145
|
+
// `?ids=,,,` carries no usable value but was still requested, so it must match
|
|
146
|
+
// nothing rather than fall back to the unfiltered list.
|
|
147
|
+
it('isIdsParamProvided treats a value that parses to nothing as still supplied', () => {
|
|
148
|
+
expect(isIdsParamProvided(',,,')).toBe(true)
|
|
149
|
+
expect(parseIdsParam(',,,')).toEqual([])
|
|
150
|
+
expect(isIdsParamProvided([',', ','])).toBe(true)
|
|
151
|
+
expect(parseIdsParam([',', ','])).toEqual([])
|
|
152
|
+
})
|
|
153
|
+
|
|
125
154
|
it('mergeIdFilter matches nothing when ids param was provided but all invalid', () => {
|
|
126
155
|
// Malformed input: parseIdsParam yields [], but the param WAS provided.
|
|
127
156
|
expect(mergeIdFilter({}, parseIdsParam('not-a-uuid'), { idsParamProvided: true })).toEqual({
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { buildQueryParams, readQueryParamList, toQueryValueList } from '@open-mercato/shared/lib/crud/query-params'
|
|
2
|
+
|
|
3
|
+
describe('buildQueryParams', () => {
|
|
4
|
+
it('keeps a key that occurs once as a plain string', () => {
|
|
5
|
+
const params = new URLSearchParams('status=win&page=2')
|
|
6
|
+
expect(buildQueryParams(params)).toEqual({ status: 'win', page: '2' })
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('keeps every value of a repeated key instead of the last one (#5548)', () => {
|
|
10
|
+
const params = new URLSearchParams('status=win&status=loose')
|
|
11
|
+
expect(buildQueryParams(params)).toEqual({ status: ['win', 'loose'] })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('preserves the order the values were supplied in', () => {
|
|
15
|
+
expect(buildQueryParams(new URLSearchParams('status=loose&status=win'))).toEqual({
|
|
16
|
+
status: ['loose', 'win'],
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('does not split a single value on commas, so comma contracts stay intact', () => {
|
|
21
|
+
const params = new URLSearchParams('ids=a,b&search=Smith, John')
|
|
22
|
+
expect(buildQueryParams(params)).toEqual({ ids: 'a,b', search: 'Smith, John' })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('returns an empty object for an empty query string', () => {
|
|
26
|
+
expect(buildQueryParams(new URLSearchParams(''))).toEqual({})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('keeps an empty repeated value so the caller can decide what it means', () => {
|
|
30
|
+
expect(buildQueryParams(new URLSearchParams('status=&status=win'))).toEqual({
|
|
31
|
+
status: ['', 'win'],
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// A plain `out[key] = value` assignment runs the `__proto__` setter, which
|
|
36
|
+
// would swap the returned object's prototype for the array and drop the key.
|
|
37
|
+
// `Object.fromEntries` defines own data properties, matching what the parse
|
|
38
|
+
// site did before this change.
|
|
39
|
+
it('carries a __proto__ key as an own property instead of touching the prototype', () => {
|
|
40
|
+
const repeated = buildQueryParams(new URLSearchParams('__proto__=a&__proto__=b'))
|
|
41
|
+
expect(Object.getPrototypeOf(repeated)).toBe(Object.prototype)
|
|
42
|
+
expect(Object.prototype.hasOwnProperty.call(repeated, '__proto__')).toBe(true)
|
|
43
|
+
expect(Object.getOwnPropertyDescriptor(repeated, '__proto__')?.value).toEqual(['a', 'b'])
|
|
44
|
+
|
|
45
|
+
const single = buildQueryParams(new URLSearchParams('__proto__=a'))
|
|
46
|
+
expect(Object.getPrototypeOf(single)).toBe(Object.prototype)
|
|
47
|
+
expect(Object.getOwnPropertyDescriptor(single, '__proto__')?.value).toBe('a')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('rejects the Object.fromEntries shape this replaced', () => {
|
|
51
|
+
// Regression guard: reverting the parse site to
|
|
52
|
+
// `Object.fromEntries(url.searchParams.entries())` makes this fail.
|
|
53
|
+
const params = new URLSearchParams('status=win&status=loose')
|
|
54
|
+
expect(buildQueryParams(params)).not.toEqual(Object.fromEntries(params.entries()))
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('toQueryValueList', () => {
|
|
59
|
+
it('turns a single string into a one-entry list', () => {
|
|
60
|
+
expect(toQueryValueList('win')).toEqual(['win'])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('splits the comma form', () => {
|
|
64
|
+
expect(toQueryValueList('win,loose')).toEqual(['win', 'loose'])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('flattens repeated values', () => {
|
|
68
|
+
expect(toQueryValueList(['win', 'loose'])).toEqual(['win', 'loose'])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('treats the mixed form as one flat list', () => {
|
|
72
|
+
expect(toQueryValueList(['a,b', 'c'])).toEqual(['a', 'b', 'c'])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('trims entries and drops empty ones', () => {
|
|
76
|
+
expect(toQueryValueList([' win ', '', ' , ', 'loose'])).toEqual(['win', 'loose'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('ignores non-string input', () => {
|
|
80
|
+
expect(toQueryValueList(undefined)).toEqual([])
|
|
81
|
+
expect(toQueryValueList(null)).toEqual([])
|
|
82
|
+
expect(toQueryValueList(42)).toEqual([])
|
|
83
|
+
expect(toQueryValueList([1, 'win'])).toEqual(['win'])
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('readQueryParamList', () => {
|
|
88
|
+
it('reads the repeated and the comma form as the same list', () => {
|
|
89
|
+
const repeated = new URLSearchParams('status=win&status=loose')
|
|
90
|
+
const comma = new URLSearchParams('status=win,loose')
|
|
91
|
+
expect(readQueryParamList(repeated, 'status')).toEqual(['win', 'loose'])
|
|
92
|
+
expect(readQueryParamList(comma, 'status')).toEqual(['win', 'loose'])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('returns an empty list for a key that was not supplied', () => {
|
|
96
|
+
expect(readQueryParamList(new URLSearchParams('status=win'), 'ownerUserId')).toEqual([])
|
|
97
|
+
})
|
|
98
|
+
})
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -69,6 +69,7 @@ import type { EnricherContext } from './response-enricher'
|
|
|
69
69
|
import type { ApiInterceptorMethod, InterceptorRequest, InterceptorResponse } from './api-interceptor'
|
|
70
70
|
import { runApiInterceptorsAfter, runApiInterceptorsBefore } from './interceptor-runner'
|
|
71
71
|
import { mergeIdFilter, parseIdsParam, isIdsParamProvided } from './ids'
|
|
72
|
+
import { buildQueryParams } from './query-params'
|
|
72
73
|
import { mergeAdvancedFilters } from './advanced-filter-integration'
|
|
73
74
|
import { parseExtensionHeaders } from '../umes/extension-headers'
|
|
74
75
|
import { createGenericOptimisticLockReader } from './optimistic-lock'
|
|
@@ -1516,7 +1517,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1516
1517
|
return json({ error: 'Not implemented' }, { status: 501 })
|
|
1517
1518
|
}
|
|
1518
1519
|
const url = new URL(request.url)
|
|
1519
|
-
const rawQueryParams =
|
|
1520
|
+
const rawQueryParams = buildQueryParams(url.searchParams)
|
|
1520
1521
|
profiler.mark('query_parsed')
|
|
1521
1522
|
let validated = opts.list.schema.parse(rawQueryParams)
|
|
1522
1523
|
profiler.mark('query_validated')
|
|
@@ -2870,7 +2871,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2870
2871
|
if (useCommand) {
|
|
2871
2872
|
const action = opts.actions!.delete!
|
|
2872
2873
|
const body = await request.json().catch(() => ({}))
|
|
2873
|
-
const raw = { body, query:
|
|
2874
|
+
const raw = { body, query: buildQueryParams(url.searchParams) }
|
|
2874
2875
|
const parsed = action.schema ? action.schema.parse(raw) : raw
|
|
2875
2876
|
const interceptorInput =
|
|
2876
2877
|
parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).body && typeof (parsed as Record<string, unknown>).body === 'object'
|
|
@@ -2889,7 +2890,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2889
2890
|
const interceptedBody = interceptorRequestPayload.body ?? {}
|
|
2890
2891
|
const reparsedRaw = {
|
|
2891
2892
|
body: interceptedBody,
|
|
2892
|
-
query:
|
|
2893
|
+
query: buildQueryParams(url.searchParams),
|
|
2893
2894
|
}
|
|
2894
2895
|
const reparsed = action.schema ? action.schema.parse(reparsedRaw) : reparsedRaw
|
|
2895
2896
|
const input = action.mapInput ? await action.mapInput({ parsed: reparsed, raw: reparsedRaw, ctx }) : reparsed
|
|
@@ -3006,7 +3007,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
3006
3007
|
request,
|
|
3007
3008
|
method: 'DELETE',
|
|
3008
3009
|
body: idFrom === 'query' ? undefined : ({ id } as Record<string, unknown>),
|
|
3009
|
-
query: idFrom === 'query' ?
|
|
3010
|
+
query: idFrom === 'query' ? buildQueryParams(url.searchParams) : undefined,
|
|
3010
3011
|
})
|
|
3011
3012
|
if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
|
|
3012
3013
|
interceptorRequestPayload = beforeInterceptors.requestPayload
|
package/src/lib/crud/ids.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Where } from '@open-mercato/shared/lib/query/types'
|
|
2
|
+
import { toQueryValueList } from './query-params'
|
|
2
3
|
|
|
3
4
|
export const MAX_IDS_PER_REQUEST = 200
|
|
4
5
|
|
|
@@ -43,21 +44,23 @@ function readExistingIds(filter: unknown): string[] | null {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export function parseIdsParam(raw: unknown, maxIds: number = MAX_IDS_PER_REQUEST): string[] {
|
|
46
|
-
|
|
47
|
+
const values = toQueryValueList(raw)
|
|
48
|
+
if (values.length === 0) return []
|
|
47
49
|
const safeMax = Number.isFinite(maxIds) && maxIds > 0 ? Math.floor(maxIds) : MAX_IDS_PER_REQUEST
|
|
48
|
-
const parsed = normalizeIdList(
|
|
50
|
+
const parsed = normalizeIdList(values)
|
|
49
51
|
return parsed.slice(0, safeMax)
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
/**
|
|
53
|
-
* Whether an `?ids=` param was supplied at all (a non-empty string
|
|
54
|
-
* of whether any value survived UUID
|
|
55
|
-
*
|
|
56
|
-
* malformed" — the latter must match
|
|
57
|
-
* (#4143 Finding 3).
|
|
55
|
+
* Whether an `?ids=` param was supplied at all (a non-empty string, or repeated
|
|
56
|
+
* `?ids=` occurrences), regardless of whether any value survived UUID
|
|
57
|
+
* validation. Lets a caller tell "no ids filter requested" apart from "ids
|
|
58
|
+
* filter requested but every value was malformed" — the latter must match
|
|
59
|
+
* nothing, not fall back to the full list (#4143 Finding 3).
|
|
58
60
|
*/
|
|
59
61
|
export function isIdsParamProvided(raw: unknown): boolean {
|
|
60
|
-
|
|
62
|
+
const occurrences = Array.isArray(raw) ? raw : [raw]
|
|
63
|
+
return occurrences.some((value) => typeof value === 'string' && value.trim().length > 0)
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
export function mergeIdFilter<Fields extends Record<string, unknown>>(
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query-string parsing helpers shared by every `makeCrudRoute` handler and by
|
|
3
|
+
* routes that read `URLSearchParams` directly.
|
|
4
|
+
*
|
|
5
|
+
* `Object.fromEntries(url.searchParams.entries())` keeps only the last value of
|
|
6
|
+
* a repeated key, so `?status=win&status=loose` reached route schemas as
|
|
7
|
+
* `'loose'` and every earlier selection was dropped before validation ran
|
|
8
|
+
* (#5548). `buildQueryParams` groups repeats instead.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { parseCommaSeparatedList } from '@open-mercato/shared/lib/string'
|
|
12
|
+
|
|
13
|
+
export type QueryParamValue = string | string[]
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Group a query string into a plain object, preserving repeated keys.
|
|
17
|
+
*
|
|
18
|
+
* A key that occurs once keeps its raw string value — that is what today's
|
|
19
|
+
* `z.string()` schemas expect and nothing about them has to change. A key that
|
|
20
|
+
* occurs two or more times becomes the array of its values, which is what a
|
|
21
|
+
* `z.array(z.string())` (or `z.union([z.string(), z.array(z.string())])`)
|
|
22
|
+
* branch has always advertised.
|
|
23
|
+
*
|
|
24
|
+
* Values are never split on commas here: `?ids=a,b` and `?search=foo,bar` carry
|
|
25
|
+
* comma semantics that belong to the individual route, not to the generic
|
|
26
|
+
* parser. Use `readQueryParamList` / `toQueryValueList` where a field's contract
|
|
27
|
+
* says a comma separates values.
|
|
28
|
+
*
|
|
29
|
+
* Repeated values are treated as a set by the list response cache: its key
|
|
30
|
+
* serializer sorts them, so `?k=a&k=b` and `?k=b&k=a` share one entry even
|
|
31
|
+
* though the schema now receives `['a','b']` and `['b','a']` respectively. Do
|
|
32
|
+
* not declare a repeated param whose order carries meaning.
|
|
33
|
+
*
|
|
34
|
+
* The result is assembled with `Object.fromEntries`, which defines own data
|
|
35
|
+
* properties. Assigning into an object literal instead would run the
|
|
36
|
+
* `__proto__` setter, so `?__proto__=a&__proto__=b` would replace the returned
|
|
37
|
+
* object's prototype and drop the key rather than carrying it to the schema.
|
|
38
|
+
*/
|
|
39
|
+
export function buildQueryParams(searchParams: URLSearchParams): Record<string, QueryParamValue> {
|
|
40
|
+
const grouped = new Map<string, string[]>()
|
|
41
|
+
searchParams.forEach((value, key) => {
|
|
42
|
+
const existing = grouped.get(key)
|
|
43
|
+
if (existing) existing.push(value)
|
|
44
|
+
else grouped.set(key, [value])
|
|
45
|
+
})
|
|
46
|
+
return Object.fromEntries(
|
|
47
|
+
Array.from(grouped, ([key, values]): [string, QueryParamValue] => [
|
|
48
|
+
key,
|
|
49
|
+
values.length === 1 ? values[0] : values,
|
|
50
|
+
]),
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Normalize a raw query value — a single string, an array of repeated values,
|
|
56
|
+
* or nothing — into the list it stands for. Comma-separated and repeated forms
|
|
57
|
+
* are equivalent here, so `?k=a,b&k=c` yields `['a', 'b', 'c']`.
|
|
58
|
+
*/
|
|
59
|
+
export function toQueryValueList(raw: unknown): string[] {
|
|
60
|
+
const candidates = Array.isArray(raw) ? raw : [raw]
|
|
61
|
+
const out: string[] = []
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
if (typeof candidate !== 'string') continue
|
|
64
|
+
out.push(...parseCommaSeparatedList(candidate))
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read every value supplied for `key`, accepting both the repeated
|
|
71
|
+
* (`?k=a&k=b`) and the comma-separated (`?k=a,b`) form.
|
|
72
|
+
*/
|
|
73
|
+
export function readQueryParamList(searchParams: URLSearchParams, key: string): string[] {
|
|
74
|
+
return toQueryValueList(searchParams.getAll(key))
|
|
75
|
+
}
|