@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.
- package/dist/generic/baseEngine.d.ts +27 -1
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +64 -4
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.d.ts +90 -0
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +100 -0
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +15 -0
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +6 -0
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +8 -0
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +1 -1
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +7 -3
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/interfaces.d.ts +123 -0
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +7 -1
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +18 -1
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/metadata.d.ts +44 -1
- package/dist/generic/metadata.d.ts.map +1 -1
- package/dist/generic/metadata.js +49 -0
- package/dist/generic/metadata.js.map +1 -1
- package/dist/generic/providerBase.d.ts +100 -1
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +280 -5
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/scoring/ReciprocalRankFusion.d.ts +11 -4
- package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -1
- package/dist/generic/scoring/ReciprocalRankFusion.js +18 -6
- package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +35 -0
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +37 -0
- package/dist/generic/securityInfo.js.map +1 -1
- package/package.json +3 -3
- 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
|
-
|
|
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
|
|
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
|
|
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);
|