@open-mercato/shared 0.8.1-develop.7294.1.0ef99fc518 → 0.8.1-develop.7296.1.2111d779db

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,3 @@
1
1
  [generate-countries] up to date (259 countries)
2
- [build:shared] found 298 entry points
2
+ [build:shared] found 299 entry points
3
3
  [build:shared] built successfully
package/AGENTS.md CHANGED
@@ -124,9 +124,12 @@ index stores hashes of the plaintext, so it keeps matching. Issue #2990.
124
124
  - `matched: true` with `ids: []` is a real empty result.
125
125
  - Queries that go through the query engine get this routing automatically; raw
126
126
  `em.find` / Kysely list routes must wire it themselves. One carve-out: with
127
- `OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS=true` (default false), a base-column
128
- `like`/`ilike` on a **plaintext** column runs as exact SQL ILIKE instead of the token
129
- rewrite — encrypted columns keep the token path either way. When the fallback would run
127
+ `OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS=true` (**off by default per #5383**, opt in ahead
128
+ of it to fix #5803), a base-column `like`/`ilike` on a **plaintext** column runs as SQL ILIKE —
129
+ one containment predicate per word of the term, ANDed (`lib/search/containment`), so word-order
130
+ independence survives the reroute — instead of the token rewrite; encrypted columns keep the
131
+ token path either way. Leaving the var unset keeps the legacy rewrite-everything behavior. When
132
+ the fallback would run
130
133
  `ILIKE` against an encrypted column, both query engines now log a warning
131
134
  (`lib/query/ciphertext-search-warning`) instead of degrading silently.
132
135
  - The `…WithDecryption` helpers log the same warning outside production when the `where`
@@ -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 };