@memberjunction/core 5.38.0 → 5.40.0

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 (44) hide show
  1. package/dist/generic/baseEngine.d.ts +27 -1
  2. package/dist/generic/baseEngine.d.ts.map +1 -1
  3. package/dist/generic/baseEngine.js +64 -4
  4. package/dist/generic/baseEngine.js.map +1 -1
  5. package/dist/generic/baseEngineRegistry.d.ts +90 -0
  6. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  7. package/dist/generic/baseEngineRegistry.js +100 -0
  8. package/dist/generic/baseEngineRegistry.js.map +1 -1
  9. package/dist/generic/baseEntity.d.ts.map +1 -1
  10. package/dist/generic/baseEntity.js +15 -0
  11. package/dist/generic/baseEntity.js.map +1 -1
  12. package/dist/generic/databaseProviderBase.d.ts +6 -0
  13. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  14. package/dist/generic/databaseProviderBase.js +8 -0
  15. package/dist/generic/databaseProviderBase.js.map +1 -1
  16. package/dist/generic/entityInfo.d.ts +1 -1
  17. package/dist/generic/entityInfo.d.ts.map +1 -1
  18. package/dist/generic/entityInfo.js +7 -3
  19. package/dist/generic/entityInfo.js.map +1 -1
  20. package/dist/generic/interfaces.d.ts +123 -0
  21. package/dist/generic/interfaces.d.ts.map +1 -1
  22. package/dist/generic/interfaces.js.map +1 -1
  23. package/dist/generic/localCacheManager.d.ts +7 -1
  24. package/dist/generic/localCacheManager.d.ts.map +1 -1
  25. package/dist/generic/localCacheManager.js +18 -1
  26. package/dist/generic/localCacheManager.js.map +1 -1
  27. package/dist/generic/metadata.d.ts +44 -1
  28. package/dist/generic/metadata.d.ts.map +1 -1
  29. package/dist/generic/metadata.js +49 -0
  30. package/dist/generic/metadata.js.map +1 -1
  31. package/dist/generic/providerBase.d.ts +100 -1
  32. package/dist/generic/providerBase.d.ts.map +1 -1
  33. package/dist/generic/providerBase.js +280 -5
  34. package/dist/generic/providerBase.js.map +1 -1
  35. package/dist/generic/scoring/ReciprocalRankFusion.d.ts +11 -4
  36. package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -1
  37. package/dist/generic/scoring/ReciprocalRankFusion.js +18 -6
  38. package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
  39. package/dist/generic/securityInfo.d.ts +35 -0
  40. package/dist/generic/securityInfo.d.ts.map +1 -1
  41. package/dist/generic/securityInfo.js +37 -0
  42. package/dist/generic/securityInfo.js.map +1 -1
  43. package/package.json +3 -3
  44. package/readme.md +117 -0
@@ -1,6 +1,7 @@
1
1
  import { BaseEntity } from "./baseEntity.js";
2
- import { EntityDocumentTypeInfo, EntityInfo } from "./entityInfo.js";
2
+ import { EntityDocumentTypeInfo, EntityFieldTSType, EntityInfo, EntityPermissionType } from "./entityInfo.js";
3
3
  import { AllMetadata } from "./interfaces.js";
4
+ import { ComputeRRF } from "./scoring/ReciprocalRankFusion.js";
4
5
  import { LocalCacheManager } from "./localCacheManager.js";
5
6
  import { ApplicationInfo } from "../generic/applicationInfo.js";
6
7
  import { AuditLogTypeInfo, AuthorizationInfo, AuthorizationRoleInfo, RoleInfo, RowLevelSecurityFilterInfo, UserInfo } from "./securityInfo.js";
@@ -768,6 +769,255 @@ export class ProviderBase {
768
769
  });
769
770
  return parts.join('||');
770
771
  }
772
+ /**
773
+ * Ranked search over **one** entity's records. See {@link IMetadataProvider.SearchEntity}
774
+ * for the contract and how this differs from {@link EntityByName} /
775
+ * {@link FullTextSearch}.
776
+ *
777
+ * Implementation overview (concrete on `ProviderBase`, used as-is by every
778
+ * server-side provider; `GraphQLDataProvider` overrides to proxy via GQL):
779
+ * 1. Resolve the EntityDocument (by `params.options.entityDocumentId`
780
+ * override or by looking up the active Search-category doc for the entity).
781
+ * 2. In parallel: run the lexical pass (RunView with LIKE filters on the
782
+ * name field + any `IncludeInUserSearchAPI` fields) and the semantic
783
+ * pass (`searchEntitiesSemanticPass`, the protected template method
784
+ * each concrete server provider implements).
785
+ * 3. Fuse via canonical `ComputeRRF()` with optional per-list weights.
786
+ * 4. Permission-filter via a second RunView constrained to the matched
787
+ * record IDs — that pipeline already enforces row-level read perms
788
+ * on this entity, so any rows the user can't read drop out.
789
+ * 5. Slice to topK, apply minScore cutoff, return.
790
+ */
791
+ async SearchEntity(params) {
792
+ const { entityName, searchText } = params;
793
+ const options = params.options ?? {};
794
+ const entity = this.EntityByName(entityName);
795
+ if (!entity) {
796
+ LogError(`SearchEntity: unknown entity "${entityName}"`);
797
+ return [];
798
+ }
799
+ if (!searchText || !searchText.trim()) {
800
+ return [];
801
+ }
802
+ const mode = options.mode ?? 'hybrid';
803
+ const topK = options.topK ?? 10;
804
+ const minScore = options.minScore ?? 0;
805
+ const overFetch = topK * 2;
806
+ const contextUser = options.contextUser;
807
+ // Resolve EntityDocument when semantic ranking is in play
808
+ let entityDocumentId = null;
809
+ let embeddingAIModelId = null;
810
+ if (mode === 'semantic' || mode === 'hybrid') {
811
+ const resolved = await this.resolveSearchEntityDocument(entity.ID, options.entityDocumentId, contextUser);
812
+ if (resolved) {
813
+ entityDocumentId = resolved.id;
814
+ embeddingAIModelId = resolved.aiModelId;
815
+ }
816
+ if (!entityDocumentId && mode === 'semantic') {
817
+ LogError(`SearchEntity: no active 'Search' EntityDocument for entity "${entityName}"; cannot run semantic-only mode`);
818
+ return [];
819
+ }
820
+ }
821
+ // Dispatch lexical and semantic in parallel. Wrapping the lexical pass
822
+ // in Promise.resolve adds no real cost today and keeps the shape ready
823
+ // if the lexical implementation ever goes async (FTS, external index).
824
+ const [lexicalRanked, semanticRanked] = await Promise.all([
825
+ mode === 'semantic'
826
+ ? Promise.resolve([])
827
+ : this.searchEntitiesLexicalPass(entity, searchText, overFetch, contextUser),
828
+ mode === 'lexical' || !entityDocumentId
829
+ ? Promise.resolve([])
830
+ : this.searchEntitiesSemanticPass(entityDocumentId, searchText, overFetch, embeddingAIModelId, contextUser),
831
+ ]);
832
+ // Blend
833
+ const blended = this.searchEntitiesBlend(mode, lexicalRanked, semanticRanked, options);
834
+ // Build component-score lookup for the result-shape step
835
+ const lexicalScoreById = new Map();
836
+ for (const c of lexicalRanked)
837
+ lexicalScoreById.set(c.ID, c.Score);
838
+ const semanticScoreById = new Map();
839
+ const semanticDocByRecord = new Map();
840
+ for (const c of semanticRanked) {
841
+ semanticScoreById.set(c.ID, c.Score);
842
+ const erdId = c.Metadata?.['entityRecordDocumentId'];
843
+ if (erdId)
844
+ semanticDocByRecord.set(c.ID, erdId);
845
+ }
846
+ // Permission-filter post hoc via RunView constrained to the matched
847
+ // record IDs — the existing pipeline already enforces row-level read
848
+ // permissions on this entity, so unauthorized rows simply drop out.
849
+ const ids = blended.map(c => c.ID);
850
+ const allowedIds = ids.length > 0
851
+ ? await this.searchEntitiesFilterByPermission(entity, ids, contextUser)
852
+ : new Set();
853
+ const results = [];
854
+ for (const cand of blended) {
855
+ if (!allowedIds.has(cand.ID))
856
+ continue;
857
+ if (cand.Score < minScore)
858
+ continue;
859
+ const lex = lexicalScoreById.get(cand.ID);
860
+ const sem = semanticScoreById.get(cand.ID);
861
+ const matchType = (lex != null && sem != null)
862
+ ? 'hybrid'
863
+ : (lex != null ? 'lexical' : 'semantic');
864
+ results.push({
865
+ entityRecordDocumentId: semanticDocByRecord.get(cand.ID) ?? null,
866
+ recordId: cand.ID,
867
+ score: cand.Score,
868
+ matchType,
869
+ components: { lexical: lex, semantic: sem },
870
+ });
871
+ if (results.length >= topK)
872
+ break;
873
+ }
874
+ return results;
875
+ }
876
+ /**
877
+ * Batch form of {@link SearchEntity}. Fans the input list out to N
878
+ * independent `SearchEntity` calls via `Promise.all`; result arrays come
879
+ * back aligned by input order (`result[i]` holds the matches for `params[i]`).
880
+ *
881
+ * On the server side, the per-entity passes are independent — running them
882
+ * concurrently is a real wall-clock win when the caller wants results from
883
+ * multiple entities. On the client side, `GraphQLDataProvider` overrides
884
+ * this method to pack the whole batch into a single GraphQL round-trip
885
+ * instead of issuing N parallel HTTP requests.
886
+ *
887
+ * See {@link IMetadataProvider.SearchEntities} for the contract.
888
+ */
889
+ async SearchEntities(params) {
890
+ if (!params || params.length === 0)
891
+ return [];
892
+ return Promise.all(params.map(p => this.SearchEntity(p)));
893
+ }
894
+ /**
895
+ * Look up the EntityDocument that backs entity search for `entityID`.
896
+ * Caller supplies an explicit ID override; otherwise we filter for an
897
+ * Active EntityDocument joined to the 'Search' type via the denormalized
898
+ * `Type` column on `vwEntityDocuments` (avoids a SQL subquery and keeps
899
+ * this metadata-layer code provider-agnostic).
900
+ *
901
+ * Returns just the EntityDocument PK and its AIModelID; concrete semantic-pass
902
+ * implementations look up the model from `AIEngine` to recover the driver
903
+ * class and API name (the view does not project them). Threading AIModelID
904
+ * through ensures the query embedding is generated with the *same* model
905
+ * used to build the index — anything else produces garbage cosine scores.
906
+ */
907
+ async resolveSearchEntityDocument(entityID, explicitId, contextUser) {
908
+ const filter = explicitId
909
+ ? `ID='${explicitId.replace(/'/g, "''")}'`
910
+ : `EntityID='${entityID.replace(/'/g, "''")}' AND Status='Active' AND Type='Search'`;
911
+ const r = await this.RunView({
912
+ EntityName: 'MJ: Entity Documents',
913
+ ExtraFilter: filter,
914
+ ResultType: 'simple',
915
+ MaxRows: 1,
916
+ }, contextUser);
917
+ if (!r.Success || (r.Results?.length ?? 0) === 0)
918
+ return null;
919
+ const row = r.Results[0];
920
+ return {
921
+ id: row.ID,
922
+ aiModelId: row.AIModelID ?? null,
923
+ };
924
+ }
925
+ /**
926
+ * Substring/prefix LIKE search against the entity's name field and any
927
+ * string-typed fields flagged `IncludeInUserSearchAPI`. Returns lexical
928
+ * scores in [0,1] blended by best match per row.
929
+ *
930
+ * **Wildcard handling.** SQL Server's `LIKE` treats `%`, `_`, and `[`
931
+ * specially; a user searching for `50%_off` would otherwise match far more
932
+ * than intended. We escape those three characters and declare an explicit
933
+ * `ESCAPE '\\'` so user-supplied text is matched literally.
934
+ *
935
+ * **Field filtering.** Only string-typed fields are searched. Bit/numeric/
936
+ * date fields can be flagged `IncludeInUserSearchAPI` via metadata edit;
937
+ * applying `LIKE` to those would error or implicit-convert in subtle ways.
938
+ */
939
+ async searchEntitiesLexicalPass(entity, searchText, overFetch, contextUser) {
940
+ const trimmed = searchText.trim();
941
+ if (!trimmed)
942
+ return [];
943
+ // Escape quotes for SQL string literal, then escape LIKE wildcards
944
+ // (%, _, [) with the explicit ESCAPE character we declare below.
945
+ const sanitized = trimmed
946
+ .replace(/'/g, "''")
947
+ .replace(/\\/g, '\\\\')
948
+ .replace(/%/g, '\\%')
949
+ .replace(/_/g, '\\_')
950
+ .replace(/\[/g, '\\[');
951
+ const searchableFields = entity.Fields
952
+ .filter(f => (f.IncludeInUserSearchAPI || f.IsNameField) && f.TSType === EntityFieldTSType.String);
953
+ if (searchableFields.length === 0)
954
+ return [];
955
+ const likeClauses = searchableFields
956
+ .map(f => `${f.Name} LIKE '%${sanitized}%' ESCAPE '\\'`)
957
+ .join(' OR ');
958
+ const r = await this.RunView({
959
+ EntityName: entity.Name,
960
+ ExtraFilter: likeClauses,
961
+ ResultType: 'simple',
962
+ MaxRows: overFetch,
963
+ }, contextUser);
964
+ if (!r.Success)
965
+ return [];
966
+ const lower = trimmed.toLowerCase();
967
+ const nameField = entity.NameField?.Name ?? entity.Fields.find(f => f.IsNameField)?.Name ?? null;
968
+ const out = [];
969
+ for (const row of (r.Results ?? [])) {
970
+ const id = String(row['ID'] ?? '');
971
+ if (!id)
972
+ continue;
973
+ const nameVal = nameField ? String(row[nameField] ?? '').toLowerCase() : '';
974
+ let score = 0.5; // any match (in some searchable field)
975
+ if (nameVal) {
976
+ if (nameVal === lower)
977
+ score = 1.0;
978
+ else if (nameVal.startsWith(lower))
979
+ score = 0.85;
980
+ else if (nameVal.includes(lower))
981
+ score = 0.7;
982
+ }
983
+ out.push({ ID: id, Score: score });
984
+ }
985
+ // ComputeRRF reads order, not magnitude — sort by score so rank 1 = best lexical match
986
+ out.sort((a, b) => b.Score - a.Score);
987
+ return out;
988
+ }
989
+ /** Blend lexical + semantic via canonical weighted ComputeRRF. */
990
+ searchEntitiesBlend(mode, lexicalRanked, semanticRanked, options) {
991
+ if (mode === 'lexical')
992
+ return lexicalRanked;
993
+ if (mode === 'semantic')
994
+ return semanticRanked;
995
+ const weights = [
996
+ options.weights?.lexical ?? 1.0,
997
+ options.weights?.semantic ?? 1.0,
998
+ ];
999
+ return ComputeRRF([lexicalRanked, semanticRanked], options.rrfK ?? 60, weights);
1000
+ }
1001
+ /**
1002
+ * Run a permission-aware RunView restricted to the matched IDs. Whatever
1003
+ * comes back is what the user is allowed to see. Used as a post-filter
1004
+ * over the fused result set.
1005
+ */
1006
+ async searchEntitiesFilterByPermission(entity, ids, contextUser) {
1007
+ if (ids.length === 0)
1008
+ return new Set();
1009
+ const escaped = ids.map(id => `'${id.replace(/'/g, "''")}'`).join(',');
1010
+ const r = await this.RunView({
1011
+ EntityName: entity.Name,
1012
+ ExtraFilter: `ID IN (${escaped})`,
1013
+ Fields: ['ID'],
1014
+ ResultType: 'simple',
1015
+ MaxRows: ids.length,
1016
+ }, contextUser);
1017
+ if (!r.Success)
1018
+ return new Set();
1019
+ return new Set((r.Results ?? []).map(row => row.ID));
1020
+ }
771
1021
  /**
772
1022
  * Returns true if any param in the batch has SaveViewResults set,
773
1023
  * which means the call has a side effect (creating UserViewRun records)
@@ -927,6 +1177,28 @@ export class ProviderBase {
927
1177
  // ========================================================================
928
1178
  // PRE-PROCESSING HOOKS
929
1179
  // ========================================================================
1180
+ /**
1181
+ * Computes the per-user Row-Level-Security WHERE clause that {@link InternalRunView} will
1182
+ * append to this query's SQL for the given user, so it can be folded into the cache
1183
+ * fingerprint. RLS-scoped reads return a different result set than unscoped reads of the same
1184
+ * entity+filter; without including the RLS clause in the cache key, a scoped user could be
1185
+ * served a cached unscoped result set (a data leak).
1186
+ *
1187
+ * Returns '' when the user is exempt from RLS on this entity (the common case), which makes the
1188
+ * resulting fingerprint byte-identical to the pre-RLS format — preserving normal cache sharing.
1189
+ *
1190
+ * Uses `this` (the active provider) to resolve the entity, never the global Metadata, so the
1191
+ * correct per-provider/per-tenant metadata is consulted.
1192
+ */
1193
+ ComputeRunViewRLSWhereClause(params, contextUser) {
1194
+ const user = contextUser ?? this.CurrentUser;
1195
+ if (!user || !params.EntityName)
1196
+ return '';
1197
+ const entity = this.EntityByName(params.EntityName);
1198
+ if (!entity)
1199
+ return '';
1200
+ return entity.GetUserRowLevelSecurityWhereClause(user, EntityPermissionType.Read, '');
1201
+ }
930
1202
  /**
931
1203
  * Pre-processing hook for RunView.
932
1204
  * Handles telemetry, validation, entity status check, and cache lookup.
@@ -990,7 +1262,8 @@ export class ProviderBase {
990
1262
  let cachedResult;
991
1263
  let fingerprint;
992
1264
  if (willCache && LocalCacheManager.Instance.IsInitialized) {
993
- fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString);
1265
+ const rlsWhereClause = this.ComputeRunViewRLSWhereClause(params, contextUser);
1266
+ fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause);
994
1267
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
995
1268
  if (cached) {
996
1269
  // Filter cached results to only the caller's requested fields (if specified)
@@ -1111,7 +1384,8 @@ export class ProviderBase {
1111
1384
  // BypassCache skips cache entirely — used by maintenance actions querying for
1112
1385
  // records that were inserted via direct SQL (bypassing BaseEntity.Save())
1113
1386
  if (batchWillCache && LocalCacheManager.Instance.IsInitialized) {
1114
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1387
+ const rlsWhereClause = this.ComputeRunViewRLSWhereClause(param, contextUser);
1388
+ const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, rlsWhereClause);
1115
1389
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
1116
1390
  if (cached) {
1117
1391
  // Filter cached results to caller's requested fields (if specified and not entity_object)
@@ -1517,7 +1791,7 @@ export class ProviderBase {
1517
1791
  // Server-side auto-cache: small, unfiltered, unsorted results are
1518
1792
  // automatically cached even without explicit CacheLocal. These are
1519
1793
  // safe for in-place upsert on entity changes (no filter to evaluate).
1520
- const fingerprint = preResult.fingerprint || LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString);
1794
+ const fingerprint = preResult.fingerprint || LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(params, contextUser));
1521
1795
  const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
1522
1796
  await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
1523
1797
  LogStatusEx({ message: ` 📦 [Auto-Cache] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${result.Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
@@ -1560,7 +1834,8 @@ export class ProviderBase {
1560
1834
  if (cacheInfo?.status === 'hit') {
1561
1835
  continue;
1562
1836
  }
1563
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params[i], this.InstanceConnectionString);
1837
+ const rlsWhereClause = this.ComputeRunViewRLSWhereClause(params[i], contextUser);
1838
+ const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params[i], this.InstanceConnectionString, rlsWhereClause);
1564
1839
  const batchEntityCacheAllowed = this.IsServerCacheAllowedForEntity(params[i]);
1565
1840
  if ((params[i].CacheLocal || this.TrustLocalCacheCompletely) && batchEntityCacheAllowed && results[i].Success && LocalCacheManager.Instance.IsInitialized) {
1566
1841
  const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);