@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.
@@ -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
- if (typeof raw !== "string" || raw.trim().length === 0) return [];
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(raw.split(","));
40
+ const parsed = normalizeIdList(values);
39
41
  return parsed.slice(0, safeMax);
40
42
  }
41
43
  function isIdsParamProvided(raw) {
42
- return typeof raw === "string" && raw.trim().length > 0;
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) {
@@ -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\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;",
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
+ }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.7.1-develop.7102.1.b41f7e3e51";
1
+ const APP_VERSION = "0.7.1-develop.7103.1.41ff100d93";
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.7.1-develop.7102.1.b41f7e3e51';\nexport const appVersion = APP_VERSION;\n"],
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.7102.1.b41f7e3e51",
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.7102.1.b41f7e3e51",
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
+ })
@@ -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 = Object.fromEntries(url.searchParams.entries())
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: Object.fromEntries(url.searchParams.entries()) }
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: Object.fromEntries(url.searchParams.entries()),
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' ? Object.fromEntries(url.searchParams.entries()) : undefined,
3010
+ query: idFrom === 'query' ? buildQueryParams(url.searchParams) : undefined,
3010
3011
  })
3011
3012
  if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
3012
3013
  interceptorRequestPayload = beforeInterceptors.requestPayload
@@ -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
- if (typeof raw !== 'string' || raw.trim().length === 0) return []
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(raw.split(','))
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), 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).
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
- return typeof raw === 'string' && raw.trim().length > 0
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
+ }