@open-mercato/shared 0.8.1-develop.7275.1.f772b944d4 → 0.8.1-develop.7295.1.d0e0e33014

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.
Files changed (56) hide show
  1. package/.turbo/turbo-build.log +2 -1
  2. package/AGENTS.md +6 -3
  3. package/build.mjs +4 -1
  4. package/dist/lib/crud/errors.js +6 -1
  5. package/dist/lib/crud/errors.js.map +2 -2
  6. package/dist/lib/crud/factory.js +22 -13
  7. package/dist/lib/crud/factory.js.map +2 -2
  8. package/dist/lib/custom-fields/kinds.js +113 -0
  9. package/dist/lib/custom-fields/kinds.js.map +7 -0
  10. package/dist/lib/encryption/indexDoc.js +12 -9
  11. package/dist/lib/encryption/indexDoc.js.map +2 -2
  12. package/dist/lib/location/countries.generated.js +265 -0
  13. package/dist/lib/location/countries.generated.js.map +7 -0
  14. package/dist/lib/location/countries.js +2 -13
  15. package/dist/lib/location/countries.js.map +2 -2
  16. package/dist/lib/navigation/pageReload.js +11 -0
  17. package/dist/lib/navigation/pageReload.js.map +7 -0
  18. package/dist/lib/query/encrypted-sort.js +4 -1
  19. package/dist/lib/query/encrypted-sort.js.map +2 -2
  20. package/dist/lib/query/engine.js +97 -10
  21. package/dist/lib/query/engine.js.map +3 -3
  22. package/dist/lib/schedule/interval.js +33 -0
  23. package/dist/lib/schedule/interval.js.map +7 -0
  24. package/dist/lib/schedule/invalidScheduleValue.js +19 -0
  25. package/dist/lib/schedule/invalidScheduleValue.js.map +7 -0
  26. package/dist/lib/search/config.js.map +2 -2
  27. package/dist/lib/search/containment.js +32 -0
  28. package/dist/lib/search/containment.js.map +7 -0
  29. package/dist/lib/version.js +1 -1
  30. package/dist/lib/version.js.map +1 -1
  31. package/package.json +3 -2
  32. package/scripts/generate-countries.mjs +75 -0
  33. package/src/lib/crud/__tests__/crud-factory.test.ts +177 -0
  34. package/src/lib/crud/__tests__/errors.test.ts +31 -1
  35. package/src/lib/crud/errors.ts +16 -0
  36. package/src/lib/crud/factory.ts +37 -15
  37. package/src/lib/custom-fields/__tests__/kinds.test.ts +208 -0
  38. package/src/lib/custom-fields/kinds.ts +205 -0
  39. package/src/lib/encryption/__tests__/indexDoc.custom-field-kinds.test.ts +127 -0
  40. package/src/lib/encryption/__tests__/indexDoc.test.ts +39 -0
  41. package/src/lib/encryption/indexDoc.ts +16 -7
  42. package/src/lib/location/__tests__/countries.test.ts +25 -0
  43. package/src/lib/location/countries.generated.ts +264 -0
  44. package/src/lib/location/countries.ts +2 -23
  45. package/src/lib/navigation/__tests__/pageReload.test.ts +58 -0
  46. package/src/lib/navigation/pageReload.ts +43 -0
  47. package/src/lib/query/__tests__/engine.test.ts +208 -16
  48. package/src/lib/query/encrypted-sort.ts +8 -1
  49. package/src/lib/query/engine.ts +172 -19
  50. package/src/lib/schedule/__tests__/interval.test.ts +37 -0
  51. package/src/lib/schedule/interval.ts +45 -0
  52. package/src/lib/schedule/invalidScheduleValue.ts +33 -0
  53. package/src/lib/search/__tests__/config.test.ts +27 -0
  54. package/src/lib/search/__tests__/containment.test.ts +61 -0
  55. package/src/lib/search/config.ts +28 -6
  56. package/src/lib/search/containment.ts +66 -0
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/navigation/pageReload.ts"],
4
+ "sourcesContent": ["/**\n * Boundary-crossing navigation helpers: they reload the document instead of handing the route to\n * the client router.\n *\n * The portal's `(frontend)` layout \u2014 the server component that decides the portal chrome and\n * resolves the customer session \u2014 sits *above* the `[...slug]` segment so portal navigation does\n * not remount the client subtree. A client-side `router.push`/`router.replace` therefore never\n * re-runs that layout, and any navigation that crosses the public/authenticated boundary would\n * keep rendering chrome computed for the page it came from: authenticated chrome over the login\n * page after a logout, or logged-out chrome over the dashboard after a login.\n *\n * Use these helpers for every boundary-crossing navigation (login, logout, signup completion,\n * invite acceptance, auth guards redirecting to login) so the layout re-runs and recomputes both\n * the chrome and the session. Same-side navigation should keep using the client router.\n *\n * Pick the one that matches the client-router call it stands in for, so session history behaves\n * the same way: `navigateWithPageReload` for `router.push`, `replaceWithPageReload` for\n * `router.replace`.\n */\n\n/** Full page load that adds a session-history entry \u2014 the document-loading counterpart of `router.push`. */\nexport function navigateWithPageReload(path: string): void {\n window.location.assign(path)\n}\n\n/**\n * Full page load that replaces the current session-history entry \u2014 the document-loading\n * counterpart of `router.replace`.\n *\n * Use this where the client-router call being replaced was a `router.replace`: an auth guard\n * sending an unauthenticated visitor to the login page, or the portal landing page forwarding a\n * signed-in customer to the dashboard.\n *\n * Note that for those call sites specifically, `navigateWithPageReload` was measured to behave\n * identically: they redirect from an effect during page load, and a browser treats a navigation\n * started then as a client redirect, replacing the entry rather than pushing it whichever method\n * is used. This helper exists so the call site states which semantics it means instead of relying\n * on that heuristic \u2014 and so a redirect that later moves behind a user gesture, where the\n * distinction does bite, keeps the behaviour it was written with.\n */\nexport function replaceWithPageReload(path: string): void {\n window.location.replace(path)\n}\n"],
5
+ "mappings": "AAqBO,SAAS,uBAAuB,MAAoB;AACzD,SAAO,SAAS,OAAO,IAAI;AAC7B;AAiBO,SAAS,sBAAsB,MAAoB;AACxD,SAAO,SAAS,QAAQ,IAAI;AAC9B;",
6
+ "names": []
7
+ }
@@ -5,7 +5,10 @@ function fieldNameCandidates(field) {
5
5
  const raw = String(field || "").trim();
6
6
  if (!raw) return [];
7
7
  const candidates = [raw, toSnakeCase(raw), toCamelCase(raw)];
8
- if (raw.startsWith("cf:")) candidates.push(raw.replace(/[^a-zA-Z0-9_]/g, "_"));
8
+ if (raw.startsWith("cf:")) {
9
+ const sanitized = raw.replace(/[^a-zA-Z0-9_]/g, "_");
10
+ candidates.push(sanitized, `${sanitized}__sort`);
11
+ }
9
12
  return Array.from(new Set(candidates));
10
13
  }
11
14
  function resolveEncryptedSortMaxRows() {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/query/encrypted-sort.ts"],
4
- "sourcesContent": ["import type { EntityId } from '@open-mercato/shared/modules/entities'\nimport { SortDir, type Sort } from './types'\n\nexport type QueryEncryptionService = {\n getEncryptedFieldNames?: (\n entityId: EntityId,\n tenantId?: string | null,\n organizationId?: string | null,\n ) => Promise<readonly string[]>\n isEnabled?: () => boolean\n}\n\nconst toSnakeCase = (value: string): string =>\n value.replace(/([A-Z])/g, '_$1').replace(/__/g, '_').toLowerCase()\n\nconst toCamelCase = (value: string): string =>\n value.replace(/_([a-z])/g, (_, c) => c.toUpperCase())\n\nexport function fieldNameCandidates(field: string): string[] {\n const raw = String(field || '').trim()\n if (!raw) return []\n const candidates = [raw, toSnakeCase(raw), toCamelCase(raw)]\n if (raw.startsWith('cf:')) candidates.push(raw.replace(/[^a-zA-Z0-9_]/g, '_'))\n return Array.from(new Set(candidates))\n}\n\n/**\n * Opt-in cap on how many candidate rows the plaintext-sort path may fetch\n * for sort-column decryption. Unset or invalid input means uncapped \u2014 the\n * default must stay byte-identical to the pre-cap behavior.\n */\nexport function resolveEncryptedSortMaxRows(): number | null {\n const raw = process.env.OM_ENCRYPTED_SORT_MAX_ROWS\n if (raw === undefined || raw === '') return null\n const parsed = Number(raw)\n if (!Number.isFinite(parsed) || parsed <= 0) return null\n return Math.floor(parsed)\n}\n\nexport async function resolveEncryptedSortFields(\n service: QueryEncryptionService | null | undefined,\n entity: EntityId,\n sortFields: readonly string[],\n tenantId?: string | null,\n organizationId?: string | null,\n): Promise<Set<string>> {\n if (!service?.getEncryptedFieldNames) return new Set()\n if (service.isEnabled && !service.isEnabled()) return new Set()\n const encrypted = await service.getEncryptedFieldNames(entity, tenantId ?? null, organizationId ?? null)\n const encryptedCandidates = new Set<string>()\n for (const field of encrypted) {\n for (const candidate of fieldNameCandidates(field)) encryptedCandidates.add(candidate)\n }\n const result = new Set<string>()\n for (const field of sortFields) {\n if (field.startsWith('cf:')) continue\n const matches = fieldNameCandidates(field).some((candidate) => encryptedCandidates.has(candidate))\n if (matches) result.add(field)\n }\n return result\n}\n\nfunction readField(row: Record<string, unknown>, field: string): unknown {\n for (const candidate of fieldNameCandidates(field)) {\n if (Object.prototype.hasOwnProperty.call(row, candidate)) return row[candidate]\n }\n return undefined\n}\n\nfunction compareValues(left: unknown, right: unknown): number {\n const leftMissing = left === null || left === undefined\n const rightMissing = right === null || right === undefined\n if (leftMissing && rightMissing) return 0\n if (leftMissing) return 1\n if (rightMissing) return -1\n if (left instanceof Date && right instanceof Date) return left.getTime() - right.getTime()\n if (typeof left === 'number' && typeof right === 'number') return left - right\n if (typeof left === 'boolean' && typeof right === 'boolean') return Number(left) - Number(right)\n return String(left).localeCompare(String(right), undefined, {\n sensitivity: 'base',\n numeric: true,\n })\n}\n\nexport function sortRowsInMemory<T extends Record<string, unknown>>(\n rows: readonly T[],\n sorts: readonly Sort[],\n): T[] {\n return [...rows].sort((left, right) => {\n for (const sort of sorts) {\n const direction = sort.dir === SortDir.Desc ? -1 : 1\n const compared = compareValues(readField(left, sort.field), readField(right, sort.field))\n if (compared !== 0) return compared * direction\n }\n const leftId = readField(left, 'id')\n const rightId = readField(right, 'id')\n return compareValues(leftId, rightId)\n })\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityId } from '@open-mercato/shared/modules/entities'\nimport { SortDir, type Sort } from './types'\n\nexport type QueryEncryptionService = {\n getEncryptedFieldNames?: (\n entityId: EntityId,\n tenantId?: string | null,\n organizationId?: string | null,\n ) => Promise<readonly string[]>\n isEnabled?: () => boolean\n}\n\nconst toSnakeCase = (value: string): string =>\n value.replace(/([A-Z])/g, '_$1').replace(/__/g, '_').toLowerCase()\n\nconst toCamelCase = (value: string): string =>\n value.replace(/_([a-z])/g, (_, c) => c.toUpperCase())\n\nexport function fieldNameCandidates(field: string): string[] {\n const raw = String(field || '').trim()\n if (!raw) return []\n const candidates = [raw, toSnakeCase(raw), toCamelCase(raw)]\n if (raw.startsWith('cf:')) {\n const sanitized = raw.replace(/[^a-zA-Z0-9_]/g, '_')\n // The ORM query engine's plaintext-sort candidate scan projects a `cf:`\n // sort key only under its dedicated `<alias>__sort` projection alias, never\n // the plain jsonb-aggregate alias \u2014 so the in-memory sort needs that exact\n // name to find the value on the phase-1 candidate rows (#5674 review).\n candidates.push(sanitized, `${sanitized}__sort`)\n }\n return Array.from(new Set(candidates))\n}\n\n/**\n * Opt-in cap on how many candidate rows the plaintext-sort path may fetch\n * for sort-column decryption. Unset or invalid input means uncapped \u2014 the\n * default must stay byte-identical to the pre-cap behavior.\n */\nexport function resolveEncryptedSortMaxRows(): number | null {\n const raw = process.env.OM_ENCRYPTED_SORT_MAX_ROWS\n if (raw === undefined || raw === '') return null\n const parsed = Number(raw)\n if (!Number.isFinite(parsed) || parsed <= 0) return null\n return Math.floor(parsed)\n}\n\nexport async function resolveEncryptedSortFields(\n service: QueryEncryptionService | null | undefined,\n entity: EntityId,\n sortFields: readonly string[],\n tenantId?: string | null,\n organizationId?: string | null,\n): Promise<Set<string>> {\n if (!service?.getEncryptedFieldNames) return new Set()\n if (service.isEnabled && !service.isEnabled()) return new Set()\n const encrypted = await service.getEncryptedFieldNames(entity, tenantId ?? null, organizationId ?? null)\n const encryptedCandidates = new Set<string>()\n for (const field of encrypted) {\n for (const candidate of fieldNameCandidates(field)) encryptedCandidates.add(candidate)\n }\n const result = new Set<string>()\n for (const field of sortFields) {\n if (field.startsWith('cf:')) continue\n const matches = fieldNameCandidates(field).some((candidate) => encryptedCandidates.has(candidate))\n if (matches) result.add(field)\n }\n return result\n}\n\nfunction readField(row: Record<string, unknown>, field: string): unknown {\n for (const candidate of fieldNameCandidates(field)) {\n if (Object.prototype.hasOwnProperty.call(row, candidate)) return row[candidate]\n }\n return undefined\n}\n\nfunction compareValues(left: unknown, right: unknown): number {\n const leftMissing = left === null || left === undefined\n const rightMissing = right === null || right === undefined\n if (leftMissing && rightMissing) return 0\n if (leftMissing) return 1\n if (rightMissing) return -1\n if (left instanceof Date && right instanceof Date) return left.getTime() - right.getTime()\n if (typeof left === 'number' && typeof right === 'number') return left - right\n if (typeof left === 'boolean' && typeof right === 'boolean') return Number(left) - Number(right)\n return String(left).localeCompare(String(right), undefined, {\n sensitivity: 'base',\n numeric: true,\n })\n}\n\nexport function sortRowsInMemory<T extends Record<string, unknown>>(\n rows: readonly T[],\n sorts: readonly Sort[],\n): T[] {\n return [...rows].sort((left, right) => {\n for (const sort of sorts) {\n const direction = sort.dir === SortDir.Desc ? -1 : 1\n const compared = compareValues(readField(left, sort.field), readField(right, sort.field))\n if (compared !== 0) return compared * direction\n }\n const leftId = readField(left, 'id')\n const rightId = readField(right, 'id')\n return compareValues(leftId, rightId)\n })\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -11,11 +11,13 @@ import {
11
11
  isSearchFilterOp
12
12
  } from "../search/availability.js";
13
13
  import { tokenizeText } from "../search/tokenize.js";
14
+ import { buildContainmentPatterns } from "../search/containment.js";
14
15
  import { fieldNameCandidates } from "./encrypted-sort.js";
15
16
  import { isTenantDataEncryptionEnabled } from "../encryption/toggles.js";
16
17
  import { runBeforeQueryPipeline, runAfterQueryPipeline } from "./query-extension-runner.js";
17
18
  import {
18
19
  buildCustomFieldDefinitionIndexFromRows,
20
+ normalizeDefinitionKey,
19
21
  resolveCfDefIndexOrgCandidates
20
22
  } from "../crud/custom-field-definition-index.js";
21
23
  import { warnOnCiphertextLikeFallback } from "./ciphertext-search-warning.js";
@@ -158,6 +160,17 @@ function buildFilterableCustomFieldJoins(sources) {
158
160
  }];
159
161
  });
160
162
  }
163
+ const NUMERIC_CF_SORT_KINDS = /* @__PURE__ */ new Set(["integer", "float"]);
164
+ function pickCfSortKind(defs) {
165
+ if (!defs || !defs.length) return null;
166
+ let winner = null;
167
+ for (const def of defs) {
168
+ if (!def.kind) continue;
169
+ const specificity = def.organizationId != null ? 2 : def.tenantId != null ? 1 : 0;
170
+ if (!winner || specificity >= winner.specificity) winner = { kind: def.kind, specificity };
171
+ }
172
+ return winner?.kind ?? null;
173
+ }
161
174
  function computeCustomFieldScore(cfg, kind, entityIndex) {
162
175
  const listVisibleScore = cfg.listVisible === false ? 0 : 1;
163
176
  const formEditableScore = cfg.formEditable === false ? 0 : 1;
@@ -409,6 +422,14 @@ class BasicQueryEngine {
409
422
  });
410
423
  }
411
424
  }
425
+ if ((op === "like" || op === "ilike") && typeof value === "string" && searchActive && fieldName && encryptedLikeFields !== null && !isEncryptedLikeField(encryptedLikeFields, fieldName)) {
426
+ const patterns = buildContainmentPatterns(value);
427
+ if (patterns.length > 1) {
428
+ let next = builder;
429
+ for (const pattern of patterns) next = this.applyColumnOp(next, column, op, pattern);
430
+ return next;
431
+ }
432
+ }
412
433
  return this.applyColumnOp(builder, column, op, value);
413
434
  };
414
435
  const applyJoinFilterOp = async (builder, filter, _qualified, join) => {
@@ -680,10 +701,46 @@ class BasicQueryEngine {
680
701
  if (!keySource.has(row.key)) keySource.set(row.key, source);
681
702
  }
682
703
  }
704
+ const cfSortKeys = Array.from(new Set(
705
+ resolvedSorts.filter((sort) => sort.field.startsWith("cf:")).map((sort) => sort.field.slice(3))
706
+ ));
707
+ const cfSortKinds = /* @__PURE__ */ new Map();
708
+ if (cfSortKeys.length > 0) {
709
+ const keysNeedingQuery = [];
710
+ for (const key of cfSortKeys) {
711
+ const kind = resolvedCustomFieldDefinitions2 ? pickCfSortKind(resolvedCustomFieldDefinitions2.index.get(normalizeDefinitionKey(key))) : null;
712
+ if (kind) cfSortKinds.set(key, kind);
713
+ else keysNeedingQuery.push(key);
714
+ }
715
+ if (keysNeedingQuery.length > 0) {
716
+ const sortEntityIds = Array.from(new Set(
717
+ keysNeedingQuery.map((key) => keySource.get(key)?.entityId).filter((id) => Boolean(id)).map((id) => String(id))
718
+ ));
719
+ if (sortEntityIds.length > 0) {
720
+ const cfSortOrgCandidates = resolveCfDefIndexOrgCandidates(opts.organizationIds, opts.organizationId ?? null);
721
+ const kindRows = await db.selectFrom("custom_field_defs").select(["key", "kind", "organization_id", "tenant_id"]).where("entity_id", "in", sortEntityIds).where("key", "in", keysNeedingQuery).where("is_active", "=", true).where((eb) => eb.or([
722
+ eb("tenant_id", "=", tenantId),
723
+ eb("tenant_id", "is", null)
724
+ ])).where((eb) => cfSortOrgCandidates.length ? eb.or([
725
+ eb("organization_id", "is", null),
726
+ eb("organization_id", "in", cfSortOrgCandidates)
727
+ ]) : eb("organization_id", "is", null)).execute();
728
+ const winners = /* @__PURE__ */ new Map();
729
+ for (const row of kindRows) {
730
+ if (!row.kind) continue;
731
+ const specificity = row.organization_id != null ? 2 : row.tenant_id != null ? 1 : 0;
732
+ const existing = winners.get(row.key);
733
+ if (!existing || specificity >= existing.specificity) winners.set(row.key, { kind: row.kind, specificity });
734
+ }
735
+ for (const [key, entry] of winners) cfSortKinds.set(key, entry.kind);
736
+ }
737
+ }
738
+ }
683
739
  const cfValueExprByKey = {};
684
740
  const cfSelectedAliases = [];
685
741
  const cfJsonAliases2 = /* @__PURE__ */ new Set();
686
742
  const cfMultiAliasByAlias2 = /* @__PURE__ */ new Map();
743
+ const cfSortAliases2 = [];
687
744
  for (const key of cfKeys) {
688
745
  const source = keySource.get(key);
689
746
  if (!source) continue;
@@ -839,29 +896,50 @@ class BasicQueryEngine {
839
896
  );
840
897
  }
841
898
  }
899
+ let lastEmittedSortField = null;
842
900
  for (const s of isCountProjection ? [] : resolvedSorts) {
843
901
  if (s.field.startsWith("cf:")) {
844
902
  const key = s.field.slice(3);
845
- const alias = sanitize(`cf:${key}`);
846
- if (!cfSelectedAliases.includes(alias)) {
847
- const expr = cfValueExprByKey[key];
848
- if (expr) {
849
- q = q.select(sql`max(${expr})`.as(alias));
850
- cfSelectedAliases.push(alias);
903
+ const sortAlias = sanitize(`cf:${key}__sort`);
904
+ if (!cfSortAliases2.includes(sortAlias)) {
905
+ const source = keySource.get(key);
906
+ const kind = cfSortKinds.get(key);
907
+ let sortExpr = null;
908
+ if (source && kind && NUMERIC_CF_SORT_KINDS.has(kind)) {
909
+ const sourceAliasSafe = sanitize(source.alias || "src");
910
+ const keyAliasSafe = sanitize(key);
911
+ const valAlias = `cfv_${sourceAliasSafe}_${keyAliasSafe}`;
912
+ const numericColumn = kind === "integer" ? "value_int" : "value_float";
913
+ sortExpr = sql`max((${sql.ref(`${valAlias}.${numericColumn}`)})::numeric)`;
914
+ } else {
915
+ const expr = cfValueExprByKey[key];
916
+ if (expr) sortExpr = sql`max(${expr})`;
917
+ }
918
+ if (sortExpr) {
919
+ q = q.select(sortExpr.as(sortAlias));
920
+ cfSortAliases2.push(sortAlias);
851
921
  }
852
922
  }
853
- if (!requiresPlaintextSort && cfSelectedAliases.includes(alias)) {
854
- q = q.orderBy(alias, s.dir ?? "asc");
923
+ if (!requiresPlaintextSort && cfSortAliases2.includes(sortAlias)) {
924
+ const direction = sql.raw((s.dir ?? "asc") === "desc" ? "desc" : "asc");
925
+ q = q.orderBy(sql`${sql.ref(sortAlias)} ${direction} NULLS LAST`);
926
+ lastEmittedSortField = s.field;
855
927
  }
856
928
  } else {
857
- if (!requiresPlaintextSort) q = q.orderBy(qualify(s.field), s.dir ?? "asc");
929
+ if (!requiresPlaintextSort) {
930
+ q = q.orderBy(qualify(s.field), s.dir ?? "asc");
931
+ lastEmittedSortField = s.field;
932
+ }
858
933
  }
859
934
  }
935
+ if (!isCountProjection && lastEmittedSortField !== null && lastEmittedSortField !== "id") {
936
+ q = q.orderBy(qualify("id"), "asc");
937
+ }
860
938
  const hasJoinedAggregates2 = !isCountProjection && (opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? opts.includeExtensions.length > 0 : true) || Object.keys(cfValueExprByKey).length > 0);
861
939
  if (hasJoinedAggregates2) {
862
940
  q = q.groupBy(`${table}.id`);
863
941
  }
864
- return { builder: q, hasJoinedAggregates: hasJoinedAggregates2, cfJsonAliases: cfJsonAliases2, cfMultiAliasByAlias: cfMultiAliasByAlias2, resolvedCustomFieldDefinitions: resolvedCustomFieldDefinitions2 };
942
+ return { builder: q, hasJoinedAggregates: hasJoinedAggregates2, cfJsonAliases: cfJsonAliases2, cfMultiAliasByAlias: cfMultiAliasByAlias2, cfSortAliases: cfSortAliases2, resolvedCustomFieldDefinitions: resolvedCustomFieldDefinitions2 };
865
943
  };
866
944
  const page = opts.page?.page ?? 1;
867
945
  const pageSize = opts.page?.pageSize ?? 20;
@@ -870,6 +948,7 @@ class BasicQueryEngine {
870
948
  hasJoinedAggregates,
871
949
  cfJsonAliases,
872
950
  cfMultiAliasByAlias,
951
+ cfSortAliases,
873
952
  resolvedCustomFieldDefinitions
874
953
  } = await buildQuery("full");
875
954
  const countCap = resolveListCountCap();
@@ -932,6 +1011,12 @@ class BasicQueryEngine {
932
1011
  }
933
1012
  }
934
1013
  };
1014
+ const stripCfSortAliases = (rows) => {
1015
+ if (cfSortAliases.length === 0) return;
1016
+ for (const row of rows) {
1017
+ for (const alias of cfSortAliases) delete row[alias];
1018
+ }
1019
+ };
935
1020
  let pagedItems;
936
1021
  let encryptedSortRowCapWarning;
937
1022
  if (requiresPlaintextSort) {
@@ -959,6 +1044,7 @@ class BasicQueryEngine {
959
1044
  } else {
960
1045
  const pageRows = await qFull.where(qualify("id"), "in", pageIds).execute();
961
1046
  normalizeCfJsonAliases(pageRows);
1047
+ stripCfSortAliases(pageRows);
962
1048
  const decryptedPageRows = decryptPayload ? await mapWithConcurrency(pageRows, DECRYPT_CONCURRENCY, decryptRow) : pageRows;
963
1049
  const byId = new Map(decryptedPageRows.map((row) => [String(row.id), row]));
964
1050
  pagedItems = pageIds.map((id) => byId.get(String(id))).filter((row) => row != null);
@@ -967,6 +1053,7 @@ class BasicQueryEngine {
967
1053
  const dataQuery = qFull.limit(pageSize).offset((page - 1) * pageSize);
968
1054
  const items = await dataQuery.execute();
969
1055
  normalizeCfJsonAliases(items);
1056
+ stripCfSortAliases(items);
970
1057
  pagedItems = decryptPayload ? await mapWithConcurrency(items, DECRYPT_CONCURRENCY, decryptRow) : items;
971
1058
  }
972
1059
  let queryResult = { items: pagedItems, page, pageSize, total };