@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +6 -3
- package/dist/lib/query/encrypted-sort.js +4 -1
- package/dist/lib/query/encrypted-sort.js.map +2 -2
- package/dist/lib/query/engine.js +97 -10
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/containment.js +32 -0
- package/dist/lib/search/containment.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/query/__tests__/engine.test.ts +208 -16
- package/src/lib/query/encrypted-sort.ts +8 -1
- package/src/lib/query/engine.ts +172 -19
- package/src/lib/search/__tests__/config.test.ts +27 -0
- package/src/lib/search/__tests__/containment.test.ts +61 -0
- package/src/lib/search/config.ts +28 -6
- package/src/lib/search/containment.ts +66 -0
package/.turbo/turbo-build.log
CHANGED
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
|
|
128
|
-
`like`/`ilike` on a **plaintext** column runs as
|
|
129
|
-
|
|
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:"))
|
|
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:'))
|
|
5
|
-
"mappings": "AACA,SAAS,eAA0B;AAWnC,MAAM,cAAc,CAAC,UACnB,MAAM,QAAQ,YAAY,KAAK,EAAE,QAAQ,OAAO,GAAG,EAAE,YAAY;AAEnE,MAAM,cAAc,CAAC,UACnB,MAAM,QAAQ,aAAa,CAAC,GAAG,MAAM,EAAE,YAAY,CAAC;AAE/C,SAAS,oBAAoB,OAAyB;AAC3D,QAAM,MAAM,OAAO,SAAS,EAAE,EAAE,KAAK;AACrC,MAAI,CAAC,IAAK,QAAO,CAAC;AAClB,QAAM,aAAa,CAAC,KAAK,YAAY,GAAG,GAAG,YAAY,GAAG,CAAC;AAC3D,MAAI,IAAI,WAAW,KAAK,
|
|
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
|
}
|
package/dist/lib/query/engine.js
CHANGED
|
@@ -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
|
|
846
|
-
if (!
|
|
847
|
-
const
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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 &&
|
|
854
|
-
|
|
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)
|
|
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 };
|