@memberjunction/core 6.2.0-edge.2 → 6.2.0-edge.4
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/InMemoryLocalStorageProvider.d.ts +5 -0
- package/dist/generic/InMemoryLocalStorageProvider.d.ts.map +1 -1
- package/dist/generic/InMemoryLocalStorageProvider.js +5 -0
- package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +27 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +19 -0
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -1
- package/dist/generic/RegisterForStartup.d.ts +44 -0
- package/dist/generic/RegisterForStartup.d.ts.map +1 -1
- package/dist/generic/RegisterForStartup.js +124 -48
- package/dist/generic/RegisterForStartup.js.map +1 -1
- package/dist/generic/baseEngine.d.ts +214 -3
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +460 -34
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineSweeper.d.ts +35 -0
- package/dist/generic/baseEngineSweeper.d.ts.map +1 -0
- package/dist/generic/baseEngineSweeper.js +97 -0
- package/dist/generic/baseEngineSweeper.js.map +1 -0
- package/dist/generic/baseEntity.d.ts +91 -7
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +161 -16
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +54 -11
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +129 -35
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityConfiguration.d.ts +20 -0
- package/dist/generic/entityConfiguration.d.ts.map +1 -1
- package/dist/generic/entityConfiguration.js +34 -0
- package/dist/generic/entityConfiguration.js.map +1 -1
- package/dist/generic/entityEventBatch.d.ts +124 -0
- package/dist/generic/entityEventBatch.d.ts.map +1 -0
- package/dist/generic/entityEventBatch.js +194 -0
- package/dist/generic/entityEventBatch.js.map +1 -0
- package/dist/generic/entityInfo.d.ts +57 -6
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +72 -7
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entityRecordNameCache.d.ts +48 -0
- package/dist/generic/entityRecordNameCache.d.ts.map +1 -0
- package/dist/generic/entityRecordNameCache.js +92 -0
- package/dist/generic/entityRecordNameCache.js.map +1 -0
- package/dist/generic/interfaces.d.ts +151 -8
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/jsonFieldBinding.d.ts +196 -0
- package/dist/generic/jsonFieldBinding.d.ts.map +1 -0
- package/dist/generic/jsonFieldBinding.js +508 -0
- package/dist/generic/jsonFieldBinding.js.map +1 -0
- package/dist/generic/localCacheManager.d.ts +278 -16
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +737 -113
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts +15 -0
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +17 -0
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/metadata.d.ts +1 -1
- package/dist/generic/metadata.d.ts.map +1 -1
- package/dist/generic/metadata.js.map +1 -1
- package/dist/generic/providerBase.d.ts +252 -35
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +503 -189
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/runQuery.d.ts +18 -1
- package/dist/generic/runQuery.d.ts.map +1 -1
- package/dist/generic/runQuery.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts +7 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +65 -140
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +5 -0
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +5 -0
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts +11 -0
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +43 -0
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/generic/transactionGroup.d.ts +27 -0
- package/dist/generic/transactionGroup.d.ts.map +1 -1
- package/dist/generic/transactionGroup.js +58 -5
- package/dist/generic/transactionGroup.js.map +1 -1
- package/dist/generic/util.d.ts +6 -0
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +20 -0
- package/dist/generic/util.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +24 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +4 -0
- package/dist/views/runView.js.map +1 -1
- package/package.json +3 -3
- package/readme.md +29 -2
|
@@ -3,10 +3,10 @@ import { EntityDocumentTypeInfo, EntityFieldTSType, EntityInfo, EntityPermission
|
|
|
3
3
|
import { AllMetadata } from "./interfaces.js";
|
|
4
4
|
import { RecordChangeFieldSecurityProjector } from "./recordChangeFieldSecurity.js";
|
|
5
5
|
import { ComputeRRF } from "./scoring/ReciprocalRankFusion.js";
|
|
6
|
-
import { LocalCacheManager } from "./localCacheManager.js";
|
|
6
|
+
import { LocalCacheManager, CacheCategory } from "./localCacheManager.js";
|
|
7
7
|
import { ApplicationInfo } from "../generic/applicationInfo.js";
|
|
8
8
|
import { AuditLogTypeInfo, AuthorizationInfo, AuthorizationRoleInfo, RoleInfo, RowLevelSecurityFilterInfo, UserInfo } from "./securityInfo.js";
|
|
9
|
-
import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual,
|
|
9
|
+
import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, EscapeSQLString, ordinalCompare } from "@memberjunction/global";
|
|
10
10
|
import { FindReferencedIdentifiers } from "@memberjunction/sql-dialect";
|
|
11
11
|
import { TelemetryManager } from "./telemetryManager.js";
|
|
12
12
|
import { LogDebug, LogError, LogStatus, LogStatusEx } from "./logging.js";
|
|
@@ -184,12 +184,14 @@ export function ProjectRowsToFields(rows, requestedFields) {
|
|
|
184
184
|
export class ProviderBase {
|
|
185
185
|
constructor() {
|
|
186
186
|
this._localMetadata = new AllMetadata();
|
|
187
|
+
/**
|
|
188
|
+
* The stored timestamps value (raw string) that describes the snapshot this process last
|
|
189
|
+
* loaded from or saved to local storage; null when that load found none. Undefined until a load
|
|
190
|
+
* or save has happened. See {@link syncLocalMetadataFromStorage}.
|
|
191
|
+
*/
|
|
192
|
+
this._storedTimestampsRaw = undefined;
|
|
187
193
|
this._entityMapByName = new Map();
|
|
188
194
|
this._entityMapByID = new Map();
|
|
189
|
-
// Bounded LRU (unlike its siblings above, this cache holds one entry per distinct
|
|
190
|
-
// *record* touched via Load()/Save()/LoadFromData() — not per entity definition — so
|
|
191
|
-
// it can't be reset on metadata refresh; it needs its own eviction policy.
|
|
192
|
-
this._entityRecordNameCache = new MJLruCache({ maxSize: 10000, ttlMs: 60 * 60 * 1000 });
|
|
193
195
|
this._refresh = false;
|
|
194
196
|
/** Single-flight guard for the full metadata reload in {@link Config} — see the comment there. */
|
|
195
197
|
this._metadataReloadInFlight = null;
|
|
@@ -248,9 +250,18 @@ export class ProviderBase {
|
|
|
248
250
|
this._metadataReadsJoinTransaction = false;
|
|
249
251
|
/** Debounce timer for {@link scheduleMetadataMemberRefresh}. */
|
|
250
252
|
this._metadataMemberRefreshTimer = null;
|
|
253
|
+
// ── Metadata change notices from other servers ─────────────────────
|
|
254
|
+
/** When the current run of undebounced peer notices began; caps how long the check is deferred. */
|
|
255
|
+
this._peerMetadataNoticeFirstAt = null;
|
|
256
|
+
/** Windows the pending peer-notice check has waited for an ambient transaction to settle. */
|
|
257
|
+
this._peerMetadataNoticeRetries = 0;
|
|
258
|
+
/** Debounce timer for {@link HandlePeerMetadataNotice}. */
|
|
259
|
+
this._peerMetadataNoticeTimer = null;
|
|
251
260
|
this._metadataMemberRefreshWaits = 0;
|
|
252
261
|
this._clientFingerprintMemo = new WeakMap();
|
|
253
262
|
this._cachedVisibleExplorerNavigationItems = null;
|
|
263
|
+
/** Guard for {@link logMetadataSnapshotDisabledOnce}. */
|
|
264
|
+
this._metadataSnapshotSkipLogged = false;
|
|
254
265
|
}
|
|
255
266
|
/**
|
|
256
267
|
* Whether this provider can execute a multi-record unit of work atomically, in-process.
|
|
@@ -485,6 +496,63 @@ export class ProviderBase {
|
|
|
485
496
|
this._metadataDatasetEntityNames = names;
|
|
486
497
|
this.ensureInflightViewInvalidation();
|
|
487
498
|
}
|
|
499
|
+
/**
|
|
500
|
+
* Result of {@link SweepMetadataAgainstDatabase}.
|
|
501
|
+
*/
|
|
502
|
+
static { this.MetadataSweepSkipped = { Declared: [], Checked: false, Refreshed: false }; }
|
|
503
|
+
/**
|
|
504
|
+
* Compares this provider's metadata with the database — but only for metadata whose entities
|
|
505
|
+
* have **declared** that their rows can change without firing an event.
|
|
506
|
+
*
|
|
507
|
+
* Metadata staleness is otherwise event-driven: a `BaseEntity` write to one of the entities the
|
|
508
|
+
* metadata is built from schedules a refresh, another process saving its snapshot publishes a
|
|
509
|
+
* notice, and a tool that changed the database clears the shared cache. None of those fire for a
|
|
510
|
+
* change made with raw SQL, so metadata edited directly in the database is never noticed by a
|
|
511
|
+
* running process. This is the backstop for that case.
|
|
512
|
+
*
|
|
513
|
+
* It is **not** an unconditional poll. `Entity.TrustServerCacheCompletely = true`
|
|
514
|
+
* (the default) states that every mutation flows through `BaseEntity`, which the event paths
|
|
515
|
+
* already hear; polling such an entity cannot discover anything, and a recurring query prevents a
|
|
516
|
+
* serverless database from pausing. So the check reads the database only when at least one
|
|
517
|
+
* metadata member entity declares `false` — typically because an operator writes that table
|
|
518
|
+
* directly. On a stock installation no metadata entity declares it, and this costs one in-memory
|
|
519
|
+
* pass over the membership set.
|
|
520
|
+
*
|
|
521
|
+
* @param providerToUse Provider to compare against, for multi-provider clients.
|
|
522
|
+
* @returns Which entities declared drift, whether the database was consulted, and whether
|
|
523
|
+
* metadata was actually reloaded.
|
|
524
|
+
*/
|
|
525
|
+
async SweepMetadataAgainstDatabase(providerToUse) {
|
|
526
|
+
const declared = this.MetadataMembersDeclaringDrift();
|
|
527
|
+
if (declared.length === 0) {
|
|
528
|
+
return { Declared: [], Checked: false, Refreshed: false };
|
|
529
|
+
}
|
|
530
|
+
// bypassMinCheckInterval: the sweep interval is the throttle, and this caller holds positive
|
|
531
|
+
// evidence that an entity can change without telling anyone.
|
|
532
|
+
const refreshed = await this.RefreshIfNeeded(providerToUse, true);
|
|
533
|
+
return { Declared: declared, Checked: true, Refreshed: refreshed };
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* The entities this provider's metadata is built from that declare they can change without
|
|
537
|
+
* firing an event (`Entity.TrustServerCacheCompletely === false`).
|
|
538
|
+
*
|
|
539
|
+
* Empty — the usual case — means a metadata sweep has nothing to look for, so a caller can skip
|
|
540
|
+
* the work (and any lease) without asking the database anything.
|
|
541
|
+
*/
|
|
542
|
+
MetadataMembersDeclaringDrift() {
|
|
543
|
+
const names = this._metadataDatasetEntityNames;
|
|
544
|
+
if (!names || names.size === 0) {
|
|
545
|
+
return [];
|
|
546
|
+
}
|
|
547
|
+
const declared = [];
|
|
548
|
+
for (const lowerName of names) {
|
|
549
|
+
const entity = this.Entities.find(e => e.Name.trim().toLowerCase() === lowerName);
|
|
550
|
+
if (entity && entity.TrustServerCacheCompletely === false) {
|
|
551
|
+
declared.push(entity.Name);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return declared;
|
|
555
|
+
}
|
|
488
556
|
/**
|
|
489
557
|
* Whether `entityName` is one of the entities this provider's metadata is built from.
|
|
490
558
|
* False when the membership set has not been loaded yet — a caller must not treat "unknown"
|
|
@@ -602,6 +670,118 @@ export class ProviderBase {
|
|
|
602
670
|
});
|
|
603
671
|
}, this.MetadataMemberRefreshDelayMs);
|
|
604
672
|
}
|
|
673
|
+
/**
|
|
674
|
+
* Upper bound, in milliseconds, of the random delay added to {@link HandlePeerMetadataNotice}'s
|
|
675
|
+
* debounce. A tool that changes the schema removes the shared snapshot, which reaches every
|
|
676
|
+
* server at once; the spread lets the first server reload and republish, so the others adopt
|
|
677
|
+
* its snapshot instead of all querying the database together.
|
|
678
|
+
*/
|
|
679
|
+
static { this.PeerMetadataNoticeJitterMs = 2000; }
|
|
680
|
+
/**
|
|
681
|
+
* How long a peer notice may be deferred by later notices before the check runs anyway. The
|
|
682
|
+
* debounce timer is reset by each arrival, so a fleet publishing steadily — exactly what a bulk
|
|
683
|
+
* import looks like — could postpone the check indefinitely.
|
|
684
|
+
*/
|
|
685
|
+
static { this.PeerMetadataNoticeMaxDeferralMs = 15000; }
|
|
686
|
+
/**
|
|
687
|
+
* True when a shared-cache event says the metadata snapshot changed: another process saved one
|
|
688
|
+
* (`set`), or a tool that changed the database removed it (`removed`, e.g. `mj migrate`). Only
|
|
689
|
+
* the timestamps key counts — it is written last (see {@link SaveLocalMetadataToStorage}) and
|
|
690
|
+
* removed last — so each save or removal is one notice.
|
|
691
|
+
*/
|
|
692
|
+
IsMetadataChangeNotice(event) {
|
|
693
|
+
return (event.Action === 'set' || event.Action === 'removed')
|
|
694
|
+
&& (event.Category || CacheCategory.Default) === CacheCategory.Default
|
|
695
|
+
&& event.CacheKey === this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Reacts to another server saving its metadata snapshot: after a short debounce, runs the
|
|
699
|
+
* normal staleness check ({@link RefreshIfNeeded}, bypassing the check throttle). The check
|
|
700
|
+
* compares this process's timestamps with the database and adopts the shared snapshot, so a
|
|
701
|
+
* metadata change made on one server reaches the others in seconds instead of at the next
|
|
702
|
+
* poll. A server that is already current does no refresh and saves nothing, so notices do not
|
|
703
|
+
* echo around the fleet.
|
|
704
|
+
*
|
|
705
|
+
* @returns true when the event was a metadata notice (and a check was scheduled)
|
|
706
|
+
*/
|
|
707
|
+
HandlePeerMetadataNotice(event) {
|
|
708
|
+
if (!this.IsMetadataChangeNotice(event)) {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
const now = Date.now();
|
|
712
|
+
this._peerMetadataNoticeFirstAt ??= now;
|
|
713
|
+
if (now - this._peerMetadataNoticeFirstAt >= ProviderBase.PeerMetadataNoticeMaxDeferralMs) {
|
|
714
|
+
// Notices have been arriving without a pause for longer than the deferral budget: stop
|
|
715
|
+
// resetting the timer and let the pending check run now.
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
if (this._peerMetadataNoticeTimer) {
|
|
719
|
+
clearTimeout(this._peerMetadataNoticeTimer);
|
|
720
|
+
}
|
|
721
|
+
const timer = setTimeout(() => {
|
|
722
|
+
this._peerMetadataNoticeTimer = null;
|
|
723
|
+
// A provider inside a transaction cannot run the check now (its reads would see the
|
|
724
|
+
// transaction's own uncommitted world, and SQL Server's RefreshIfNeeded refuses
|
|
725
|
+
// outright). Re-arm instead of dropping the notice, which used to lose it entirely.
|
|
726
|
+
// The deferral budget is deliberately NOT cleared here: it bounds how long one check
|
|
727
|
+
// may be put off, and the check has not run yet. Clearing it as the timer fires ended
|
|
728
|
+
// the budget exactly when deferral began, leaving the retry count as the only real
|
|
729
|
+
// bound.
|
|
730
|
+
if (this.MetadataMemberRefreshMustWait) {
|
|
731
|
+
this.handlePeerMetadataNoticeRetry();
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
this.runPeerMetadataCheck();
|
|
735
|
+
}, ProviderBase.MetadataDatasetRefreshDebounceMs + Math.floor(Math.random() * ProviderBase.PeerMetadataNoticeJitterMs));
|
|
736
|
+
if (typeof timer === 'object' && timer !== null && 'unref' in timer) {
|
|
737
|
+
timer.unref();
|
|
738
|
+
}
|
|
739
|
+
this._peerMetadataNoticeTimer = timer;
|
|
740
|
+
return true;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Runs the deferred metadata check and closes out both budgets.
|
|
744
|
+
*
|
|
745
|
+
* Both are reset HERE rather than at any earlier point, because both measure deferral of a
|
|
746
|
+
* check that has now happened. The retry count in particular used to be cleared only on the
|
|
747
|
+
* re-armed path, so a check that ran on the direct path — which is what happens when a fresh
|
|
748
|
+
* notice arrives after the transaction closed — left its count behind for the next deferral to
|
|
749
|
+
* inherit.
|
|
750
|
+
*/
|
|
751
|
+
runPeerMetadataCheck() {
|
|
752
|
+
this._peerMetadataNoticeRetries = 0;
|
|
753
|
+
this._peerMetadataNoticeFirstAt = null;
|
|
754
|
+
this.RefreshIfNeeded(undefined, true).catch((e) => {
|
|
755
|
+
LogError(`Metadata check after another server's change failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Re-arms the peer-notice check when the provider was inside a transaction at the moment it
|
|
760
|
+
* came due. Bounded by the same budget as the debounce, so a provider that never leaves its
|
|
761
|
+
* transaction (a leaked handle) stops re-arming rather than holding a timer open forever; the
|
|
762
|
+
* next notice, or the periodic poll, picks the change up.
|
|
763
|
+
*/
|
|
764
|
+
handlePeerMetadataNoticeRetry() {
|
|
765
|
+
this._peerMetadataNoticeRetries++;
|
|
766
|
+
if (this._peerMetadataNoticeRetries > ProviderBase.MaxMetadataMemberRefreshWaits) {
|
|
767
|
+
LogError(`Metadata check after another server's change was dropped: this provider has been inside a transaction for ${this._peerMetadataNoticeRetries} windows. The next notice or the periodic check will pick the change up.`);
|
|
768
|
+
this._peerMetadataNoticeRetries = 0;
|
|
769
|
+
this._peerMetadataNoticeFirstAt = null; // the next notice starts a fresh budget
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const timer = setTimeout(() => {
|
|
773
|
+
this._peerMetadataNoticeTimer = null;
|
|
774
|
+
if (this.MetadataMemberRefreshMustWait) {
|
|
775
|
+
this.handlePeerMetadataNoticeRetry();
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
this.runPeerMetadataCheck();
|
|
779
|
+
}, this.MetadataMemberRefreshDelayMs);
|
|
780
|
+
if (typeof timer === 'object' && timer !== null && 'unref' in timer) {
|
|
781
|
+
timer.unref();
|
|
782
|
+
}
|
|
783
|
+
this._peerMetadataNoticeTimer = timer;
|
|
784
|
+
}
|
|
605
785
|
/**
|
|
606
786
|
* How this provider refreshes after a metadata member entity changed. The base behavior is a
|
|
607
787
|
* hard {@link Refresh} — correct for database providers, where the process that PERFORMED the
|
|
@@ -672,143 +852,63 @@ export class ProviderBase {
|
|
|
672
852
|
return false;
|
|
673
853
|
}
|
|
674
854
|
/**
|
|
675
|
-
*
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
*
|
|
682
|
-
* Use this for synchronous contexts (like template rendering) where you can't await GetEntityRecordName().
|
|
855
|
+
* Returns a record's name for synchronous display code, or `undefined` when it is not at hand.
|
|
856
|
+
*
|
|
857
|
+
* This provider keeps no record names, so without `loadIfNeeded` the answer is always
|
|
858
|
+
* `undefined`. A provider that serves a single user (`GraphQLDataProvider`) overrides the
|
|
859
|
+
* record-name methods with a cache; one shared by several users must not, since a name can be
|
|
860
|
+
* withheld from some of them.
|
|
861
|
+
*
|
|
683
862
|
* @param entityName - The name of the entity
|
|
684
863
|
* @param compositeKey - The primary key value(s) for the record
|
|
685
|
-
* @param loadIfNeeded -
|
|
686
|
-
* @returns The
|
|
864
|
+
* @param loadIfNeeded - Look the name up when it is not cached
|
|
865
|
+
* @returns The display name, or undefined if not cached and not looked up
|
|
687
866
|
*/
|
|
688
867
|
async GetCachedRecordName(entityName, compositeKey, loadIfNeeded) {
|
|
689
|
-
|
|
690
|
-
if (!cachedEntry && loadIfNeeded) {
|
|
691
|
-
cachedEntry = await this.GetEntityRecordName(entityName, compositeKey);
|
|
692
|
-
}
|
|
693
|
-
return cachedEntry;
|
|
868
|
+
return loadIfNeeded ? this.GetEntityRecordName(entityName, compositeKey) : undefined;
|
|
694
869
|
}
|
|
695
870
|
/**
|
|
696
|
-
*
|
|
697
|
-
* @
|
|
698
|
-
* @param compositeKey - The primary key value(s) for the record
|
|
699
|
-
* @returns True if the record name is cached in memory, false otherwise
|
|
871
|
+
* Whether a record's name is cached for synchronous retrieval. Always false here; see
|
|
872
|
+
* {@link GetCachedRecordName}.
|
|
700
873
|
*/
|
|
701
874
|
HasCachedRecordName(entityName, compositeKey) {
|
|
702
|
-
return
|
|
875
|
+
return false;
|
|
703
876
|
}
|
|
704
877
|
/**
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
* @param entityName - The name of the entity
|
|
708
|
-
* @param compositeKey - The primary key value(s) for the record
|
|
709
|
-
* @returns The cached display name, or undefined if not in cache
|
|
878
|
+
* A record's cached name, without ever starting a lookup. Always undefined here; see
|
|
879
|
+
* {@link GetCachedRecordName}.
|
|
710
880
|
*/
|
|
711
881
|
GetCachedRecordNameOnlyIfCached(entityName, compositeKey) {
|
|
712
|
-
return
|
|
882
|
+
return undefined;
|
|
713
883
|
}
|
|
714
884
|
/**
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
* @param entityName - The name of the entity
|
|
718
|
-
* @param compositeKey - The primary key value(s) for the record
|
|
719
|
-
* @param recordName - The display name to cache
|
|
885
|
+
* Offers a record's name for later synchronous retrieval. `BaseEntity` calls it after every
|
|
886
|
+
* Load(), LoadFromData() and Save(). Ignored here; see {@link GetCachedRecordName}.
|
|
720
887
|
*/
|
|
721
888
|
SetCachedRecordName(entityName, compositeKey, recordName) {
|
|
722
|
-
this
|
|
889
|
+
// Intentionally empty: this provider keeps no record names.
|
|
723
890
|
}
|
|
724
891
|
/**
|
|
725
|
-
* Gets the display name for a single entity record
|
|
892
|
+
* Gets the display name for a single entity record.
|
|
726
893
|
* Uses the entity's IsNameField or falls back to 'Name' field if available.
|
|
727
894
|
* @param entityName - The name of the entity
|
|
728
895
|
* @param compositeKey - The primary key value(s) for the record
|
|
729
|
-
* @param contextUser -
|
|
730
|
-
* @param forceRefresh -
|
|
731
|
-
* @returns The display name of the record or
|
|
896
|
+
* @param contextUser - The acting user; field- and row-level security are applied for them
|
|
897
|
+
* @param forceRefresh - Bypass any cache. This provider has none, so it always looks up.
|
|
898
|
+
* @returns The display name of the record, or an empty string if not found or not readable
|
|
732
899
|
*/
|
|
733
900
|
async GetEntityRecordName(entityName, compositeKey, contextUser, forceRefresh = false) {
|
|
734
|
-
|
|
735
|
-
// Check cache unless forceRefresh
|
|
736
|
-
if (!forceRefresh) {
|
|
737
|
-
const cached = this._entityRecordNameCache.Get(cacheKey);
|
|
738
|
-
if (cached !== undefined) {
|
|
739
|
-
return cached;
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
// Fetch from database via provider-specific implementation
|
|
743
|
-
const name = await this.InternalGetEntityRecordName(entityName, compositeKey, contextUser);
|
|
744
|
-
if (name) {
|
|
745
|
-
this._entityRecordNameCache.Set(cacheKey, name);
|
|
746
|
-
}
|
|
747
|
-
return name;
|
|
901
|
+
return this.InternalGetEntityRecordName(entityName, compositeKey, contextUser);
|
|
748
902
|
}
|
|
749
903
|
/**
|
|
750
|
-
* Gets display names for multiple entity records in a single operation
|
|
751
|
-
* More efficient than multiple GetEntityRecordName calls.
|
|
904
|
+
* Gets display names for multiple entity records in a single operation.
|
|
752
905
|
* @param info - Array of entity/key pairs to lookup
|
|
753
|
-
* @param contextUser -
|
|
754
|
-
* @param forceRefresh -
|
|
906
|
+
* @param contextUser - The acting user; field- and row-level security are applied for them
|
|
907
|
+
* @param forceRefresh - Bypass any cache. This provider has none, so it always looks up.
|
|
755
908
|
* @returns Array of results with names and status for each requested record
|
|
756
909
|
*/
|
|
757
910
|
async GetEntityRecordNames(info, contextUser, forceRefresh = false) {
|
|
758
|
-
|
|
759
|
-
// Check cache for each item, collect uncached items
|
|
760
|
-
const results = [];
|
|
761
|
-
const uncachedInfo = [];
|
|
762
|
-
const uncachedIndexes = [];
|
|
763
|
-
for (let i = 0; i < info.length; i++) {
|
|
764
|
-
const item = info[i];
|
|
765
|
-
const cacheKey = this.getCacheKey(item.EntityName, item.CompositeKey);
|
|
766
|
-
const cached = this._entityRecordNameCache.Get(cacheKey);
|
|
767
|
-
if (cached !== undefined) {
|
|
768
|
-
// Cache hit
|
|
769
|
-
results[i] = {
|
|
770
|
-
EntityName: item.EntityName,
|
|
771
|
-
CompositeKey: item.CompositeKey,
|
|
772
|
-
Status: 'cached',
|
|
773
|
-
Success: true,
|
|
774
|
-
RecordName: cached
|
|
775
|
-
};
|
|
776
|
-
}
|
|
777
|
-
else {
|
|
778
|
-
// Cache miss - need to fetch
|
|
779
|
-
uncachedInfo.push(item);
|
|
780
|
-
uncachedIndexes.push(i);
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
// Fetch uncached items from database
|
|
784
|
-
if (uncachedInfo.length > 0) {
|
|
785
|
-
const uncachedResults = await this.InternalGetEntityRecordNames(uncachedInfo, contextUser);
|
|
786
|
-
// Merge results and update cache
|
|
787
|
-
for (let i = 0; i < uncachedResults.length; i++) {
|
|
788
|
-
const result = uncachedResults[i];
|
|
789
|
-
const originalIndex = uncachedIndexes[i];
|
|
790
|
-
results[originalIndex] = result;
|
|
791
|
-
// Cache successful results
|
|
792
|
-
if (result.Success && result.RecordName) {
|
|
793
|
-
const cacheKey = this.getCacheKey(result.EntityName, result.CompositeKey);
|
|
794
|
-
this._entityRecordNameCache.Set(cacheKey, result.RecordName);
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
return results;
|
|
799
|
-
}
|
|
800
|
-
else {
|
|
801
|
-
// Force refresh - bypass cache entirely
|
|
802
|
-
const results = await this.InternalGetEntityRecordNames(info, contextUser);
|
|
803
|
-
// Update cache with fresh results
|
|
804
|
-
for (const result of results) {
|
|
805
|
-
if (result.Success && result.RecordName) {
|
|
806
|
-
const cacheKey = this.getCacheKey(result.EntityName, result.CompositeKey);
|
|
807
|
-
this._entityRecordNameCache.Set(cacheKey, result.RecordName);
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
return results;
|
|
811
|
-
}
|
|
911
|
+
return this.InternalGetEntityRecordNames(info, contextUser);
|
|
812
912
|
}
|
|
813
913
|
// ========================================================================
|
|
814
914
|
// PUBLIC API METHODS - Orchestrate Pre → Cache → Internal → Post flow
|
|
@@ -1449,7 +1549,19 @@ export class ProviderBase {
|
|
|
1449
1549
|
].join('|');
|
|
1450
1550
|
return `${base}|${extras}`;
|
|
1451
1551
|
});
|
|
1452
|
-
return parts.join('||')
|
|
1552
|
+
return `${parts.join('||')}|||${this.RunViewDedupScope}`;
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* What, besides the request itself and the user, decides whether two identical RunViews may
|
|
1556
|
+
* share one execution (in flight or lingering). Empty by default. A provider whose callers can
|
|
1557
|
+
* see different data for the same query — a database provider whose open transaction belongs
|
|
1558
|
+
* to one async scope only (#4786) — returns a value that differs between those callers, so a
|
|
1559
|
+
* transaction member never awaits an outsider's pool read (missing its own writes, or on
|
|
1560
|
+
* SQL Server waiting on its own row lock) and an outsider never receives uncommitted rows.
|
|
1561
|
+
* Read synchronously while the dedup key is built, in the caller's async context.
|
|
1562
|
+
*/
|
|
1563
|
+
get RunViewDedupScope() {
|
|
1564
|
+
return '';
|
|
1453
1565
|
}
|
|
1454
1566
|
/**
|
|
1455
1567
|
* Normalizes a Fields list into a stable key segment: trimmed, lowercased,
|
|
@@ -1494,13 +1606,15 @@ export class ProviderBase {
|
|
|
1494
1606
|
if (memoized) {
|
|
1495
1607
|
return memoized;
|
|
1496
1608
|
}
|
|
1497
|
-
// The
|
|
1498
|
-
//
|
|
1499
|
-
//
|
|
1500
|
-
// maxUpdatedAt and rowCount,
|
|
1501
|
-
//
|
|
1502
|
-
//
|
|
1503
|
-
|
|
1609
|
+
// The signed-in user's row filter (rls:) and allowed field list (fls:, see
|
|
1610
|
+
// ComputeClientFLSAllowedKey) are part of client slot identity, as they are on the server.
|
|
1611
|
+
// The browser cache outlives a session — one IndexedDB store per server URL — and the
|
|
1612
|
+
// currency check compares only maxUpdatedAt and rowCount, which cannot tell one user's
|
|
1613
|
+
// rows from another's, nor notice a column. Without these segments, a different user on
|
|
1614
|
+
// the same browser could be served the previous user's rows, and a user whose field
|
|
1615
|
+
// access is tightened would keep seeing a column just taken away. Both are empty for an
|
|
1616
|
+
// unrestricted user, whose fingerprints are unchanged.
|
|
1617
|
+
const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(param), undefined, this.ComputeClientFLSAllowedKey(param));
|
|
1504
1618
|
// Normalize a FULL-COVERAGE field list to '*' — in the FINGERPRINT only (B44).
|
|
1505
1619
|
//
|
|
1506
1620
|
// entity_object params get widened to an explicit list of every entity field
|
|
@@ -2257,8 +2371,44 @@ export class ProviderBase {
|
|
|
2257
2371
|
* they asked for, and field security narrows per request at read time via
|
|
2258
2372
|
* {@link ApplyFieldSecurityProjection}.
|
|
2259
2373
|
*/
|
|
2260
|
-
ComputeRunViewFetchFields(entity) {
|
|
2261
|
-
|
|
2374
|
+
ComputeRunViewFetchFields(entity, params) {
|
|
2375
|
+
const includeBinary = params?.IncludeBinaryFields === true;
|
|
2376
|
+
return entity.Fields.filter(f => includeBinary || !f.IsBinaryFieldType).map(f => f.Name);
|
|
2377
|
+
}
|
|
2378
|
+
/**
|
|
2379
|
+
* True when `fieldNames` names at least one of the entity's binary fields (case-insensitive,
|
|
2380
|
+
* whitespace-trimmed).
|
|
2381
|
+
*
|
|
2382
|
+
* @param entity - The entity being queried.
|
|
2383
|
+
* @param fieldNames - Field names as a caller supplied them, e.g. `RunViewParams.Fields`.
|
|
2384
|
+
*/
|
|
2385
|
+
static NamesAnyBinaryField(entity, fieldNames) {
|
|
2386
|
+
if (!fieldNames || fieldNames.length === 0)
|
|
2387
|
+
return false;
|
|
2388
|
+
const binaryNames = new Set(entity.Fields.filter(f => f.IsBinaryFieldType).map(f => f.Name.toLowerCase()));
|
|
2389
|
+
if (binaryNames.size === 0)
|
|
2390
|
+
return false;
|
|
2391
|
+
return fieldNames.some(name => typeof name === 'string' && binaryNames.has(name.trim().toLowerCase()));
|
|
2392
|
+
}
|
|
2393
|
+
/**
|
|
2394
|
+
* Decides whether a RunView returns the entity's binary fields, and records the decision on
|
|
2395
|
+
* `params.IncludeBinaryFields` so every later step (field widening, cache fingerprint,
|
|
2396
|
+
* transport) sees one answer.
|
|
2397
|
+
*
|
|
2398
|
+
* Binary fields are returned when the caller sets `IncludeBinaryFields`, or names a binary
|
|
2399
|
+
* field in `Fields` — asking for a column by name is asking for it. Otherwise they are left
|
|
2400
|
+
* out, which keeps result sets, engine caches and the RunView caches free of large values
|
|
2401
|
+
* nobody reads. Must run before `Fields` is widened, since widening replaces the caller's list.
|
|
2402
|
+
*
|
|
2403
|
+
* @param params - The view parameters; `IncludeBinaryFields` is set to true when applicable.
|
|
2404
|
+
* @param entity - The entity being queried.
|
|
2405
|
+
* @returns True when binary fields will be returned.
|
|
2406
|
+
*/
|
|
2407
|
+
ResolveIncludeBinaryFields(params, entity) {
|
|
2408
|
+
if (params.IncludeBinaryFields !== true && ProviderBase.NamesAnyBinaryField(entity, params.Fields)) {
|
|
2409
|
+
params.IncludeBinaryFields = true;
|
|
2410
|
+
}
|
|
2411
|
+
return params.IncludeBinaryFields === true;
|
|
2262
2412
|
}
|
|
2263
2413
|
/**
|
|
2264
2414
|
* Pre-processing hook for RunView.
|
|
@@ -2530,8 +2680,10 @@ export class ProviderBase {
|
|
|
2530
2680
|
// field-restricted user RECEIVES is still their allowed set: the server strips denied
|
|
2531
2681
|
// columns on the wire, and the missing keys mark those fields not-loaded.
|
|
2532
2682
|
const widenForEntityObject = params.ResultType === 'entity_object';
|
|
2683
|
+
if (entity)
|
|
2684
|
+
this.ResolveIncludeBinaryFields(params, entity);
|
|
2533
2685
|
if (entity && (willCache || widenForEntityObject)) {
|
|
2534
|
-
params.Fields = this.ComputeRunViewFetchFields(entity);
|
|
2686
|
+
params.Fields = this.ComputeRunViewFetchFields(entity, params);
|
|
2535
2687
|
// Platform contract: explicit Fields always include the primary key(s) —
|
|
2536
2688
|
// project back down to requested ∪ PK, matching the direct SQL path.
|
|
2537
2689
|
if (callerRequestedFields) {
|
|
@@ -2705,8 +2857,10 @@ export class ProviderBase {
|
|
|
2705
2857
|
const batchWillCache = this.runViewCacheEligible(param);
|
|
2706
2858
|
// Same entity_object-always-widens rule as the single-view path above.
|
|
2707
2859
|
const batchWidenForEntityObject = param.ResultType === 'entity_object';
|
|
2860
|
+
if (batchEntity)
|
|
2861
|
+
this.ResolveIncludeBinaryFields(param, batchEntity);
|
|
2708
2862
|
if (batchEntity && (batchWillCache || batchWidenForEntityObject)) {
|
|
2709
|
-
param.Fields = this.ComputeRunViewFetchFields(batchEntity);
|
|
2863
|
+
param.Fields = this.ComputeRunViewFetchFields(batchEntity, param);
|
|
2710
2864
|
// Platform contract: explicit Fields always include the primary key(s)
|
|
2711
2865
|
if (callerFields) {
|
|
2712
2866
|
callerFields = ProviderBase.UnionFieldsWithPrimaryKeys(callerFields, batchEntity);
|
|
@@ -2815,7 +2969,8 @@ export class ProviderBase {
|
|
|
2815
2969
|
// provider FETCHES, not just how the slot is keyed, and I could not verify the
|
|
2816
2970
|
// downstream fetch behavior tonight. The safe fix is to normalize a full-coverage
|
|
2817
2971
|
// field list to `*` in the FINGERPRINT only, which cannot affect fetching.
|
|
2818
|
-
param
|
|
2972
|
+
this.ResolveIncludeBinaryFields(param, entity);
|
|
2973
|
+
param.Fields = this.ComputeRunViewFetchFields(entity, param);
|
|
2819
2974
|
}
|
|
2820
2975
|
// Gate on runViewCacheEligible (NOT raw param.CacheLocal): the smart-cache-check path is a
|
|
2821
2976
|
// second, independent cache transport, and gating it on CacheLocal alone re-admits the exact
|
|
@@ -3534,32 +3689,14 @@ export class ProviderBase {
|
|
|
3534
3689
|
return true;
|
|
3535
3690
|
}
|
|
3536
3691
|
extractMaxUpdatedAt(results) {
|
|
3537
|
-
|
|
3538
|
-
//
|
|
3539
|
-
// timestamp
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
}
|
|
3546
|
-
for (const item of results) {
|
|
3547
|
-
if (item && typeof item === 'object') {
|
|
3548
|
-
const record = item;
|
|
3549
|
-
// Check for __mj_UpdatedAt field (standard MJ timestamp field)
|
|
3550
|
-
const updatedAt = record['__mj_UpdatedAt'] || record['UpdatedAt'];
|
|
3551
|
-
if (updatedAt) {
|
|
3552
|
-
const date = updatedAt instanceof Date ? updatedAt : new Date(updatedAt);
|
|
3553
|
-
if (!isNaN(date.getTime()) && (!maxDate || date > maxDate)) {
|
|
3554
|
-
maxDate = date;
|
|
3555
|
-
}
|
|
3556
|
-
}
|
|
3557
|
-
}
|
|
3558
|
-
}
|
|
3559
|
-
// Return empty string for empty/timestamp-less results instead of current server time.
|
|
3560
|
-
// Using current time would make empty result sets perpetually "stale" — each smart-cache-check
|
|
3561
|
-
// would see a different timestamp and force an unnecessary DB refresh.
|
|
3562
|
-
return maxDate ? maxDate.toISOString() : '';
|
|
3692
|
+
// Delegates so the RunView write path and the engine/slot path cannot drift apart: they
|
|
3693
|
+
// used to be two implementations with different fallback columns and different answers for
|
|
3694
|
+
// timestamp-less rows, which stamped the same rows differently depending on which funnel
|
|
3695
|
+
// wrote the slot.
|
|
3696
|
+
//
|
|
3697
|
+
// Empty string, never the current time, for empty or timestamp-less results: a clock value
|
|
3698
|
+
// would make such a slot perpetually "stale", since every check would see a new timestamp.
|
|
3699
|
+
return LocalCacheManager.MaxUpdatedAtOfRows(results) ?? '';
|
|
3563
3700
|
}
|
|
3564
3701
|
/**
|
|
3565
3702
|
* Merges cached and fresh results for RunViews, maintaining original order.
|
|
@@ -3642,7 +3779,11 @@ export class ProviderBase {
|
|
|
3642
3779
|
const entity = this.EntityByName(params.EntityName);
|
|
3643
3780
|
if (!entity)
|
|
3644
3781
|
throw new Error(`Entity ${params.EntityName} not found in metadata`);
|
|
3645
|
-
|
|
3782
|
+
// Override whatever was passed in with every field the entity object hydrates from.
|
|
3783
|
+
// Binary fields are included only when requested (ResolveIncludeBinaryFields); a
|
|
3784
|
+
// missing binary field hydrates as not-loaded and is never written back on Save.
|
|
3785
|
+
this.ResolveIncludeBinaryFields(params, entity);
|
|
3786
|
+
params.Fields = this.ComputeRunViewFetchFields(entity, params);
|
|
3646
3787
|
}
|
|
3647
3788
|
}
|
|
3648
3789
|
/**
|
|
@@ -3706,7 +3847,9 @@ export class ProviderBase {
|
|
|
3706
3847
|
if (!entity) {
|
|
3707
3848
|
throw new Error(`Entity ${param.EntityName} not found in metadata`);
|
|
3708
3849
|
}
|
|
3709
|
-
|
|
3850
|
+
// Same rule as PreProcessRunView: every field, binary fields only on request.
|
|
3851
|
+
this.ResolveIncludeBinaryFields(param, entity);
|
|
3852
|
+
param.Fields = this.ComputeRunViewFetchFields(entity, param);
|
|
3710
3853
|
}
|
|
3711
3854
|
}
|
|
3712
3855
|
}
|
|
@@ -3934,8 +4077,11 @@ export class ProviderBase {
|
|
|
3934
4077
|
// Describes THIS Config only: a flag left by an earlier Refresh() must not make the
|
|
3935
4078
|
// pre-validation that follows a Config which loaded nothing skip its check.
|
|
3936
4079
|
this._configLoadedFromServer = false;
|
|
3937
|
-
// Initialize LocalCacheManager early so dataset loading can use the cache.
|
|
3938
|
-
//
|
|
4080
|
+
// Initialize LocalCacheManager early so dataset loading can use the cache. This call has no
|
|
4081
|
+
// settings to give it, so a LATER caller that does (StartupManager, passing the host's
|
|
4082
|
+
// cacheSettings) is not a no-op: Initialize applies that configuration to the already
|
|
4083
|
+
// initialized manager rather than discarding it, which is what made cacheSettings inert
|
|
4084
|
+
// before.
|
|
3939
4085
|
if (!LocalCacheManager.Instance.IsInitialized) {
|
|
3940
4086
|
const storageProvider = this.LocalStorageProvider;
|
|
3941
4087
|
await LocalCacheManager.Instance.Initialize(storageProvider);
|
|
@@ -4676,15 +4822,47 @@ export class ProviderBase {
|
|
|
4676
4822
|
}
|
|
4677
4823
|
}
|
|
4678
4824
|
}
|
|
4679
|
-
/**
|
|
4825
|
+
/**
|
|
4826
|
+
* One refresh check: fetch the remote timestamps, arm the throttle only on success, bring the
|
|
4827
|
+
* in-memory snapshot up to the stored one, then compare. The third step is what makes the
|
|
4828
|
+
* answer correct in a fleet — a peer may have written a newer snapshot to the shared store
|
|
4829
|
+
* since this process last loaded — and {@link syncLocalMetadataFromStorage} keeps it cheap by
|
|
4830
|
+
* reading the whole snapshot only when the stored timestamps say it could have changed.
|
|
4831
|
+
*/
|
|
4680
4832
|
async runRefreshCheck(providerToUse) {
|
|
4681
4833
|
const gotTimestamps = await this.RefreshRemoteMetadataTimestamps(providerToUse);
|
|
4682
4834
|
if (gotTimestamps) {
|
|
4683
4835
|
this._lastRefreshCheckAt = Date.now();
|
|
4684
4836
|
}
|
|
4685
|
-
await this.
|
|
4837
|
+
await this.syncLocalMetadataFromStorage();
|
|
4686
4838
|
return this.LocalMetadataObsolete();
|
|
4687
4839
|
}
|
|
4840
|
+
/**
|
|
4841
|
+
* Brings the in-memory metadata up to the stored snapshot before a staleness check.
|
|
4842
|
+
*
|
|
4843
|
+
* The full snapshot (several MB compressed) is read only when it may differ from what this
|
|
4844
|
+
* process holds: nothing is held yet, or the stored timestamps are not the ones this process
|
|
4845
|
+
* last loaded or saved — another process sharing the store wrote a newer snapshot, or the
|
|
4846
|
+
* store was cleared. Otherwise only the small timestamps key is read. Reading and parsing the
|
|
4847
|
+
* whole snapshot on every periodic check cost each server ~230 ms per poll on a shared cache
|
|
4848
|
+
*.
|
|
4849
|
+
*/
|
|
4850
|
+
async syncLocalMetadataFromStorage() {
|
|
4851
|
+
const ls = this.LocalStorageProvider;
|
|
4852
|
+
if (ls && this._storedTimestampsRaw !== undefined && this._localMetadata?.AllEntities?.length) {
|
|
4853
|
+
try {
|
|
4854
|
+
const tsKey = this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey;
|
|
4855
|
+
const stored = (await ls.GetItems([tsKey])).get(tsKey) ?? null;
|
|
4856
|
+
if (stored === this._storedTimestampsRaw) {
|
|
4857
|
+
return;
|
|
4858
|
+
}
|
|
4859
|
+
}
|
|
4860
|
+
catch (e) {
|
|
4861
|
+
LogError(`[Metadata Cache] Reading stored metadata timestamps failed, loading the full snapshot: ${e instanceof Error ? e.message : String(e)}`);
|
|
4862
|
+
}
|
|
4863
|
+
}
|
|
4864
|
+
await this.LoadLocalMetadataFromStorage();
|
|
4865
|
+
}
|
|
4688
4866
|
/**
|
|
4689
4867
|
* Refreshes metadata only if needed based on timestamp comparison.
|
|
4690
4868
|
* Combines check and refresh into a single operation.
|
|
@@ -4831,7 +5009,7 @@ export class ProviderBase {
|
|
|
4831
5009
|
let cachedDataset = null;
|
|
4832
5010
|
let cachedDateStr = null;
|
|
4833
5011
|
if (ls && key && dateKey) {
|
|
4834
|
-
const both = await ls.GetItems([key, dateKey],
|
|
5012
|
+
const both = await ls.GetItems([key, dateKey], ProviderBase.DatasetCacheCategory);
|
|
4835
5013
|
const rawData = both.get(key);
|
|
4836
5014
|
const rawDate = both.get(dateKey);
|
|
4837
5015
|
// Type guards — entries can be either depending on which key matched.
|
|
@@ -4847,15 +5025,8 @@ export class ProviderBase {
|
|
|
4847
5025
|
if (status && localDate.getTime() >= status.LatestUpdateDate.getTime()) {
|
|
4848
5026
|
// Timestamps suggest cache is fresh; verify per-entity row counts to
|
|
4849
5027
|
// catch deleted rows (timestamp comparison alone misses pure deletes).
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
const localEntity = cachedDataset.Results.find(e => UUIDsEqual(e.EntityID, eu.EntityID));
|
|
4853
|
-
if (!localEntity || localEntity.Results.length !== eu.RowCount) {
|
|
4854
|
-
allCountsMatch = false;
|
|
4855
|
-
break;
|
|
4856
|
-
}
|
|
4857
|
-
}
|
|
4858
|
-
if (allCountsMatch) {
|
|
5028
|
+
// Shared with IsDatasetCacheUpToDate so the two cannot drift again.
|
|
5029
|
+
if (this.DatasetRowCountsMatch(cachedDataset, status)) {
|
|
4859
5030
|
return cachedDataset;
|
|
4860
5031
|
}
|
|
4861
5032
|
}
|
|
@@ -4876,7 +5047,7 @@ export class ProviderBase {
|
|
|
4876
5047
|
if (ls) {
|
|
4877
5048
|
const key = this.GetDatasetCacheKey(datasetName, itemFilters);
|
|
4878
5049
|
const dateKey = key + '_date';
|
|
4879
|
-
const val = await ls.GetItem(dateKey);
|
|
5050
|
+
const val = await ls.GetItem(dateKey, ProviderBase.DatasetCacheCategory);
|
|
4880
5051
|
if (val) {
|
|
4881
5052
|
return new Date(val);
|
|
4882
5053
|
}
|
|
@@ -4900,17 +5071,7 @@ export class ProviderBase {
|
|
|
4900
5071
|
// in this situation, the last thing we check is for each entity, if the rowcount is the same as the server, if it is, we're good
|
|
4901
5072
|
// iterate through all of the entities and check the row counts
|
|
4902
5073
|
const localDataset = await this.GetCachedDataset(datasetName, itemFilters);
|
|
4903
|
-
|
|
4904
|
-
const localEntity = localDataset.Results.find(e => UUIDsEqual(e.EntityID, eu.EntityID));
|
|
4905
|
-
if (!localEntity || localEntity.Results.length !== eu.RowCount) {
|
|
4906
|
-
// we either couldn't find the entity in the local cache or the row count is different, so we're out of date
|
|
4907
|
-
// the RowCount being different picks up on DELETED rows. The UpdatedAt check which is handled above would pick up
|
|
4908
|
-
// on any new rows or updated rows. This approach makes sure we detect deleted rows and refresh the cache.
|
|
4909
|
-
return false;
|
|
4910
|
-
}
|
|
4911
|
-
}
|
|
4912
|
-
// if we get here that means that the row counts are the same for all entities and we're up to date
|
|
4913
|
-
return true;
|
|
5074
|
+
return this.DatasetRowCountsMatch(localDataset, status);
|
|
4914
5075
|
}
|
|
4915
5076
|
else {
|
|
4916
5077
|
// our local cache timestamp is < the server timestamp, so we're out of date
|
|
@@ -4927,6 +5088,40 @@ export class ProviderBase {
|
|
|
4927
5088
|
return false;
|
|
4928
5089
|
}
|
|
4929
5090
|
}
|
|
5091
|
+
/**
|
|
5092
|
+
* Compares the per-entity row counts of a cached dataset against the server's status.
|
|
5093
|
+
*
|
|
5094
|
+
* The timestamp comparison its callers run first catches new and updated rows; it cannot catch a
|
|
5095
|
+
* pure DELETE, which leaves `LatestUpdateDate` untouched while the count drops. This is the
|
|
5096
|
+
* check that catches those, and it is shared by `GetAndCacheDatasetByName` (which already holds
|
|
5097
|
+
* the blob) and `IsDatasetCacheUpToDate` (which reads it) so the two cannot drift apart again —
|
|
5098
|
+
* they had, and the differences were only ever masked by the warm path not running.
|
|
5099
|
+
*
|
|
5100
|
+
* Two cases decide the shape of this, both load-bearing:
|
|
5101
|
+
*
|
|
5102
|
+
* - **No counts to compare.** A status reporting no `EntityUpdateDates` never touches the blob
|
|
5103
|
+
* and is judged on its timestamp alone, so it answers `true`. Returning `false` here instead
|
|
5104
|
+
* looks strictly safer and is not: it makes such a dataset permanently stale, reloading on
|
|
5105
|
+
* every check (plan — the `dataset-cache.DS2` regression).
|
|
5106
|
+
* - **A count to compare but no blob.** The blob and its `_date` proxy expire independently and
|
|
5107
|
+
* a clear removes them in order, so the date can outlive the blob. Counts then cannot be
|
|
5108
|
+
* compared, which is `false` — refetch. A blob present but missing `Results`
|
|
5109
|
+
* answers `false` for the same reason rather than throwing, which is what the pre-unification
|
|
5110
|
+
* copy in the warm path would have done the moment that path started running.
|
|
5111
|
+
*
|
|
5112
|
+
* @param cachedDataset the cached dataset, which may be absent or incomplete
|
|
5113
|
+
* @param status the server's status for the same dataset/filters combination
|
|
5114
|
+
* @returns true when every entity the server reports matches the cache's row count for it
|
|
5115
|
+
*/
|
|
5116
|
+
DatasetRowCountsMatch(cachedDataset, status) {
|
|
5117
|
+
for (const eu of status.EntityUpdateDates) {
|
|
5118
|
+
const localEntity = cachedDataset?.Results?.find(e => UUIDsEqual(e.EntityID, eu.EntityID));
|
|
5119
|
+
if (!localEntity || localEntity.Results.length !== eu.RowCount) {
|
|
5120
|
+
return false;
|
|
5121
|
+
}
|
|
5122
|
+
}
|
|
5123
|
+
return true;
|
|
5124
|
+
}
|
|
4930
5125
|
/**
|
|
4931
5126
|
* This routine gets the local cached version of a given datasetName/itemFilters combination, it does NOT check the server status first and does not fall back on the server if there isn't a local cache version of this dataset/itemFilters combination
|
|
4932
5127
|
* @param datasetName
|
|
@@ -4938,7 +5133,7 @@ export class ProviderBase {
|
|
|
4938
5133
|
if (ls) {
|
|
4939
5134
|
const key = this.GetDatasetCacheKey(datasetName, itemFilters);
|
|
4940
5135
|
// Native object read — IDB structured-clones, localStorage / Redis JSON-decode internally.
|
|
4941
|
-
const dataset = await ls.GetItem(key);
|
|
5136
|
+
const dataset = await ls.GetItem(key, ProviderBase.DatasetCacheCategory);
|
|
4942
5137
|
if (dataset) {
|
|
4943
5138
|
return dataset;
|
|
4944
5139
|
}
|
|
@@ -4955,10 +5150,10 @@ export class ProviderBase {
|
|
|
4955
5150
|
if (ls) {
|
|
4956
5151
|
const key = this.GetDatasetCacheKey(datasetName, itemFilters);
|
|
4957
5152
|
// Native object storage — no JSON.stringify on the hot path.
|
|
4958
|
-
await ls.SetItem(key, dataset);
|
|
5153
|
+
await ls.SetItem(key, dataset, ProviderBase.DatasetCacheCategory, { TTLSeconds: ProviderBase.DatasetCacheTTLSeconds });
|
|
4959
5154
|
// Date is stored as ISO string for forward-compatibility across providers
|
|
4960
5155
|
// (Redis can't natively round-trip Date; localStorage requires string).
|
|
4961
|
-
await ls.SetItem(key + '_date', dataset.LatestUpdateDate.toISOString());
|
|
5156
|
+
await ls.SetItem(key + '_date', dataset.LatestUpdateDate.toISOString(), ProviderBase.DatasetCacheCategory, { TTLSeconds: ProviderBase.DatasetDateCacheTTLSeconds });
|
|
4962
5157
|
}
|
|
4963
5158
|
}
|
|
4964
5159
|
/**
|
|
@@ -4975,10 +5170,42 @@ export class ProviderBase {
|
|
|
4975
5170
|
// CacheDataset, so the date's presence is a faithful proxy for the
|
|
4976
5171
|
// data's presence.
|
|
4977
5172
|
const key = this.GetDatasetCacheKey(datasetName, itemFilters);
|
|
4978
|
-
const val = await ls.GetItem(key + '_date');
|
|
5173
|
+
const val = await ls.GetItem(key + '_date', ProviderBase.DatasetCacheCategory);
|
|
4979
5174
|
return val !== null && val !== undefined;
|
|
4980
5175
|
}
|
|
4981
5176
|
}
|
|
5177
|
+
/**
|
|
5178
|
+
* The cache category every dataset read and write uses.
|
|
5179
|
+
*
|
|
5180
|
+
* Every storage provider isolates by category — Redis keys are `{prefix}:{category}:{key}`,
|
|
5181
|
+
* browser localStorage `[mj]:[category]:[key]`, IndexedDB a dedicated object store, the
|
|
5182
|
+
* in-memory providers a nested map — so a read and a write that disagree about the category can
|
|
5183
|
+
* never meet. When the batched warm read in `GetAndCacheDatasetByName` named `'DatasetCache'`
|
|
5184
|
+
* while every write and every other reader landed in `default`, the warm-serve path missed on
|
|
5185
|
+
* every transport and silently refetched from the server. Naming the category once is what keeps
|
|
5186
|
+
* all six call sites in agreement.
|
|
5187
|
+
*/
|
|
5188
|
+
static { this.DatasetCacheCategory = 'DatasetCache'; }
|
|
5189
|
+
/**
|
|
5190
|
+
* How long a cached dataset lives.
|
|
5191
|
+
*
|
|
5192
|
+
* Datasets have a category to themselves and it carries no category-wide expiry, so without a
|
|
5193
|
+
* per-write TTL every distinct filter set (`GetDatasetCacheKey` includes the filters) would
|
|
5194
|
+
* leave behind a blob nothing removes. An explicit TTL bounds that growth, and the store
|
|
5195
|
+
* prefers a per-write value over any category or global setting — which is also why giving
|
|
5196
|
+
* datasets their own category did not change how long one lives. Providers that do not
|
|
5197
|
+
* implement expiry ignore it.
|
|
5198
|
+
*/
|
|
5199
|
+
static { this.DatasetCacheTTLSeconds = 3600; }
|
|
5200
|
+
/**
|
|
5201
|
+
* How long the `_date` key lives — deliberately SHORTER than the blob it vouches for.
|
|
5202
|
+
*
|
|
5203
|
+
* It is written second, so with equal lifetimes it would usually be the one to survive, leaving
|
|
5204
|
+
* a freshness claim with nothing behind it: `IsDatasetCached` answers true and the blob is gone.
|
|
5205
|
+
* Expiring the claim first makes the pair fail in the safe direction — the cache reads as
|
|
5206
|
+
* absent and is refetched.
|
|
5207
|
+
*/
|
|
5208
|
+
static { this.DatasetDateCacheTTLSeconds = ProviderBase.DatasetCacheTTLSeconds - 300; }
|
|
4982
5209
|
/**
|
|
4983
5210
|
* Creates a unique key for the given datasetName and itemFilters combination coupled with the instance connection string to ensure uniqueness when 2+ connections exist
|
|
4984
5211
|
* @param datasetName
|
|
@@ -5010,9 +5237,9 @@ export class ProviderBase {
|
|
|
5010
5237
|
const ls = this.LocalStorageProvider;
|
|
5011
5238
|
if (ls) {
|
|
5012
5239
|
const key = this.GetDatasetCacheKey(datasetName, itemFilters);
|
|
5013
|
-
await ls.Remove(key);
|
|
5240
|
+
await ls.Remove(key, ProviderBase.DatasetCacheCategory);
|
|
5014
5241
|
const dateKey = key + '_date';
|
|
5015
|
-
await ls.Remove(dateKey);
|
|
5242
|
+
await ls.Remove(dateKey, ProviderBase.DatasetCacheCategory);
|
|
5016
5243
|
}
|
|
5017
5244
|
}
|
|
5018
5245
|
/**
|
|
@@ -5161,6 +5388,12 @@ export class ProviderBase {
|
|
|
5161
5388
|
const ls = this.LocalStorageProvider;
|
|
5162
5389
|
if (!ls)
|
|
5163
5390
|
return;
|
|
5391
|
+
// Symmetrical with the save: an in-process store can only hand back a copy of the
|
|
5392
|
+
// metadata this heap already holds, at the cost of rebuilding every metadata object.
|
|
5393
|
+
if (!this.MetadataSnapshotPersistenceEnabled) {
|
|
5394
|
+
this.logMetadataSnapshotDisabledOnce();
|
|
5395
|
+
return;
|
|
5396
|
+
}
|
|
5164
5397
|
const overallStart = Date.now();
|
|
5165
5398
|
// The metadata snapshot uses three keys (timestamps, format, AllMetadata).
|
|
5166
5399
|
// Pull them in a single batched read — saves two IDB transaction round-trips
|
|
@@ -5177,7 +5410,27 @@ export class ProviderBase {
|
|
|
5177
5410
|
const format = all.get(fmtKey) ?? null;
|
|
5178
5411
|
const raw = all.get(dataKey) ?? null;
|
|
5179
5412
|
const membershipRaw = all.get(membershipKey) ?? null;
|
|
5180
|
-
|
|
5413
|
+
// The timestamps are a freshness claim FOR THE PAYLOAD, so they are only adopted when the
|
|
5414
|
+
// payload is there to adopt with them. Timestamps without a payload happen whenever the
|
|
5415
|
+
// two keys do not disappear together: the timestamps key is written last (and removed
|
|
5416
|
+
// last by a cache clear), so it outlives the payload under any per-key expiry. Adopting
|
|
5417
|
+
// them alone left this process claiming to be current while holding no metadata at all
|
|
5418
|
+
// — the staleness check then confirms "current" and nothing ever reloads.
|
|
5419
|
+
const claimUsable = !!tsRaw && !!raw;
|
|
5420
|
+
if (claimUsable) {
|
|
5421
|
+
this._latestLocalMetadataTimestamps = JSON.parse(tsRaw);
|
|
5422
|
+
}
|
|
5423
|
+
else if (!this._localMetadata?.AllEntities?.length) {
|
|
5424
|
+
// Nothing usable stored and nothing held: fall back to asking the database.
|
|
5425
|
+
this._latestLocalMetadataTimestamps = null;
|
|
5426
|
+
}
|
|
5427
|
+
// A store without timestamps says nothing about the metadata this process already holds
|
|
5428
|
+
// (another process may have just cleared it), so held timestamps survive: the staleness
|
|
5429
|
+
// check then compares what is held with the database instead of assuming it is stale.
|
|
5430
|
+
this._storedTimestampsRaw = claimUsable ? tsRaw : null;
|
|
5431
|
+
if (tsRaw && !raw) {
|
|
5432
|
+
LogStatusEx({ message: '[Metadata Cache] stored timestamps have no payload behind them (the payload expired or was cleared first) — ignoring the claim and checking the database', verboseOnly: true });
|
|
5433
|
+
}
|
|
5181
5434
|
// Restore the metadata-member entity set so the event-driven refresh works on a warm
|
|
5182
5435
|
// boot that never calls GetAllMetadata (the stale-while-revalidate fast start). The
|
|
5183
5436
|
// set is only stored after a successful dataset load, so restoring it is exactly as
|
|
@@ -5251,6 +5504,18 @@ export class ProviderBase {
|
|
|
5251
5504
|
* Used for compressed metadata storage/retrieval.
|
|
5252
5505
|
*/
|
|
5253
5506
|
static base64ToArrayBuffer(base64) {
|
|
5507
|
+
// Node decodes natively; the atob path below allocates an intermediate string and fills the
|
|
5508
|
+
// array a byte at a time.
|
|
5509
|
+
if (typeof Buffer !== 'undefined') {
|
|
5510
|
+
const buf = Buffer.from(base64, 'base64');
|
|
5511
|
+
// Copy into an exact-size buffer: `buf.buffer` is a view into a shared pool, usually
|
|
5512
|
+
// larger than the payload, so returning it directly would carry unrelated bytes and a
|
|
5513
|
+
// wrong byteLength. It is also typed ArrayBufferLike and will not assign to the
|
|
5514
|
+
// declared return type.
|
|
5515
|
+
const exact = new ArrayBuffer(buf.byteLength);
|
|
5516
|
+
new Uint8Array(exact).set(buf);
|
|
5517
|
+
return exact;
|
|
5518
|
+
}
|
|
5254
5519
|
const binaryString = atob(base64);
|
|
5255
5520
|
const bytes = new Uint8Array(binaryString.length);
|
|
5256
5521
|
for (let i = 0; i < binaryString.length; i++) {
|
|
@@ -5263,6 +5528,11 @@ export class ProviderBase {
|
|
|
5263
5528
|
* Used for compressed metadata storage/retrieval.
|
|
5264
5529
|
*/
|
|
5265
5530
|
static arrayBufferToBase64(buffer) {
|
|
5531
|
+
// Node encodes natively. The loop below concatenates one character per byte, building a rope
|
|
5532
|
+
// the size of the payload that then has to be flattened.
|
|
5533
|
+
if (typeof Buffer !== 'undefined') {
|
|
5534
|
+
return Buffer.from(buffer).toString('base64');
|
|
5535
|
+
}
|
|
5266
5536
|
const bytes = new Uint8Array(buffer);
|
|
5267
5537
|
let binary = '';
|
|
5268
5538
|
for (let i = 0; i < bytes.byteLength; i++) {
|
|
@@ -5278,6 +5548,42 @@ export class ProviderBase {
|
|
|
5278
5548
|
get LocalStoragePrefix() {
|
|
5279
5549
|
return "";
|
|
5280
5550
|
}
|
|
5551
|
+
/**
|
|
5552
|
+
* Whether anything could read the metadata snapshot back, and therefore whether saving it is
|
|
5553
|
+
* worth the cost.
|
|
5554
|
+
*
|
|
5555
|
+
* The snapshot lets a cold process start from a cached copy of the metadata instead of querying
|
|
5556
|
+
* for it, which only works if the store outlives the writer. Where it does not, both halves of
|
|
5557
|
+
* the round trip are pure cost: the save serializes, gzips and base64-encodes the entire
|
|
5558
|
+
* metadata graph, and the load parses it and rebuilds every `EntityInfo` and `EntityFieldInfo`
|
|
5559
|
+
* from a copy of objects the heap already holds. On a large tenant that is expensive enough to
|
|
5560
|
+
* exhaust the heap.
|
|
5561
|
+
*
|
|
5562
|
+
* A provider that does not declare {@link ILocalStorageProvider.SupportsCrossProcessPersistence}
|
|
5563
|
+
* is treated as persistent, which fails in the safer direction.
|
|
5564
|
+
*/
|
|
5565
|
+
get MetadataSnapshotPersistenceEnabled() {
|
|
5566
|
+
const ls = this.LocalStorageProvider;
|
|
5567
|
+
if (!ls) {
|
|
5568
|
+
return false;
|
|
5569
|
+
}
|
|
5570
|
+
return ls.SupportsCrossProcessPersistence !== false;
|
|
5571
|
+
}
|
|
5572
|
+
/**
|
|
5573
|
+
* Explains the skip once per process. The refresh path runs every 30 seconds on a busy server,
|
|
5574
|
+
* so logging per call would bury the one line that matters.
|
|
5575
|
+
*/
|
|
5576
|
+
logMetadataSnapshotDisabledOnce() {
|
|
5577
|
+
if (this._metadataSnapshotSkipLogged) {
|
|
5578
|
+
return;
|
|
5579
|
+
}
|
|
5580
|
+
this._metadataSnapshotSkipLogged = true;
|
|
5581
|
+
const name = this.LocalStorageProvider?.constructor?.name ?? 'the local storage provider';
|
|
5582
|
+
LogStatusEx({
|
|
5583
|
+
message: `[Metadata Cache] Snapshot persistence disabled: ${name} is in-process only, so a saved snapshot could never be read back. Skipping the metadata snapshot save and load.`,
|
|
5584
|
+
verboseOnly: false
|
|
5585
|
+
});
|
|
5586
|
+
}
|
|
5281
5587
|
/**
|
|
5282
5588
|
* Saves current metadata to local storage for caching.
|
|
5283
5589
|
* Serializes both timestamps and full metadata collections.
|
|
@@ -5287,6 +5593,12 @@ export class ProviderBase {
|
|
|
5287
5593
|
const ls = this.LocalStorageProvider;
|
|
5288
5594
|
if (!ls)
|
|
5289
5595
|
return;
|
|
5596
|
+
// Nothing could ever read this snapshot back, so the entire serialize/compress/encode
|
|
5597
|
+
// pass buys nothing. See MetadataSnapshotPersistenceEnabled.
|
|
5598
|
+
if (!this.MetadataSnapshotPersistenceEnabled) {
|
|
5599
|
+
this.logMetadataSnapshotDisabledOnce();
|
|
5600
|
+
return;
|
|
5601
|
+
}
|
|
5290
5602
|
const start = Date.now();
|
|
5291
5603
|
// Serialize the AllMetadata object FIRST. If this throws (or the payload write below
|
|
5292
5604
|
// fails), nothing else has been written yet, so the previously stored timestamps still
|
|
@@ -5303,7 +5615,9 @@ export class ProviderBase {
|
|
|
5303
5615
|
}
|
|
5304
5616
|
// Timestamps LAST: they are the freshness claim for everything written above, so they
|
|
5305
5617
|
// must be the final thing to land.
|
|
5306
|
-
|
|
5618
|
+
const timestampsRaw = JSON.stringify(this._latestLocalMetadataTimestamps);
|
|
5619
|
+
await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey, timestampsRaw);
|
|
5620
|
+
this._storedTimestampsRaw = timestampsRaw;
|
|
5307
5621
|
this.logMetadataSaveComplete(Date.now() - start, jsonString.length, snapshot);
|
|
5308
5622
|
}
|
|
5309
5623
|
catch (e) {
|