@memberjunction/core 6.2.0-edge.3 → 6.2.0-edge.5
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/BaseEntitySaveQueue.d.ts +7 -0
- package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
- package/dist/generic/BaseEntitySaveQueue.js +22 -4
- package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +33 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.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 +222 -7
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +555 -37
- 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 +45 -1
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +308 -187
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseInfo.d.ts +31 -3
- package/dist/generic/baseInfo.d.ts.map +1 -1
- package/dist/generic/baseInfo.js +138 -40
- package/dist/generic/baseInfo.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +57 -19
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +203 -80
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityConfiguration.d.ts +8 -2
- package/dist/generic/entityConfiguration.d.ts.map +1 -1
- package/dist/generic/entityConfiguration.js +9 -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 +103 -8
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +376 -60
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/infoProviderResolution.d.ts +16 -0
- package/dist/generic/infoProviderResolution.d.ts.map +1 -0
- package/dist/generic/infoProviderResolution.js +17 -0
- package/dist/generic/infoProviderResolution.js.map +1 -0
- package/dist/generic/interfaces.d.ts +143 -3
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +505 -56
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +1185 -175
- 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/mjapiUrl.d.ts +21 -0
- package/dist/generic/mjapiUrl.d.ts.map +1 -0
- package/dist/generic/mjapiUrl.js +25 -0
- package/dist/generic/mjapiUrl.js.map +1 -0
- package/dist/generic/providerBase.d.ts +301 -5
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +750 -168
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +10 -9
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +60 -33
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -1
- package/dist/generic/scoring/ReciprocalRankFusion.js +10 -3
- package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +11 -6
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +15 -10
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +4 -1
- 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.map +1 -1
- package/dist/generic/util.js +3 -0
- package/dist/generic/util.js.map +1 -1
- package/dist/generic/wellKnownUserSource.d.ts +3 -2
- package/dist/generic/wellKnownUserSource.d.ts.map +1 -1
- package/dist/generic/wellKnownUserSource.js +18 -9
- package/dist/generic/wellKnownUserSource.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/readme.md +29 -4
|
@@ -3,7 +3,7 @@ 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
9
|
import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, EscapeSQLString, ordinalCompare } from "@memberjunction/global";
|
|
@@ -184,6 +184,12 @@ 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
195
|
this._refresh = false;
|
|
@@ -242,8 +248,22 @@ export class ProviderBase {
|
|
|
242
248
|
* so a push can see the metadata rows it has not committed yet (MJ#4836).
|
|
243
249
|
*/
|
|
244
250
|
this._metadataReadsJoinTransaction = false;
|
|
251
|
+
/**
|
|
252
|
+
* The MJ_Metadata dataset items (code + entity name) as of the last full load. The remote
|
|
253
|
+
* timestamps ({@link GetLatestMetadataUpdates}) are keyed by entity name while a dataset
|
|
254
|
+
* read is scoped by item code, so this is the bridge that lets a staleness check be turned
|
|
255
|
+
* into a scoped reload (see {@link PartialRefreshItemCodes}).
|
|
256
|
+
*/
|
|
257
|
+
this._metadataDatasetItems = null;
|
|
245
258
|
/** Debounce timer for {@link scheduleMetadataMemberRefresh}. */
|
|
246
259
|
this._metadataMemberRefreshTimer = null;
|
|
260
|
+
// ── Metadata change notices from other servers ─────────────────────
|
|
261
|
+
/** When the current run of undebounced peer notices began; caps how long the check is deferred. */
|
|
262
|
+
this._peerMetadataNoticeFirstAt = null;
|
|
263
|
+
/** Windows the pending peer-notice check has waited for an ambient transaction to settle. */
|
|
264
|
+
this._peerMetadataNoticeRetries = 0;
|
|
265
|
+
/** Debounce timer for {@link HandlePeerMetadataNotice}. */
|
|
266
|
+
this._peerMetadataNoticeTimer = null;
|
|
247
267
|
this._metadataMemberRefreshWaits = 0;
|
|
248
268
|
this._clientFingerprintMemo = new WeakMap();
|
|
249
269
|
this._cachedVisibleExplorerNavigationItems = null;
|
|
@@ -426,6 +446,34 @@ export class ProviderBase {
|
|
|
426
446
|
this._lingerInvalidationWired = true;
|
|
427
447
|
ProviderBase._invalidationTargets.add(new WeakRef(this));
|
|
428
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* Drops this provider's in-flight/lingered RunView dedup entries so the next identical
|
|
451
|
+
* RunView goes to the server instead of replaying a result from the last
|
|
452
|
+
* {@link DedupLingerMs}.
|
|
453
|
+
*
|
|
454
|
+
* Saves and deletes made through a `BaseEntity` in this process already invalidate the entries
|
|
455
|
+
* for their entity automatically. Call this after anything that can write on the SERVER
|
|
456
|
+
* without such an event reaching this process — a Remote Operation, an Action, an agent run,
|
|
457
|
+
* any other mutation. {@link RouteOperation} and the GraphQL client's mutation transport call
|
|
458
|
+
* it for you.
|
|
459
|
+
*
|
|
460
|
+
* Dropping an in-flight entry never affects the callers already waiting on it; it only stops
|
|
461
|
+
* later callers from joining (and stops the result from lingering), since that query may have
|
|
462
|
+
* read pre-write data.
|
|
463
|
+
*
|
|
464
|
+
* @param entityNames - entities known to have been written. Only entries touching them are
|
|
465
|
+
* dropped. Omit (or pass an empty list) when the writes are unknown: every entry is dropped.
|
|
466
|
+
*/
|
|
467
|
+
InvalidateRunViewDedupCache(entityNames) {
|
|
468
|
+
const names = (entityNames ?? []).map(n => n?.trim().toLowerCase()).filter((n) => !!n);
|
|
469
|
+
if (names.length === 0) {
|
|
470
|
+
this._inflightViews.clear();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
for (const n of names) {
|
|
474
|
+
this.invalidateInflightViewsForEntity(n);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
429
477
|
/**
|
|
430
478
|
* Drops every in-flight/lingered RunView entry whose params touch the given
|
|
431
479
|
* entity (lowercased name). Called on BaseEntity save/delete/remote-invalidate.
|
|
@@ -463,6 +511,31 @@ export class ProviderBase {
|
|
|
463
511
|
* timer, so the refresh runs no earlier than this long after the LAST write.
|
|
464
512
|
*/
|
|
465
513
|
static { this.MetadataDatasetRefreshDebounceMs = 500; }
|
|
514
|
+
/**
|
|
515
|
+
* MJ_Metadata item codes that may be reloaded on their own, without rebuilding the rest of
|
|
516
|
+
* the metadata graph: the saved-query family. Those rows are written continuously by agents
|
|
517
|
+
* (a query is persisted per analysis step), and every write moves the timestamps of these
|
|
518
|
+
* items. Before this list existed, each such write cost a FULL reload — every EntityInfo and
|
|
519
|
+
* EntityFieldInfo rebuilt (hundreds of MB on a large schema) to pick up a few QueryInfo rows;
|
|
520
|
+
* on a busy server that was a fresh half-gigabyte graph per refresh window until the process
|
|
521
|
+
* hit its memory guard.
|
|
522
|
+
*
|
|
523
|
+
* Membership rule: an item qualifies only when no OTHER metadata class embeds or memoizes its
|
|
524
|
+
* rows at construction. The query classes reference each other lazily through the provider
|
|
525
|
+
* (`QueryInfo.Fields` filters `QueryFields` on first use), so they can be swapped as a set;
|
|
526
|
+
* entity metadata cannot (EntityInfo owns its fields, permissions and relationships).
|
|
527
|
+
* The family is always reloaded together — see {@link PartialRefreshItemCodes}.
|
|
528
|
+
* Intersected with {@link AllMetadataArrays} so the list is valid for whatever this build knows.
|
|
529
|
+
*/
|
|
530
|
+
static get PartialRefreshMetadataItemCodes() {
|
|
531
|
+
const known = new Set(AllMetadataArrays.map(m => m.key.substring(3))); // 'AllQueries' → 'Queries'
|
|
532
|
+
return ProviderBase._queryFamilyItemCodes.filter(code => known.has(code));
|
|
533
|
+
}
|
|
534
|
+
static { this._queryFamilyItemCodes = [
|
|
535
|
+
'QueryCategories', 'Queries', 'QueryFields', 'QueryPermissions', 'QueryEntities', 'QueryParameters', 'QueryDependencies', 'QuerySQLs',
|
|
536
|
+
]; }
|
|
537
|
+
/** The synthetic roll-up row {@link GetLatestMetadataUpdates} appends to the per-entity timestamps. */
|
|
538
|
+
static { this._allMetadataTypeName = 'All Entity Metadata'; }
|
|
466
539
|
/**
|
|
467
540
|
* Records which entities compose this provider's metadata, from the loaded MJ_Metadata
|
|
468
541
|
* dataset result, and registers this instance with the static event fan-out so writes to any
|
|
@@ -481,8 +554,68 @@ export class ProviderBase {
|
|
|
481
554
|
return; // a failed/empty load must not erase a previously recorded set
|
|
482
555
|
}
|
|
483
556
|
this._metadataDatasetEntityNames = names;
|
|
557
|
+
this._metadataDatasetItems = (dataset.Results ?? [])
|
|
558
|
+
.filter(item => item.Code && item.EntityName)
|
|
559
|
+
.map(item => ({ Code: item.Code, EntityName: item.EntityName }));
|
|
484
560
|
this.ensureInflightViewInvalidation();
|
|
485
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* Result of {@link SweepMetadataAgainstDatabase}.
|
|
564
|
+
*/
|
|
565
|
+
static { this.MetadataSweepSkipped = { Declared: [], Checked: false, Refreshed: false }; }
|
|
566
|
+
/**
|
|
567
|
+
* Compares this provider's metadata with the database — but only for metadata whose entities
|
|
568
|
+
* have **declared** that their rows can change without firing an event.
|
|
569
|
+
*
|
|
570
|
+
* Metadata staleness is otherwise event-driven: a `BaseEntity` write to one of the entities the
|
|
571
|
+
* metadata is built from schedules a refresh, another process saving its snapshot publishes a
|
|
572
|
+
* notice, and a tool that changed the database clears the shared cache. None of those fire for a
|
|
573
|
+
* change made with raw SQL, so metadata edited directly in the database is never noticed by a
|
|
574
|
+
* running process. This is the backstop for that case.
|
|
575
|
+
*
|
|
576
|
+
* It is **not** an unconditional poll. `Entity.TrustServerCacheCompletely = true`
|
|
577
|
+
* (the default) states that every mutation flows through `BaseEntity`, which the event paths
|
|
578
|
+
* already hear; polling such an entity cannot discover anything, and a recurring query prevents a
|
|
579
|
+
* serverless database from pausing. So the check reads the database only when at least one
|
|
580
|
+
* metadata member entity declares `false` — typically because an operator writes that table
|
|
581
|
+
* directly. On a stock installation no metadata entity declares it, and this costs one in-memory
|
|
582
|
+
* pass over the membership set.
|
|
583
|
+
*
|
|
584
|
+
* @param providerToUse Provider to compare against, for multi-provider clients.
|
|
585
|
+
* @returns Which entities declared drift, whether the database was consulted, and whether
|
|
586
|
+
* metadata was actually reloaded.
|
|
587
|
+
*/
|
|
588
|
+
async SweepMetadataAgainstDatabase(providerToUse) {
|
|
589
|
+
const declared = this.MetadataMembersDeclaringDrift();
|
|
590
|
+
if (declared.length === 0) {
|
|
591
|
+
return { Declared: [], Checked: false, Refreshed: false };
|
|
592
|
+
}
|
|
593
|
+
// bypassMinCheckInterval: the sweep interval is the throttle, and this caller holds positive
|
|
594
|
+
// evidence that an entity can change without telling anyone.
|
|
595
|
+
const refreshed = await this.RefreshIfNeeded(providerToUse, true);
|
|
596
|
+
return { Declared: declared, Checked: true, Refreshed: refreshed };
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* The entities this provider's metadata is built from that declare they can change without
|
|
600
|
+
* firing an event (`Entity.TrustServerCacheCompletely === false`).
|
|
601
|
+
*
|
|
602
|
+
* Empty — the usual case — means a metadata sweep has nothing to look for, so a caller can skip
|
|
603
|
+
* the work (and any lease) without asking the database anything.
|
|
604
|
+
*/
|
|
605
|
+
MetadataMembersDeclaringDrift() {
|
|
606
|
+
const names = this._metadataDatasetEntityNames;
|
|
607
|
+
if (!names || names.size === 0) {
|
|
608
|
+
return [];
|
|
609
|
+
}
|
|
610
|
+
const declared = [];
|
|
611
|
+
for (const lowerName of names) {
|
|
612
|
+
const entity = this.Entities.find(e => e.Name.trim().toLowerCase() === lowerName);
|
|
613
|
+
if (entity && entity.TrustServerCacheCompletely === false) {
|
|
614
|
+
declared.push(entity.Name);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return declared;
|
|
618
|
+
}
|
|
486
619
|
/**
|
|
487
620
|
* Whether `entityName` is one of the entities this provider's metadata is built from.
|
|
488
621
|
* False when the membership set has not been loaded yet — a caller must not treat "unknown"
|
|
@@ -600,6 +733,118 @@ export class ProviderBase {
|
|
|
600
733
|
});
|
|
601
734
|
}, this.MetadataMemberRefreshDelayMs);
|
|
602
735
|
}
|
|
736
|
+
/**
|
|
737
|
+
* Upper bound, in milliseconds, of the random delay added to {@link HandlePeerMetadataNotice}'s
|
|
738
|
+
* debounce. A tool that changes the schema removes the shared snapshot, which reaches every
|
|
739
|
+
* server at once; the spread lets the first server reload and republish, so the others adopt
|
|
740
|
+
* its snapshot instead of all querying the database together.
|
|
741
|
+
*/
|
|
742
|
+
static { this.PeerMetadataNoticeJitterMs = 2000; }
|
|
743
|
+
/**
|
|
744
|
+
* How long a peer notice may be deferred by later notices before the check runs anyway. The
|
|
745
|
+
* debounce timer is reset by each arrival, so a fleet publishing steadily — exactly what a bulk
|
|
746
|
+
* import looks like — could postpone the check indefinitely.
|
|
747
|
+
*/
|
|
748
|
+
static { this.PeerMetadataNoticeMaxDeferralMs = 15000; }
|
|
749
|
+
/**
|
|
750
|
+
* True when a shared-cache event says the metadata snapshot changed: another process saved one
|
|
751
|
+
* (`set`), or a tool that changed the database removed it (`removed`, e.g. `mj migrate`). Only
|
|
752
|
+
* the timestamps key counts — it is written last (see {@link SaveLocalMetadataToStorage}) and
|
|
753
|
+
* removed last — so each save or removal is one notice.
|
|
754
|
+
*/
|
|
755
|
+
IsMetadataChangeNotice(event) {
|
|
756
|
+
return (event.Action === 'set' || event.Action === 'removed')
|
|
757
|
+
&& (event.Category || CacheCategory.Default) === CacheCategory.Default
|
|
758
|
+
&& event.CacheKey === this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Reacts to another server saving its metadata snapshot: after a short debounce, runs the
|
|
762
|
+
* normal staleness check ({@link RefreshIfNeeded}, bypassing the check throttle). The check
|
|
763
|
+
* compares this process's timestamps with the database and adopts the shared snapshot, so a
|
|
764
|
+
* metadata change made on one server reaches the others in seconds instead of at the next
|
|
765
|
+
* poll. A server that is already current does no refresh and saves nothing, so notices do not
|
|
766
|
+
* echo around the fleet.
|
|
767
|
+
*
|
|
768
|
+
* @returns true when the event was a metadata notice (and a check was scheduled)
|
|
769
|
+
*/
|
|
770
|
+
HandlePeerMetadataNotice(event) {
|
|
771
|
+
if (!this.IsMetadataChangeNotice(event)) {
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
const now = Date.now();
|
|
775
|
+
this._peerMetadataNoticeFirstAt ??= now;
|
|
776
|
+
if (now - this._peerMetadataNoticeFirstAt >= ProviderBase.PeerMetadataNoticeMaxDeferralMs) {
|
|
777
|
+
// Notices have been arriving without a pause for longer than the deferral budget: stop
|
|
778
|
+
// resetting the timer and let the pending check run now.
|
|
779
|
+
return true;
|
|
780
|
+
}
|
|
781
|
+
if (this._peerMetadataNoticeTimer) {
|
|
782
|
+
clearTimeout(this._peerMetadataNoticeTimer);
|
|
783
|
+
}
|
|
784
|
+
const timer = setTimeout(() => {
|
|
785
|
+
this._peerMetadataNoticeTimer = null;
|
|
786
|
+
// A provider inside a transaction cannot run the check now (its reads would see the
|
|
787
|
+
// transaction's own uncommitted world, and SQL Server's RefreshIfNeeded refuses
|
|
788
|
+
// outright). Re-arm instead of dropping the notice, which used to lose it entirely.
|
|
789
|
+
// The deferral budget is deliberately NOT cleared here: it bounds how long one check
|
|
790
|
+
// may be put off, and the check has not run yet. Clearing it as the timer fires ended
|
|
791
|
+
// the budget exactly when deferral began, leaving the retry count as the only real
|
|
792
|
+
// bound.
|
|
793
|
+
if (this.MetadataMemberRefreshMustWait) {
|
|
794
|
+
this.handlePeerMetadataNoticeRetry();
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
this.runPeerMetadataCheck();
|
|
798
|
+
}, ProviderBase.MetadataDatasetRefreshDebounceMs + Math.floor(Math.random() * ProviderBase.PeerMetadataNoticeJitterMs));
|
|
799
|
+
if (typeof timer === 'object' && timer !== null && 'unref' in timer) {
|
|
800
|
+
timer.unref();
|
|
801
|
+
}
|
|
802
|
+
this._peerMetadataNoticeTimer = timer;
|
|
803
|
+
return true;
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Runs the deferred metadata check and closes out both budgets.
|
|
807
|
+
*
|
|
808
|
+
* Both are reset HERE rather than at any earlier point, because both measure deferral of a
|
|
809
|
+
* check that has now happened. The retry count in particular used to be cleared only on the
|
|
810
|
+
* re-armed path, so a check that ran on the direct path — which is what happens when a fresh
|
|
811
|
+
* notice arrives after the transaction closed — left its count behind for the next deferral to
|
|
812
|
+
* inherit.
|
|
813
|
+
*/
|
|
814
|
+
runPeerMetadataCheck() {
|
|
815
|
+
this._peerMetadataNoticeRetries = 0;
|
|
816
|
+
this._peerMetadataNoticeFirstAt = null;
|
|
817
|
+
this.RefreshIfNeeded(undefined, true).catch((e) => {
|
|
818
|
+
LogError(`Metadata check after another server's change failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Re-arms the peer-notice check when the provider was inside a transaction at the moment it
|
|
823
|
+
* came due. Bounded by the same budget as the debounce, so a provider that never leaves its
|
|
824
|
+
* transaction (a leaked handle) stops re-arming rather than holding a timer open forever; the
|
|
825
|
+
* next notice, or the periodic poll, picks the change up.
|
|
826
|
+
*/
|
|
827
|
+
handlePeerMetadataNoticeRetry() {
|
|
828
|
+
this._peerMetadataNoticeRetries++;
|
|
829
|
+
if (this._peerMetadataNoticeRetries > ProviderBase.MaxMetadataMemberRefreshWaits) {
|
|
830
|
+
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.`);
|
|
831
|
+
this._peerMetadataNoticeRetries = 0;
|
|
832
|
+
this._peerMetadataNoticeFirstAt = null; // the next notice starts a fresh budget
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
const timer = setTimeout(() => {
|
|
836
|
+
this._peerMetadataNoticeTimer = null;
|
|
837
|
+
if (this.MetadataMemberRefreshMustWait) {
|
|
838
|
+
this.handlePeerMetadataNoticeRetry();
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
this.runPeerMetadataCheck();
|
|
842
|
+
}, this.MetadataMemberRefreshDelayMs);
|
|
843
|
+
if (typeof timer === 'object' && timer !== null && 'unref' in timer) {
|
|
844
|
+
timer.unref();
|
|
845
|
+
}
|
|
846
|
+
this._peerMetadataNoticeTimer = timer;
|
|
847
|
+
}
|
|
603
848
|
/**
|
|
604
849
|
* How this provider refreshes after a metadata member entity changed. The base behavior is a
|
|
605
850
|
* hard {@link Refresh} — correct for database providers, where the process that PERFORMED the
|
|
@@ -625,6 +870,21 @@ export class ProviderBase {
|
|
|
625
870
|
return true;
|
|
626
871
|
}
|
|
627
872
|
this._metadataMemberRefreshWaits = 0;
|
|
873
|
+
// The writer holds positive evidence, so the timestamps are re-read from the authoritative
|
|
874
|
+
// status query. When they attribute the change entirely to the saved-query family (see
|
|
875
|
+
// PartialRefreshMetadataItemCodes) only that family is reloaded, cache layers bypassed as a
|
|
876
|
+
// hard refresh would. Anything that cannot be attributed — another item moved, the status
|
|
877
|
+
// read failed, or it claims nothing changed — keeps the base policy: a HARD Refresh().
|
|
878
|
+
let scoped = false;
|
|
879
|
+
try {
|
|
880
|
+
scoped = this.AllowRefresh && await this.RefreshRemoteMetadataTimestamps() && this.PartialRefreshItemCodes() !== null;
|
|
881
|
+
}
|
|
882
|
+
catch (e) {
|
|
883
|
+
LogError(`Metadata refresh after a member-entity change could not read the timestamps; running a full refresh: ${e instanceof Error ? e.message : String(e)}`);
|
|
884
|
+
}
|
|
885
|
+
if (scoped) {
|
|
886
|
+
return this.ReloadMetadata(true, undefined, true);
|
|
887
|
+
}
|
|
628
888
|
return this.Refresh();
|
|
629
889
|
}
|
|
630
890
|
/**
|
|
@@ -752,7 +1012,16 @@ export class ProviderBase {
|
|
|
752
1012
|
if (!key) {
|
|
753
1013
|
return { Success: false, ResultCode: 'INVALID_OPERATION_KEY', ErrorMessage: 'operationKey is required' };
|
|
754
1014
|
}
|
|
755
|
-
|
|
1015
|
+
try {
|
|
1016
|
+
return await this.InternalRouteOperation(key, input, options ?? {});
|
|
1017
|
+
}
|
|
1018
|
+
finally {
|
|
1019
|
+
// A Remote Operation executes server-side and can write any entity — none of which
|
|
1020
|
+
// raises a BaseEntity event in THIS process. Without this, an identical RunView answered
|
|
1021
|
+
// in the last DedupLingerMs would replay pre-operation rows. Runs on failure too: an
|
|
1022
|
+
// operation that failed part-way may still have written.
|
|
1023
|
+
this.InvalidateRunViewDedupCache();
|
|
1024
|
+
}
|
|
756
1025
|
}
|
|
757
1026
|
/**
|
|
758
1027
|
* Provider-specific transport for a Remote Operation. The default implementation reports that
|
|
@@ -1216,7 +1485,12 @@ export class ProviderBase {
|
|
|
1216
1485
|
return results;
|
|
1217
1486
|
})
|
|
1218
1487
|
.catch(err => {
|
|
1219
|
-
|
|
1488
|
+
// Only our own entry: an invalidation may have dropped it and a newer request for the
|
|
1489
|
+
// same key taken the slot, which this rejection must not evict.
|
|
1490
|
+
const entry = this._inflightViews.get(key);
|
|
1491
|
+
if (entry && entry.promise === promise) {
|
|
1492
|
+
this._inflightViews.delete(key);
|
|
1493
|
+
}
|
|
1220
1494
|
throw err;
|
|
1221
1495
|
});
|
|
1222
1496
|
this.ensureInflightViewInvalidation();
|
|
@@ -1367,7 +1641,19 @@ export class ProviderBase {
|
|
|
1367
1641
|
].join('|');
|
|
1368
1642
|
return `${base}|${extras}`;
|
|
1369
1643
|
});
|
|
1370
|
-
return parts.join('||')
|
|
1644
|
+
return `${parts.join('||')}|||${this.RunViewDedupScope}`;
|
|
1645
|
+
}
|
|
1646
|
+
/**
|
|
1647
|
+
* What, besides the request itself and the user, decides whether two identical RunViews may
|
|
1648
|
+
* share one execution (in flight or lingering). Empty by default. A provider whose callers can
|
|
1649
|
+
* see different data for the same query — a database provider whose open transaction belongs
|
|
1650
|
+
* to one async scope only (#4786) — returns a value that differs between those callers, so a
|
|
1651
|
+
* transaction member never awaits an outsider's pool read (missing its own writes, or on
|
|
1652
|
+
* SQL Server waiting on its own row lock) and an outsider never receives uncommitted rows.
|
|
1653
|
+
* Read synchronously while the dedup key is built, in the caller's async context.
|
|
1654
|
+
*/
|
|
1655
|
+
get RunViewDedupScope() {
|
|
1656
|
+
return '';
|
|
1371
1657
|
}
|
|
1372
1658
|
/**
|
|
1373
1659
|
* Normalizes a Fields list into a stable key segment: trimmed, lowercased,
|
|
@@ -1412,13 +1698,15 @@ export class ProviderBase {
|
|
|
1412
1698
|
if (memoized) {
|
|
1413
1699
|
return memoized;
|
|
1414
1700
|
}
|
|
1415
|
-
// The
|
|
1416
|
-
//
|
|
1417
|
-
//
|
|
1418
|
-
// maxUpdatedAt and rowCount,
|
|
1419
|
-
//
|
|
1420
|
-
//
|
|
1421
|
-
|
|
1701
|
+
// The signed-in user's row filter (rls:) and allowed field list (fls:, see
|
|
1702
|
+
// ComputeClientFLSAllowedKey) are part of client slot identity, as they are on the server.
|
|
1703
|
+
// The browser cache outlives a session — one IndexedDB store per server URL — and the
|
|
1704
|
+
// currency check compares only maxUpdatedAt and rowCount, which cannot tell one user's
|
|
1705
|
+
// rows from another's, nor notice a column. Without these segments, a different user on
|
|
1706
|
+
// the same browser could be served the previous user's rows, and a user whose field
|
|
1707
|
+
// access is tightened would keep seeing a column just taken away. Both are empty for an
|
|
1708
|
+
// unrestricted user, whose fingerprints are unchanged.
|
|
1709
|
+
const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(param), undefined, this.ComputeClientFLSAllowedKey(param));
|
|
1422
1710
|
// Normalize a FULL-COVERAGE field list to '*' — in the FINGERPRINT only (B44).
|
|
1423
1711
|
//
|
|
1424
1712
|
// entity_object params get widened to an explicit list of every entity field
|
|
@@ -1430,14 +1718,17 @@ export class ProviderBase {
|
|
|
1430
1718
|
// This touches ONLY how the slot is keyed. param.Fields is left untouched, so what the
|
|
1431
1719
|
// provider FETCHES is unchanged — an earlier attempt cleared param.Fields instead and was
|
|
1432
1720
|
// reverted precisely because fetch behavior could not be verified.
|
|
1433
|
-
const fieldsKey =
|
|
1721
|
+
const fieldsKey = (param.ResultType === 'entity_object' || this.isFullCoverageFieldList(param))
|
|
1722
|
+
? '*'
|
|
1723
|
+
: ProviderBase.normalizeFieldsKey(param.Fields);
|
|
1434
1724
|
const fingerprint = `${base}|f:${fieldsKey}`;
|
|
1435
1725
|
this._clientFingerprintMemo.set(param, fingerprint);
|
|
1436
1726
|
return fingerprint;
|
|
1437
1727
|
}
|
|
1438
1728
|
/**
|
|
1439
|
-
* True when `param.Fields` names EVERY field of the entity — i.e. an explicit list
|
|
1440
|
-
* semantically "full width"
|
|
1729
|
+
* True when `param.Fields` names EVERY fetchable field of the entity — i.e. an explicit list
|
|
1730
|
+
* that is semantically "full width" (accounting for binary fields excluded when
|
|
1731
|
+
* `IncludeBinaryFields !== true`). Case-insensitive; order-independent. Returns false on any
|
|
1441
1732
|
* uncertainty (unknown entity, missing fields) so the fingerprint falls back to the explicit
|
|
1442
1733
|
* list — the safe direction, since misclassifying narrow-as-full would cross-serve shapes.
|
|
1443
1734
|
*/
|
|
@@ -1449,11 +1740,15 @@ export class ProviderBase {
|
|
|
1449
1740
|
if (!entity || entity.Fields.length === 0) {
|
|
1450
1741
|
return false;
|
|
1451
1742
|
}
|
|
1452
|
-
|
|
1743
|
+
const includeBinary = param.IncludeBinaryFields === true;
|
|
1744
|
+
const expectedFields = includeBinary || !entity.HasBinaryFields
|
|
1745
|
+
? entity.Fields
|
|
1746
|
+
: entity.Fields.filter(f => !f.IsBinaryFieldType);
|
|
1747
|
+
if (param.Fields.length < expectedFields.length) {
|
|
1453
1748
|
return false; // cheap reject: cannot cover every field with fewer names
|
|
1454
1749
|
}
|
|
1455
1750
|
const requested = new Set(param.Fields.map(f => f.trim().toLowerCase()));
|
|
1456
|
-
return
|
|
1751
|
+
return expectedFields.every(f => requested.has(f.Name.trim().toLowerCase()));
|
|
1457
1752
|
}
|
|
1458
1753
|
/**
|
|
1459
1754
|
* Ranked search over **one** entity's records. See {@link IMetadataProvider.SearchEntity}
|
|
@@ -1799,7 +2094,8 @@ export class ProviderBase {
|
|
|
1799
2094
|
// the gate only consults `authorized` when a contextUser is present.
|
|
1800
2095
|
authorized: !user || qInfo.UserCanRun(user),
|
|
1801
2096
|
categoryPath: qInfo.CategoryPath,
|
|
1802
|
-
queryName: qInfo.Name
|
|
2097
|
+
queryName: qInfo.Name,
|
|
2098
|
+
queryId: qInfo.ID
|
|
1803
2099
|
};
|
|
1804
2100
|
}
|
|
1805
2101
|
async RunQuery(params, contextUser) {
|
|
@@ -1832,6 +2128,7 @@ export class ProviderBase {
|
|
|
1832
2128
|
&& !IsMaterializedDataSource(params.DataSource)
|
|
1833
2129
|
&& LocalCacheManager.Instance.IsInitialized;
|
|
1834
2130
|
let queryFingerprint;
|
|
2131
|
+
let queryDefinition;
|
|
1835
2132
|
if (queryCacheEngaged) {
|
|
1836
2133
|
// Resolve + authorize ONCE, BEFORE fingerprinting (B45/B46 seam). The resolved
|
|
1837
2134
|
// category feeds the fingerprint below, and the same result drives the permission
|
|
@@ -1845,13 +2142,19 @@ export class ProviderBase {
|
|
|
1845
2142
|
__maxRows: params.MaxRows ?? -1,
|
|
1846
2143
|
__startRow: params.StartRow ?? 0
|
|
1847
2144
|
};
|
|
1848
|
-
queryFingerprint = LocalCacheManager.Instance.GenerateRunQueryFingerprint(
|
|
2145
|
+
queryFingerprint = LocalCacheManager.Instance.GenerateRunQueryFingerprint(
|
|
2146
|
+
// The RESOLVED identity when metadata resolves, so a request by ID, by name, or by
|
|
2147
|
+
// both shares one slot (and a query's slots can be found by its ID). The caller's
|
|
2148
|
+
// identity otherwise.
|
|
2149
|
+
queryAuth.resolvable ? queryAuth.queryId : params.QueryID, queryAuth.resolvable ? queryAuth.queryName : params.QueryName, fingerprintParams, this.InstanceConnectionString,
|
|
1849
2150
|
// B46: the full category path is a DISTINGUISHING element. Canonical resolved
|
|
1850
2151
|
// path when metadata resolves; the caller-stated path otherwise (runtime-created
|
|
1851
2152
|
// queries) — either way, same-named queries in different categories get
|
|
1852
2153
|
// different slots.
|
|
1853
2154
|
queryAuth.resolvable ? queryAuth.categoryPath : params.CategoryPath);
|
|
1854
|
-
|
|
2155
|
+
queryDefinition = queryAuth.definition;
|
|
2156
|
+
// TTL-enforced, and a miss when the slot was written under another query definition.
|
|
2157
|
+
const cached = await LocalCacheManager.Instance.GetRunQueryResult(queryFingerprint, queryDefinition);
|
|
1855
2158
|
if (cached) {
|
|
1856
2159
|
const serveFromSlot = () => ({
|
|
1857
2160
|
QueryID: cached.queryId ?? params.QueryID ?? '',
|
|
@@ -1935,7 +2238,9 @@ export class ProviderBase {
|
|
|
1935
2238
|
else {
|
|
1936
2239
|
const response = await checker([{
|
|
1937
2240
|
params,
|
|
1938
|
-
|
|
2241
|
+
// '' for a slot written without a hash: the server answers `stale` and returns
|
|
2242
|
+
// its hash, so the rewrite below stamps the slot.
|
|
2243
|
+
cacheStatus: { maxUpdatedAt: cached.maxUpdatedAt, rowCount: cached.rowCount, definitionHash: cached.definitionHash ?? '' }
|
|
1939
2244
|
}], contextUser);
|
|
1940
2245
|
const check = response.results?.[0];
|
|
1941
2246
|
if (response.success && check) {
|
|
@@ -1945,7 +2250,7 @@ export class ProviderBase {
|
|
|
1945
2250
|
if ((check.status === 'stale' || check.status === 'no_validation') && check.results) {
|
|
1946
2251
|
const freshRows = check.results;
|
|
1947
2252
|
// Fire-and-forget slot rewrite — same pattern as the RunView client path
|
|
1948
|
-
LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, params.QueryName ?? '', freshRows, check.maxUpdatedAt ?? '', check.rowCount, check.queryId, params.CacheLocalTTL, contextUser?.ID).catch(e => LogError(`RunQuery cache rewrite failed: ${e}`));
|
|
2253
|
+
LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, params.QueryName ?? '', freshRows, check.maxUpdatedAt ?? '', check.rowCount, check.queryId, params.CacheLocalTTL, contextUser?.ID, check.definitionHash ? { QueryID: check.queryId, Hash: check.definitionHash } : undefined).catch(e => LogError(`RunQuery cache rewrite failed: ${e}`));
|
|
1949
2254
|
return {
|
|
1950
2255
|
QueryID: check.queryId ?? params.QueryID ?? '',
|
|
1951
2256
|
QueryName: params.QueryName ?? '',
|
|
@@ -1981,7 +2286,7 @@ export class ProviderBase {
|
|
|
1981
2286
|
// or the LocalCacheManager default). maxUpdatedAt is unknown for a plain run —
|
|
1982
2287
|
// the smart-validation path stamps it when the Query has CacheValidationSQL.
|
|
1983
2288
|
if (queryCacheEngaged && queryFingerprint && result.Success) {
|
|
1984
|
-
LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, result.QueryName, result.Results, '', result.TotalRowCount, result.QueryID, params.CacheLocalTTL, contextUser?.ID).catch(e => LogError(`RunQuery cache write failed: ${e}`));
|
|
2289
|
+
LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, result.QueryName, result.Results, '', result.TotalRowCount, result.QueryID, params.CacheLocalTTL, contextUser?.ID, queryDefinition).catch(e => LogError(`RunQuery cache write failed: ${e}`));
|
|
1985
2290
|
}
|
|
1986
2291
|
return result;
|
|
1987
2292
|
}
|
|
@@ -2187,12 +2492,15 @@ export class ProviderBase {
|
|
|
2187
2492
|
* @param fieldNames - Field names as a caller supplied them, e.g. `RunViewParams.Fields`.
|
|
2188
2493
|
*/
|
|
2189
2494
|
static NamesAnyBinaryField(entity, fieldNames) {
|
|
2190
|
-
if (!fieldNames || fieldNames.length === 0)
|
|
2191
|
-
return false;
|
|
2192
|
-
const binaryNames = new Set(entity.Fields.filter(f => f.IsBinaryFieldType).map(f => f.Name.toLowerCase()));
|
|
2193
|
-
if (binaryNames.size === 0)
|
|
2495
|
+
if (!fieldNames || fieldNames.length === 0 || !entity.HasBinaryFields)
|
|
2194
2496
|
return false;
|
|
2195
|
-
|
|
2497
|
+
const binaryFields = entity.BinaryFields;
|
|
2498
|
+
return fieldNames.some(name => {
|
|
2499
|
+
if (typeof name !== 'string')
|
|
2500
|
+
return false;
|
|
2501
|
+
const lower = name.trim().toLowerCase();
|
|
2502
|
+
return binaryFields.some(bf => bf.Name.toLowerCase() === lower);
|
|
2503
|
+
});
|
|
2196
2504
|
}
|
|
2197
2505
|
/**
|
|
2198
2506
|
* Decides whether a RunView returns the entity's binary fields, and records the decision on
|
|
@@ -2757,24 +3065,13 @@ export class ProviderBase {
|
|
|
2757
3065
|
for (let i = 0; i < params.length; i++) {
|
|
2758
3066
|
const param = params[i];
|
|
2759
3067
|
await this.EntityStatusCheck(param, 'PreRunViews', contextUser);
|
|
3068
|
+
let entity;
|
|
2760
3069
|
if (param.ResultType === 'entity_object') {
|
|
2761
|
-
|
|
3070
|
+
entity = this.EntityByName(param.EntityName);
|
|
2762
3071
|
if (!entity) {
|
|
2763
3072
|
throw new Error(`Entity ${param.EntityName} not found in metadata`);
|
|
2764
3073
|
}
|
|
2765
|
-
// NOTE (R2-GAP-1, logged as B44): widening to an explicit full field list makes
|
|
2766
|
-
// the client fingerprint carry `f:<every field>` rather than `f:*`, so the cache
|
|
2767
|
-
// classifier reads the slot as a NARROW projection and stops maintaining it in
|
|
2768
|
-
// place. entity_object is the BaseEngine default, so this costs in-place
|
|
2769
|
-
// maintenance on the client's most common slot shape. It is a PERF gap only —
|
|
2770
|
-
// the slot is invalidated and refetched, never served wrong.
|
|
2771
|
-
//
|
|
2772
|
-
// Deliberately NOT "fixed" by clearing Fields here: that would change what the
|
|
2773
|
-
// provider FETCHES, not just how the slot is keyed, and I could not verify the
|
|
2774
|
-
// downstream fetch behavior tonight. The safe fix is to normalize a full-coverage
|
|
2775
|
-
// field list to `*` in the FINGERPRINT only, which cannot affect fetching.
|
|
2776
3074
|
this.ResolveIncludeBinaryFields(param, entity);
|
|
2777
|
-
param.Fields = this.ComputeRunViewFetchFields(entity, param);
|
|
2778
3075
|
}
|
|
2779
3076
|
// Gate on runViewCacheEligible (NOT raw param.CacheLocal): the smart-cache-check path is a
|
|
2780
3077
|
// second, independent cache transport, and gating it on CacheLocal alone re-admits the exact
|
|
@@ -2782,9 +3079,16 @@ export class ProviderBase {
|
|
|
2782
3079
|
// BaseEntity event), count_only, AfterKey, BypassCache, cache-disallowed entities. On a client
|
|
2783
3080
|
// (!TrustLocalCacheCompletely) runViewCacheEligible already implies CacheLocal===true, so this is
|
|
2784
3081
|
// strictly a tightening — normal cacheable slots are unaffected.
|
|
3082
|
+
//
|
|
3083
|
+
// Compute clientCacheFingerprint BEFORE widening param.Fields for entity_object so the
|
|
3084
|
+
// fingerprint sees the caller's original Fields (normalizing to `|f:*` in O(1) without
|
|
3085
|
+
// building a full-entity field Set or misclassifying entities with excluded binary columns).
|
|
2785
3086
|
if (this.runViewCacheEligibleForWrite(param) && LocalCacheManager.Instance.IsInitialized) {
|
|
2786
3087
|
cacheable.push({ paramIndex: i, fingerprint: this.clientCacheFingerprint(param) });
|
|
2787
3088
|
}
|
|
3089
|
+
if (param.ResultType === 'entity_object' && entity) {
|
|
3090
|
+
param.Fields = this.ComputeRunViewFetchFields(entity, param);
|
|
3091
|
+
}
|
|
2788
3092
|
}
|
|
2789
3093
|
// Phase 2 — batched read: one IDB transaction (or one Redis MGET) returns
|
|
2790
3094
|
// every cached entry's status (maxUpdatedAt + rowCount). Was previously N
|
|
@@ -2809,7 +3113,8 @@ export class ProviderBase {
|
|
|
2809
3113
|
allCached: false, // Don't return cached directly - let server validate
|
|
2810
3114
|
useSmartCacheCheck: true,
|
|
2811
3115
|
smartCacheCheckParams,
|
|
2812
|
-
cacheStatusMap: new Map()
|
|
3116
|
+
cacheStatusMap: new Map(),
|
|
3117
|
+
preResolvedCache: cacheMap,
|
|
2813
3118
|
};
|
|
2814
3119
|
}
|
|
2815
3120
|
/**
|
|
@@ -2843,20 +3148,30 @@ export class ProviderBase {
|
|
|
2843
3148
|
UserViewRunID: ''
|
|
2844
3149
|
}));
|
|
2845
3150
|
}
|
|
2846
|
-
// ──
|
|
2847
|
-
//
|
|
2848
|
-
//
|
|
2849
|
-
//
|
|
2850
|
-
//
|
|
2851
|
-
|
|
3151
|
+
// ── Reuse pre-resolved cache map from prepareSmartCacheCheckParams ──
|
|
3152
|
+
// prepareSmartCacheCheckParams already read all cacheable fingerprints from
|
|
3153
|
+
// LocalCacheManager in a single batched IDB transaction. Reuse that map directly
|
|
3154
|
+
// so 'current' entries don't trigger a second IDB transaction + JSON.parse pass.
|
|
3155
|
+
// Only query LocalCacheManager for any 'current' fingerprint not already present
|
|
3156
|
+
// in preResult.preResolvedCache (e.g., direct test invocations).
|
|
3157
|
+
const preResolvedCache = preResult.preResolvedCache
|
|
3158
|
+
? new Map(preResult.preResolvedCache)
|
|
3159
|
+
: new Map();
|
|
3160
|
+
const missingFingerprints = [];
|
|
2852
3161
|
for (const sr of response.results) {
|
|
2853
3162
|
if (sr.status === 'current' && params[sr.viewIndex]) {
|
|
2854
|
-
|
|
3163
|
+
const fp = this.clientCacheFingerprint(params[sr.viewIndex]);
|
|
3164
|
+
if (!preResolvedCache.has(fp)) {
|
|
3165
|
+
missingFingerprints.push(fp);
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
if (missingFingerprints.length > 0) {
|
|
3170
|
+
const fetched = await LocalCacheManager.Instance.GetRunViewResults(missingFingerprints);
|
|
3171
|
+
for (const [k, v] of fetched) {
|
|
3172
|
+
preResolvedCache.set(k, v);
|
|
2855
3173
|
}
|
|
2856
3174
|
}
|
|
2857
|
-
const preResolvedCache = currentFingerprints.length > 0
|
|
2858
|
-
? await LocalCacheManager.Instance.GetRunViewResults(currentFingerprints)
|
|
2859
|
-
: new Map();
|
|
2860
3175
|
// Pre-build index Map for server results lookup — avoids O(N^2) .find() scans below.
|
|
2861
3176
|
const serverResultsByViewIndex = new Map();
|
|
2862
3177
|
if (response.results) {
|
|
@@ -3493,32 +3808,14 @@ export class ProviderBase {
|
|
|
3493
3808
|
return true;
|
|
3494
3809
|
}
|
|
3495
3810
|
extractMaxUpdatedAt(results) {
|
|
3496
|
-
|
|
3497
|
-
//
|
|
3498
|
-
// timestamp
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
}
|
|
3505
|
-
for (const item of results) {
|
|
3506
|
-
if (item && typeof item === 'object') {
|
|
3507
|
-
const record = item;
|
|
3508
|
-
// Check for __mj_UpdatedAt field (standard MJ timestamp field)
|
|
3509
|
-
const updatedAt = record['__mj_UpdatedAt'] || record['UpdatedAt'];
|
|
3510
|
-
if (updatedAt) {
|
|
3511
|
-
const date = updatedAt instanceof Date ? updatedAt : new Date(updatedAt);
|
|
3512
|
-
if (!isNaN(date.getTime()) && (!maxDate || date > maxDate)) {
|
|
3513
|
-
maxDate = date;
|
|
3514
|
-
}
|
|
3515
|
-
}
|
|
3516
|
-
}
|
|
3517
|
-
}
|
|
3518
|
-
// Return empty string for empty/timestamp-less results instead of current server time.
|
|
3519
|
-
// Using current time would make empty result sets perpetually "stale" — each smart-cache-check
|
|
3520
|
-
// would see a different timestamp and force an unnecessary DB refresh.
|
|
3521
|
-
return maxDate ? maxDate.toISOString() : '';
|
|
3811
|
+
// Delegates so the RunView write path and the engine/slot path cannot drift apart: they
|
|
3812
|
+
// used to be two implementations with different fallback columns and different answers for
|
|
3813
|
+
// timestamp-less rows, which stamped the same rows differently depending on which funnel
|
|
3814
|
+
// wrote the slot.
|
|
3815
|
+
//
|
|
3816
|
+
// Empty string, never the current time, for empty or timestamp-less results: a clock value
|
|
3817
|
+
// would make such a slot perpetually "stale", since every check would see a new timestamp.
|
|
3818
|
+
return LocalCacheManager.MaxUpdatedAtOfRows(results) ?? '';
|
|
3522
3819
|
}
|
|
3523
3820
|
/**
|
|
3524
3821
|
* Merges cached and fresh results for RunViews, maintaining original order.
|
|
@@ -3899,12 +4196,15 @@ export class ProviderBase {
|
|
|
3899
4196
|
// Describes THIS Config only: a flag left by an earlier Refresh() must not make the
|
|
3900
4197
|
// pre-validation that follows a Config which loaded nothing skip its check.
|
|
3901
4198
|
this._configLoadedFromServer = false;
|
|
3902
|
-
// Initialize LocalCacheManager early so dataset loading can use the cache.
|
|
3903
|
-
//
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
4199
|
+
// Initialize LocalCacheManager early so dataset loading can use the cache. This call has no
|
|
4200
|
+
// settings to give it, so a LATER caller that does (StartupManager, passing the host's
|
|
4201
|
+
// cacheSettings) is not a no-op: Initialize applies that configuration to the already
|
|
4202
|
+
// initialized manager rather than discarding it, which is what made cacheSettings inert
|
|
4203
|
+
// before. Kick off initialization without awaiting immediately so on warm boots it runs
|
|
4204
|
+
// concurrently with LoadLocalMetadataFromStorage() instead of adding a serial IDB round-trip.
|
|
4205
|
+
const localCacheInitPromise = !LocalCacheManager.Instance.IsInitialized
|
|
4206
|
+
? LocalCacheManager.Instance.Initialize(this.LocalStorageProvider)
|
|
4207
|
+
: Promise.resolve();
|
|
3908
4208
|
// first, let's check to see if we have an existing Metadata.Provider registered, if so
|
|
3909
4209
|
// unless our data.IgnoreExistingMetadata is set to true, we will not refresh the metadata.
|
|
3910
4210
|
//
|
|
@@ -3921,6 +4221,7 @@ export class ProviderBase {
|
|
|
3921
4221
|
// we have an existing globally registered provider AND we are not
|
|
3922
4222
|
// requested to ignore the existing metadata, so we will not refresh it
|
|
3923
4223
|
if (this.CopyMetadataFromGlobalProvider()) {
|
|
4224
|
+
await localCacheInitPromise;
|
|
3924
4225
|
return true; // we're done, if we fail here, we keep going and do normal logic
|
|
3925
4226
|
}
|
|
3926
4227
|
}
|
|
@@ -3934,7 +4235,7 @@ export class ProviderBase {
|
|
|
3934
4235
|
// This is a stale-while-revalidate pattern: serve cached data instantly, then
|
|
3935
4236
|
// refresh if the server says it's outdated.
|
|
3936
4237
|
if (!this.TrustLocalCacheCompletely && !hardRefresh && !this._localMetadata?.AllEntities?.length) {
|
|
3937
|
-
await this.LoadLocalMetadataFromStorage();
|
|
4238
|
+
await Promise.all([localCacheInitPromise, this.LoadLocalMetadataFromStorage()]);
|
|
3938
4239
|
if (this._localMetadata?.AllEntities?.length) {
|
|
3939
4240
|
LogStatusEx({ message: `⚡ [Metadata Cache] Loaded ${this._localMetadata.AllEntities.length} entities from local cache — pre-validating before engine startup`, verboseOnly: false });
|
|
3940
4241
|
// Do NOT kick off background validation here — it writes to IndexedDB
|
|
@@ -3944,6 +4245,9 @@ export class ProviderBase {
|
|
|
3944
4245
|
return true; // App can proceed immediately with cached metadata
|
|
3945
4246
|
}
|
|
3946
4247
|
}
|
|
4248
|
+
else {
|
|
4249
|
+
await localCacheInitPromise;
|
|
4250
|
+
}
|
|
3947
4251
|
// An empty graph has nothing to throttle: the throttle exists to stop redundant checks of a
|
|
3948
4252
|
// snapshot we already hold. After a status check succeeded but the download that followed
|
|
3949
4253
|
// failed, the throttle is armed and a non-bypassing retry would read "current" and load
|
|
@@ -3951,36 +4255,71 @@ export class ProviderBase {
|
|
|
3951
4255
|
const graphIsEmpty = !this._localMetadata?.AllEntities?.length;
|
|
3952
4256
|
if (hardRefresh || await this.CheckToSeeIfRefreshNeeded(providerToUse, graphIsEmpty)) {
|
|
3953
4257
|
// either a hard refresh flag was set within Refresh(), or LocalMetadata is Obsolete
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
4258
|
+
return this.ReloadMetadata(hardRefresh, providerToUse);
|
|
4259
|
+
}
|
|
4260
|
+
return true;
|
|
4261
|
+
}
|
|
4262
|
+
/**
|
|
4263
|
+
* Reloads this provider's metadata. Single-flight: at most one reload runs at a time, and a
|
|
4264
|
+
* request arriving mid-reload queues exactly one follow-up pass.
|
|
4265
|
+
* @param hardRefresh - re-read the remote timestamps first and bypass every cache layer
|
|
4266
|
+
* @param scopedFirst - try the scoped reload of the saved-query family (see
|
|
4267
|
+
* {@link PartialRefreshMetadataItemCodes}) before the full one. Defaults to true unless
|
|
4268
|
+
* hardRefresh: an explicit Refresh() is always a full reload.
|
|
4269
|
+
*/
|
|
4270
|
+
async ReloadMetadata(hardRefresh, providerToUse, scopedFirst = !hardRefresh) {
|
|
4271
|
+
// first, make sure we reset the flag to false so that if another call to this function happens
|
|
4272
|
+
// while we are waiting for the async call to finish, we dont do it again
|
|
4273
|
+
this._refresh = false;
|
|
4274
|
+
// SINGLE-FLIGHT: at most one full metadata reload runs at a time. Without this, a
|
|
4275
|
+
// second refresh request arriving while a reload is still awaiting its queries starts
|
|
4276
|
+
// a CONCURRENT reload, and whichever finishes LAST wins the atomic swap — an older
|
|
4277
|
+
// snapshot can overwrite a newer one. A joiner must not simply await and return,
|
|
4278
|
+
// either: the in-flight reload's queries may predate the write that prompted the
|
|
4279
|
+
// joiner, so it flags ONE follow-up; the loop below reruns after the current pass,
|
|
4280
|
+
// guaranteeing the final swap comes from a read that started after the last request.
|
|
4281
|
+
if (this._metadataReloadInFlight) {
|
|
4282
|
+
this._metadataReloadQueued = true;
|
|
4283
|
+
if (this._metadataReadsJoinTransaction) {
|
|
4284
|
+
// Caller owns an open database transaction (RefreshWithinTransaction) and is
|
|
4285
|
+
// waiting for this reload before it can COMMIT or ROLLBACK. An in-flight pool
|
|
4286
|
+
// reload may be blocked in the database on the caller's own uncommitted locks
|
|
4287
|
+
// (LCK_M_S), so awaiting _metadataReloadInFlight here would self-deadlock.
|
|
4288
|
+
// Run the transactional reload directly on the ambient transaction; setting
|
|
4289
|
+
// _metadataReloadQueued = true above ensures the pool reload re-reads clean
|
|
4290
|
+
// committed state once the transaction finishes and releases its locks.
|
|
4291
|
+
await this.RefreshRemoteMetadataTimestamps(providerToUse);
|
|
4292
|
+
const res = await this.GetAllMetadata(providerToUse, true);
|
|
4293
|
+
if (res) {
|
|
4294
|
+
await this.adoptServerMetadata(res);
|
|
4295
|
+
this._configLoadedFromServer = true;
|
|
4296
|
+
}
|
|
3967
4297
|
return true;
|
|
3968
4298
|
}
|
|
3969
|
-
this._metadataReloadInFlight
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
4299
|
+
await this._metadataReloadInFlight;
|
|
4300
|
+
return true;
|
|
4301
|
+
}
|
|
4302
|
+
this._metadataReloadInFlight = (async () => {
|
|
4303
|
+
let effectiveHardRefresh = hardRefresh;
|
|
4304
|
+
let tryScoped = scopedFirst;
|
|
4305
|
+
do {
|
|
4306
|
+
this._metadataReloadQueued = false;
|
|
4307
|
+
// The local timestamps must describe the snapshot about to be loaded. On the
|
|
4308
|
+
// hard-refresh path the staleness check was SKIPPED, so the cached remote
|
|
4309
|
+
// timestamps predate this pass — copying them as-is would make the next
|
|
4310
|
+
// periodic check see a mismatch and reload once more for nothing. Re-read
|
|
4311
|
+
// them (one cheap status query, authoritative) BEFORE the load, not after:
|
|
4312
|
+
// a write landing DURING the load then leaves the stamped timestamps looking
|
|
4313
|
+
// stale and the next tick reloads — the safe direction. Reading after could
|
|
4314
|
+
// stamp the snapshot as containing a write it does not.
|
|
4315
|
+
if (effectiveHardRefresh) {
|
|
4316
|
+
await this.RefreshRemoteMetadataTimestamps(providerToUse);
|
|
4317
|
+
}
|
|
4318
|
+
// SCOPED FIRST: when the staleness is attributable entirely to the saved-query family
|
|
4319
|
+
// (see PartialRefreshMetadataItemCodes), reload just that family and keep every other
|
|
4320
|
+
// array as is. Anything else — or a failed scoped read — takes the full reload below.
|
|
4321
|
+
const scopedDone = tryScoped && await this.TryPartialMetadataReload(providerToUse);
|
|
4322
|
+
if (!scopedDone) {
|
|
3984
4323
|
// Fetch new metadata without clearing current metadata
|
|
3985
4324
|
// This ensures readers always see valid data (old until new is ready)
|
|
3986
4325
|
const start = new Date().getTime();
|
|
@@ -3995,17 +4334,20 @@ export class ProviderBase {
|
|
|
3995
4334
|
// GetAllMetadata failed - log error but keep existing metadata
|
|
3996
4335
|
LogError('GetAllMetadata() returned undefined - metadata not updated');
|
|
3997
4336
|
}
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
}
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4337
|
+
}
|
|
4338
|
+
// A queued follow-up exists only because another refresh request arrived
|
|
4339
|
+
// mid-reload; rerun hard so the re-read cannot be served by any cache layer.
|
|
4340
|
+
effectiveHardRefresh = true;
|
|
4341
|
+
// The follow-up re-reads the timestamps first, so it may still be attributed to the
|
|
4342
|
+
// saved-query family — and a scoped read never trusts a cache layer either.
|
|
4343
|
+
tryScoped = true;
|
|
4344
|
+
} while (this._metadataReloadQueued);
|
|
4345
|
+
})();
|
|
4346
|
+
try {
|
|
4347
|
+
await this._metadataReloadInFlight;
|
|
4348
|
+
}
|
|
4349
|
+
finally {
|
|
4350
|
+
this._metadataReloadInFlight = null;
|
|
4009
4351
|
}
|
|
4010
4352
|
return true;
|
|
4011
4353
|
}
|
|
@@ -4022,6 +4364,117 @@ export class ProviderBase {
|
|
|
4022
4364
|
this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps; // we just used the server to get all the stuff
|
|
4023
4365
|
await this.SaveLocalMetadataToStorage();
|
|
4024
4366
|
}
|
|
4367
|
+
/**
|
|
4368
|
+
* Decides whether the current staleness can be served by a scoped reload of the saved-query
|
|
4369
|
+
* family. Compares local and remote timestamps item by item (the same test as
|
|
4370
|
+
* {@link LocalMetadataObsolete}) and maps each stale entity back to its MJ_Metadata item code.
|
|
4371
|
+
* @returns the family's item codes (the whole family — the query classes are swapped as a set)
|
|
4372
|
+
* when every stale item belongs to it; null when anything else is stale, nothing is, or the
|
|
4373
|
+
* inputs needed to attribute the change are missing — the full reload is then the answer.
|
|
4374
|
+
*/
|
|
4375
|
+
PartialRefreshItemCodes() {
|
|
4376
|
+
const items = this._metadataDatasetItems;
|
|
4377
|
+
const local = this.LatestLocalMetadata;
|
|
4378
|
+
const remote = this.LatestRemoteMetadata;
|
|
4379
|
+
if (!items?.length || !this._localMetadata?.AllEntities?.length || !local?.length || !remote?.length) {
|
|
4380
|
+
return null;
|
|
4381
|
+
}
|
|
4382
|
+
const family = new Set(ProviderBase.PartialRefreshMetadataItemCodes);
|
|
4383
|
+
const norm = (v) => (v ?? '').trim().toLowerCase();
|
|
4384
|
+
const rollup = norm(ProviderBase._allMetadataTypeName);
|
|
4385
|
+
let stale = 0;
|
|
4386
|
+
for (const r of remote) {
|
|
4387
|
+
if (norm(r.Type) === rollup) {
|
|
4388
|
+
continue; // the roll-up moves with any item; the per-item rows decide
|
|
4389
|
+
}
|
|
4390
|
+
const l = local.find(m => norm(m.Type) === norm(r.Type));
|
|
4391
|
+
if (l && !ProviderBase.metadataStampChanged(l, r)) {
|
|
4392
|
+
continue;
|
|
4393
|
+
}
|
|
4394
|
+
const owners = items.filter(i => norm(i.EntityName) === norm(r.Type));
|
|
4395
|
+
if (owners.length === 0 || owners.some(o => !family.has(o.Code))) {
|
|
4396
|
+
return null; // something outside the family moved, or a new item appeared
|
|
4397
|
+
}
|
|
4398
|
+
stale++;
|
|
4399
|
+
}
|
|
4400
|
+
if (local.some(l => norm(l.Type) !== rollup && !remote.some(r => norm(r.Type) === norm(l.Type)))) {
|
|
4401
|
+
return null; // an item left the dataset
|
|
4402
|
+
}
|
|
4403
|
+
if (stale === 0) {
|
|
4404
|
+
return null; // nothing attributable — the caller's own policy decides
|
|
4405
|
+
}
|
|
4406
|
+
const codes = [...new Set(items.map(i => i.Code).filter(c => family.has(c)))];
|
|
4407
|
+
return codes.length > 0 ? codes : null;
|
|
4408
|
+
}
|
|
4409
|
+
static metadataStampChanged(local, remote) {
|
|
4410
|
+
if (!local.UpdatedAt && !remote.UpdatedAt) {
|
|
4411
|
+
return false;
|
|
4412
|
+
}
|
|
4413
|
+
if (!local.UpdatedAt || !remote.UpdatedAt) {
|
|
4414
|
+
return true;
|
|
4415
|
+
}
|
|
4416
|
+
return new Date(local.UpdatedAt).getTime() !== new Date(remote.UpdatedAt).getTime() || local.RowCount !== remote.RowCount;
|
|
4417
|
+
}
|
|
4418
|
+
/**
|
|
4419
|
+
* Reloads only the saved-query family of MJ_Metadata and swaps it into the current metadata,
|
|
4420
|
+
* keeping every other array — and every object in them — as is. The dataset definition is
|
|
4421
|
+
* reused verbatim (same columns, where clauses and post-processing): every other item is
|
|
4422
|
+
* requested with a `1=0` filter, so it reads nothing. Always bypasses cache layers — the read
|
|
4423
|
+
* is small, and a cached answer is exactly what a staleness check cannot trust.
|
|
4424
|
+
* @returns true when the scoped reload was applied; false when it does not apply or any part
|
|
4425
|
+
* of it failed — the caller then runs the full reload, so a failure never leaves stale queries.
|
|
4426
|
+
*/
|
|
4427
|
+
async TryPartialMetadataReload(providerToUse) {
|
|
4428
|
+
const codes = this.PartialRefreshItemCodes();
|
|
4429
|
+
const current = this._localMetadata;
|
|
4430
|
+
const items = this._metadataDatasetItems;
|
|
4431
|
+
if (!codes || !current || !items) {
|
|
4432
|
+
return false;
|
|
4433
|
+
}
|
|
4434
|
+
try {
|
|
4435
|
+
const keep = new Set(codes);
|
|
4436
|
+
const skipOthers = items
|
|
4437
|
+
.filter(item => !keep.has(item.Code))
|
|
4438
|
+
.map(item => ({ ItemCode: item.Code, Filter: '1=0' }));
|
|
4439
|
+
const start = Date.now();
|
|
4440
|
+
const d = await this.GetDatasetByName(ProviderBase._mjMetadataDatasetName, skipOthers, this.CurrentUser, providerToUse, true);
|
|
4441
|
+
if (!d?.Success) {
|
|
4442
|
+
LogStatusEx({ message: `[Metadata] Scoped reload of ${codes.join(', ')} failed (${d?.Status ?? 'no result'}); running the full reload`, verboseOnly: false });
|
|
4443
|
+
return false;
|
|
4444
|
+
}
|
|
4445
|
+
const simple = {};
|
|
4446
|
+
for (const code of codes) {
|
|
4447
|
+
const item = d.Results?.find(r => r.Code === code);
|
|
4448
|
+
if (!item || item.Success === false || !Array.isArray(item.Results)) {
|
|
4449
|
+
LogStatusEx({ message: `[Metadata] Scoped reload: item ${code} ${item ? `failed (${item.Status ?? 'no rows'})` : 'missing'}; running the full reload`, verboseOnly: false });
|
|
4450
|
+
return false;
|
|
4451
|
+
}
|
|
4452
|
+
simple[code] = item.Results;
|
|
4453
|
+
}
|
|
4454
|
+
const reloaded = MetadataFromSimpleObjectWithoutUser(simple, this);
|
|
4455
|
+
if (!reloaded) {
|
|
4456
|
+
return false;
|
|
4457
|
+
}
|
|
4458
|
+
const next = new AllMetadata();
|
|
4459
|
+
next.CurrentUser = current.CurrentUser;
|
|
4460
|
+
const target = next;
|
|
4461
|
+
const kept = current;
|
|
4462
|
+
const fresh = reloaded;
|
|
4463
|
+
for (const m of AllMetadataArrays) {
|
|
4464
|
+
target[m.key] = keep.has(m.key.substring(3)) ? fresh[m.key] : kept[m.key];
|
|
4465
|
+
}
|
|
4466
|
+
// Adopted like every other server snapshot. _configLoadedFromServer is NOT set: this read
|
|
4467
|
+
// fetched no current user (the existing one is carried over), so the next pre-validation
|
|
4468
|
+
// must still run its check.
|
|
4469
|
+
await this.adoptServerMetadata(next);
|
|
4470
|
+
LogStatusEx({ message: `[Metadata] Reloaded ${codes.join(', ')} only in ${Date.now() - start} ms; the rest of the metadata is unchanged`, verboseOnly: true });
|
|
4471
|
+
return true;
|
|
4472
|
+
}
|
|
4473
|
+
catch (e) {
|
|
4474
|
+
LogError(`[Metadata] Scoped reload failed; running the full reload: ${e instanceof Error ? e.message : String(e)}`);
|
|
4475
|
+
return false;
|
|
4476
|
+
}
|
|
4477
|
+
}
|
|
4025
4478
|
/**
|
|
4026
4479
|
* Background validation for the stale-while-revalidate fast-start pattern.
|
|
4027
4480
|
* Checks if local metadata is still current; if stale, fetches fresh metadata
|
|
@@ -4085,7 +4538,12 @@ export class ProviderBase {
|
|
|
4085
4538
|
return;
|
|
4086
4539
|
}
|
|
4087
4540
|
try {
|
|
4088
|
-
|
|
4541
|
+
// Run the staleness check and CurrentUser refresh concurrently on warm boot so
|
|
4542
|
+
// when metadata is current (99%+ of warm loads) we eliminate a sequential round-trip.
|
|
4543
|
+
const [needsRefresh] = await Promise.all([
|
|
4544
|
+
this.CheckToSeeIfRefreshNeeded(providerToUse),
|
|
4545
|
+
this.RefreshCurrentUser(),
|
|
4546
|
+
]);
|
|
4089
4547
|
if (needsRefresh) {
|
|
4090
4548
|
LogStatusEx({ message: `⚡ [Metadata Cache] Pre-validation: metadata is stale — refreshing before engine startup`, verboseOnly: false });
|
|
4091
4549
|
const start = Date.now();
|
|
@@ -4098,9 +4556,6 @@ export class ProviderBase {
|
|
|
4098
4556
|
}
|
|
4099
4557
|
else {
|
|
4100
4558
|
LogStatusEx({ message: `⚡ [Metadata Cache] Pre-validation: metadata is current`, verboseOnly: false });
|
|
4101
|
-
// Even when entity metadata is current, refresh CurrentUser so user roles and permissions
|
|
4102
|
-
// stay in sync with the database without requiring a schema change or manual cache clear.
|
|
4103
|
-
await this.RefreshCurrentUser();
|
|
4104
4559
|
}
|
|
4105
4560
|
}
|
|
4106
4561
|
catch (e) {
|
|
@@ -4151,6 +4606,15 @@ export class ProviderBase {
|
|
|
4151
4606
|
* were on the client's global provider): treat Info objects and everything
|
|
4152
4607
|
* they own as read-only; copy before sorting.
|
|
4153
4608
|
*
|
|
4609
|
+
* Owning provider: the shared Info objects deliberately keep the
|
|
4610
|
+
* {@link BaseInfo.OwningProvider} of the provider that built them (the source
|
|
4611
|
+
* global provider); the shell does NOT re-stamp them. Re-stamping a shared
|
|
4612
|
+
* instance would re-point it for every other provider holding it. The sibling
|
|
4613
|
+
* lookups it drives (IS-A children, RLS filters, query fields, ...) therefore
|
|
4614
|
+
* read the source provider's graph — the same immutable graph this shell
|
|
4615
|
+
* points into, so the answers are identical. A provider that needs its own
|
|
4616
|
+
* graph (a different database) must load its own metadata, not adopt a shell.
|
|
4617
|
+
*
|
|
4154
4618
|
* Override precedence: if a subclass overrides BOTH this method and the
|
|
4155
4619
|
* deprecated {@link CloneAllMetadata}, the CloneAllMetadata override wins on
|
|
4156
4620
|
* the fast path (see {@link CopyMetadataFromGlobalProvider}) — the
|
|
@@ -4309,8 +4773,17 @@ export class ProviderBase {
|
|
|
4309
4773
|
try {
|
|
4310
4774
|
const user = await this.GetCurrentUser();
|
|
4311
4775
|
if (user && this._localMetadata) {
|
|
4776
|
+
const previousUserJson = this._localMetadata.CurrentUser
|
|
4777
|
+
? JSON.stringify(this._localMetadata.CurrentUser)
|
|
4778
|
+
: null;
|
|
4312
4779
|
this._localMetadata.CurrentUser = user;
|
|
4313
|
-
|
|
4780
|
+
// Only re-persist the full metadata snapshot when the user record or roles
|
|
4781
|
+
// actually changed; on warm boots where CurrentUser is unchanged, skipping
|
|
4782
|
+
// SaveLocalMetadataToStorage avoids a multi-MB JSON.stringify + gzip + IDB
|
|
4783
|
+
// write pass on the main thread during startup.
|
|
4784
|
+
if (previousUserJson !== JSON.stringify(user)) {
|
|
4785
|
+
void this.SaveLocalMetadataToStorage();
|
|
4786
|
+
}
|
|
4314
4787
|
return user;
|
|
4315
4788
|
}
|
|
4316
4789
|
}
|
|
@@ -4397,7 +4870,7 @@ export class ProviderBase {
|
|
|
4397
4870
|
if (organicKeys) {
|
|
4398
4871
|
e.EntityOrganicKeys = activeOrganicKeysByEntityId.get(entityIdKey) || [];
|
|
4399
4872
|
}
|
|
4400
|
-
result.push(new EntityInfo(e));
|
|
4873
|
+
result.push(new EntityInfo(e, this));
|
|
4401
4874
|
}
|
|
4402
4875
|
// Check for schema name collision: if both 'MJ' and 'MJCustom' schemas exist,
|
|
4403
4876
|
// the class name prefix for 'MJ' is 'MJCustom' which would collide with the
|
|
@@ -4641,15 +5114,47 @@ export class ProviderBase {
|
|
|
4641
5114
|
}
|
|
4642
5115
|
}
|
|
4643
5116
|
}
|
|
4644
|
-
/**
|
|
5117
|
+
/**
|
|
5118
|
+
* One refresh check: fetch the remote timestamps, arm the throttle only on success, bring the
|
|
5119
|
+
* in-memory snapshot up to the stored one, then compare. The third step is what makes the
|
|
5120
|
+
* answer correct in a fleet — a peer may have written a newer snapshot to the shared store
|
|
5121
|
+
* since this process last loaded — and {@link syncLocalMetadataFromStorage} keeps it cheap by
|
|
5122
|
+
* reading the whole snapshot only when the stored timestamps say it could have changed.
|
|
5123
|
+
*/
|
|
4645
5124
|
async runRefreshCheck(providerToUse) {
|
|
4646
5125
|
const gotTimestamps = await this.RefreshRemoteMetadataTimestamps(providerToUse);
|
|
4647
5126
|
if (gotTimestamps) {
|
|
4648
5127
|
this._lastRefreshCheckAt = Date.now();
|
|
4649
5128
|
}
|
|
4650
|
-
await this.
|
|
5129
|
+
await this.syncLocalMetadataFromStorage();
|
|
4651
5130
|
return this.LocalMetadataObsolete();
|
|
4652
5131
|
}
|
|
5132
|
+
/**
|
|
5133
|
+
* Brings the in-memory metadata up to the stored snapshot before a staleness check.
|
|
5134
|
+
*
|
|
5135
|
+
* The full snapshot (several MB compressed) is read only when it may differ from what this
|
|
5136
|
+
* process holds: nothing is held yet, or the stored timestamps are not the ones this process
|
|
5137
|
+
* last loaded or saved — another process sharing the store wrote a newer snapshot, or the
|
|
5138
|
+
* store was cleared. Otherwise only the small timestamps key is read. Reading and parsing the
|
|
5139
|
+
* whole snapshot on every periodic check cost each server ~230 ms per poll on a shared cache
|
|
5140
|
+
*.
|
|
5141
|
+
*/
|
|
5142
|
+
async syncLocalMetadataFromStorage() {
|
|
5143
|
+
const ls = this.LocalStorageProvider;
|
|
5144
|
+
if (ls && this._storedTimestampsRaw !== undefined && this._localMetadata?.AllEntities?.length) {
|
|
5145
|
+
try {
|
|
5146
|
+
const tsKey = this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey;
|
|
5147
|
+
const stored = (await ls.GetItems([tsKey])).get(tsKey) ?? null;
|
|
5148
|
+
if (stored === this._storedTimestampsRaw) {
|
|
5149
|
+
return;
|
|
5150
|
+
}
|
|
5151
|
+
}
|
|
5152
|
+
catch (e) {
|
|
5153
|
+
LogError(`[Metadata Cache] Reading stored metadata timestamps failed, loading the full snapshot: ${e instanceof Error ? e.message : String(e)}`);
|
|
5154
|
+
}
|
|
5155
|
+
}
|
|
5156
|
+
await this.LoadLocalMetadataFromStorage();
|
|
5157
|
+
}
|
|
4653
5158
|
/**
|
|
4654
5159
|
* Refreshes metadata only if needed based on timestamp comparison.
|
|
4655
5160
|
* Combines check and refresh into a single operation.
|
|
@@ -4659,8 +5164,13 @@ export class ProviderBase {
|
|
|
4659
5164
|
* @returns True if refresh was successful or not needed
|
|
4660
5165
|
*/
|
|
4661
5166
|
async RefreshIfNeeded(providerToUse, bypassMinCheckInterval) {
|
|
4662
|
-
if (await this.CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval))
|
|
5167
|
+
if (await this.CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval)) {
|
|
5168
|
+
// Stale only in the saved-query family → reload just that family (fresh timestamps, no
|
|
5169
|
+
// cache layer), falling back to the full hard reload if the scoped read fails.
|
|
5170
|
+
if (this.PartialRefreshItemCodes() !== null)
|
|
5171
|
+
return this.ReloadMetadata(true, providerToUse, true);
|
|
4663
5172
|
return this.Refresh(providerToUse);
|
|
5173
|
+
}
|
|
4664
5174
|
else
|
|
4665
5175
|
return true;
|
|
4666
5176
|
}
|
|
@@ -4796,7 +5306,7 @@ export class ProviderBase {
|
|
|
4796
5306
|
let cachedDataset = null;
|
|
4797
5307
|
let cachedDateStr = null;
|
|
4798
5308
|
if (ls && key && dateKey) {
|
|
4799
|
-
const both = await ls.GetItems([key, dateKey],
|
|
5309
|
+
const both = await ls.GetItems([key, dateKey], ProviderBase.DatasetCacheCategory);
|
|
4800
5310
|
const rawData = both.get(key);
|
|
4801
5311
|
const rawDate = both.get(dateKey);
|
|
4802
5312
|
// Type guards — entries can be either depending on which key matched.
|
|
@@ -4812,15 +5322,8 @@ export class ProviderBase {
|
|
|
4812
5322
|
if (status && localDate.getTime() >= status.LatestUpdateDate.getTime()) {
|
|
4813
5323
|
// Timestamps suggest cache is fresh; verify per-entity row counts to
|
|
4814
5324
|
// catch deleted rows (timestamp comparison alone misses pure deletes).
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
const localEntity = cachedDataset.Results.find(e => UUIDsEqual(e.EntityID, eu.EntityID));
|
|
4818
|
-
if (!localEntity || localEntity.Results.length !== eu.RowCount) {
|
|
4819
|
-
allCountsMatch = false;
|
|
4820
|
-
break;
|
|
4821
|
-
}
|
|
4822
|
-
}
|
|
4823
|
-
if (allCountsMatch) {
|
|
5325
|
+
// Shared with IsDatasetCacheUpToDate so the two cannot drift again.
|
|
5326
|
+
if (this.DatasetRowCountsMatch(cachedDataset, status)) {
|
|
4824
5327
|
return cachedDataset;
|
|
4825
5328
|
}
|
|
4826
5329
|
}
|
|
@@ -4841,7 +5344,7 @@ export class ProviderBase {
|
|
|
4841
5344
|
if (ls) {
|
|
4842
5345
|
const key = this.GetDatasetCacheKey(datasetName, itemFilters);
|
|
4843
5346
|
const dateKey = key + '_date';
|
|
4844
|
-
const val = await ls.GetItem(dateKey);
|
|
5347
|
+
const val = await ls.GetItem(dateKey, ProviderBase.DatasetCacheCategory);
|
|
4845
5348
|
if (val) {
|
|
4846
5349
|
return new Date(val);
|
|
4847
5350
|
}
|
|
@@ -4865,17 +5368,7 @@ export class ProviderBase {
|
|
|
4865
5368
|
// 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
|
|
4866
5369
|
// iterate through all of the entities and check the row counts
|
|
4867
5370
|
const localDataset = await this.GetCachedDataset(datasetName, itemFilters);
|
|
4868
|
-
|
|
4869
|
-
const localEntity = localDataset.Results.find(e => UUIDsEqual(e.EntityID, eu.EntityID));
|
|
4870
|
-
if (!localEntity || localEntity.Results.length !== eu.RowCount) {
|
|
4871
|
-
// we either couldn't find the entity in the local cache or the row count is different, so we're out of date
|
|
4872
|
-
// the RowCount being different picks up on DELETED rows. The UpdatedAt check which is handled above would pick up
|
|
4873
|
-
// on any new rows or updated rows. This approach makes sure we detect deleted rows and refresh the cache.
|
|
4874
|
-
return false;
|
|
4875
|
-
}
|
|
4876
|
-
}
|
|
4877
|
-
// if we get here that means that the row counts are the same for all entities and we're up to date
|
|
4878
|
-
return true;
|
|
5371
|
+
return this.DatasetRowCountsMatch(localDataset, status);
|
|
4879
5372
|
}
|
|
4880
5373
|
else {
|
|
4881
5374
|
// our local cache timestamp is < the server timestamp, so we're out of date
|
|
@@ -4892,6 +5385,40 @@ export class ProviderBase {
|
|
|
4892
5385
|
return false;
|
|
4893
5386
|
}
|
|
4894
5387
|
}
|
|
5388
|
+
/**
|
|
5389
|
+
* Compares the per-entity row counts of a cached dataset against the server's status.
|
|
5390
|
+
*
|
|
5391
|
+
* The timestamp comparison its callers run first catches new and updated rows; it cannot catch a
|
|
5392
|
+
* pure DELETE, which leaves `LatestUpdateDate` untouched while the count drops. This is the
|
|
5393
|
+
* check that catches those, and it is shared by `GetAndCacheDatasetByName` (which already holds
|
|
5394
|
+
* the blob) and `IsDatasetCacheUpToDate` (which reads it) so the two cannot drift apart again —
|
|
5395
|
+
* they had, and the differences were only ever masked by the warm path not running.
|
|
5396
|
+
*
|
|
5397
|
+
* Two cases decide the shape of this, both load-bearing:
|
|
5398
|
+
*
|
|
5399
|
+
* - **No counts to compare.** A status reporting no `EntityUpdateDates` never touches the blob
|
|
5400
|
+
* and is judged on its timestamp alone, so it answers `true`. Returning `false` here instead
|
|
5401
|
+
* looks strictly safer and is not: it makes such a dataset permanently stale, reloading on
|
|
5402
|
+
* every check (plan — the `dataset-cache.DS2` regression).
|
|
5403
|
+
* - **A count to compare but no blob.** The blob and its `_date` proxy expire independently and
|
|
5404
|
+
* a clear removes them in order, so the date can outlive the blob. Counts then cannot be
|
|
5405
|
+
* compared, which is `false` — refetch. A blob present but missing `Results`
|
|
5406
|
+
* answers `false` for the same reason rather than throwing, which is what the pre-unification
|
|
5407
|
+
* copy in the warm path would have done the moment that path started running.
|
|
5408
|
+
*
|
|
5409
|
+
* @param cachedDataset the cached dataset, which may be absent or incomplete
|
|
5410
|
+
* @param status the server's status for the same dataset/filters combination
|
|
5411
|
+
* @returns true when every entity the server reports matches the cache's row count for it
|
|
5412
|
+
*/
|
|
5413
|
+
DatasetRowCountsMatch(cachedDataset, status) {
|
|
5414
|
+
for (const eu of status.EntityUpdateDates) {
|
|
5415
|
+
const localEntity = cachedDataset?.Results?.find(e => UUIDsEqual(e.EntityID, eu.EntityID));
|
|
5416
|
+
if (!localEntity || localEntity.Results.length !== eu.RowCount) {
|
|
5417
|
+
return false;
|
|
5418
|
+
}
|
|
5419
|
+
}
|
|
5420
|
+
return true;
|
|
5421
|
+
}
|
|
4895
5422
|
/**
|
|
4896
5423
|
* 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
|
|
4897
5424
|
* @param datasetName
|
|
@@ -4903,7 +5430,7 @@ export class ProviderBase {
|
|
|
4903
5430
|
if (ls) {
|
|
4904
5431
|
const key = this.GetDatasetCacheKey(datasetName, itemFilters);
|
|
4905
5432
|
// Native object read — IDB structured-clones, localStorage / Redis JSON-decode internally.
|
|
4906
|
-
const dataset = await ls.GetItem(key);
|
|
5433
|
+
const dataset = await ls.GetItem(key, ProviderBase.DatasetCacheCategory);
|
|
4907
5434
|
if (dataset) {
|
|
4908
5435
|
return dataset;
|
|
4909
5436
|
}
|
|
@@ -4920,10 +5447,10 @@ export class ProviderBase {
|
|
|
4920
5447
|
if (ls) {
|
|
4921
5448
|
const key = this.GetDatasetCacheKey(datasetName, itemFilters);
|
|
4922
5449
|
// Native object storage — no JSON.stringify on the hot path.
|
|
4923
|
-
await ls.SetItem(key, dataset);
|
|
5450
|
+
await ls.SetItem(key, dataset, ProviderBase.DatasetCacheCategory, { TTLSeconds: ProviderBase.DatasetCacheTTLSeconds });
|
|
4924
5451
|
// Date is stored as ISO string for forward-compatibility across providers
|
|
4925
5452
|
// (Redis can't natively round-trip Date; localStorage requires string).
|
|
4926
|
-
await ls.SetItem(key + '_date', dataset.LatestUpdateDate.toISOString());
|
|
5453
|
+
await ls.SetItem(key + '_date', dataset.LatestUpdateDate.toISOString(), ProviderBase.DatasetCacheCategory, { TTLSeconds: ProviderBase.DatasetDateCacheTTLSeconds });
|
|
4927
5454
|
}
|
|
4928
5455
|
}
|
|
4929
5456
|
/**
|
|
@@ -4940,10 +5467,42 @@ export class ProviderBase {
|
|
|
4940
5467
|
// CacheDataset, so the date's presence is a faithful proxy for the
|
|
4941
5468
|
// data's presence.
|
|
4942
5469
|
const key = this.GetDatasetCacheKey(datasetName, itemFilters);
|
|
4943
|
-
const val = await ls.GetItem(key + '_date');
|
|
5470
|
+
const val = await ls.GetItem(key + '_date', ProviderBase.DatasetCacheCategory);
|
|
4944
5471
|
return val !== null && val !== undefined;
|
|
4945
5472
|
}
|
|
4946
5473
|
}
|
|
5474
|
+
/**
|
|
5475
|
+
* The cache category every dataset read and write uses.
|
|
5476
|
+
*
|
|
5477
|
+
* Every storage provider isolates by category — Redis keys are `{prefix}:{category}:{key}`,
|
|
5478
|
+
* browser localStorage `[mj]:[category]:[key]`, IndexedDB a dedicated object store, the
|
|
5479
|
+
* in-memory providers a nested map — so a read and a write that disagree about the category can
|
|
5480
|
+
* never meet. When the batched warm read in `GetAndCacheDatasetByName` named `'DatasetCache'`
|
|
5481
|
+
* while every write and every other reader landed in `default`, the warm-serve path missed on
|
|
5482
|
+
* every transport and silently refetched from the server. Naming the category once is what keeps
|
|
5483
|
+
* all six call sites in agreement.
|
|
5484
|
+
*/
|
|
5485
|
+
static { this.DatasetCacheCategory = 'DatasetCache'; }
|
|
5486
|
+
/**
|
|
5487
|
+
* How long a cached dataset lives.
|
|
5488
|
+
*
|
|
5489
|
+
* Datasets have a category to themselves and it carries no category-wide expiry, so without a
|
|
5490
|
+
* per-write TTL every distinct filter set (`GetDatasetCacheKey` includes the filters) would
|
|
5491
|
+
* leave behind a blob nothing removes. An explicit TTL bounds that growth, and the store
|
|
5492
|
+
* prefers a per-write value over any category or global setting — which is also why giving
|
|
5493
|
+
* datasets their own category did not change how long one lives. Providers that do not
|
|
5494
|
+
* implement expiry ignore it.
|
|
5495
|
+
*/
|
|
5496
|
+
static { this.DatasetCacheTTLSeconds = 3600; }
|
|
5497
|
+
/**
|
|
5498
|
+
* How long the `_date` key lives — deliberately SHORTER than the blob it vouches for.
|
|
5499
|
+
*
|
|
5500
|
+
* It is written second, so with equal lifetimes it would usually be the one to survive, leaving
|
|
5501
|
+
* a freshness claim with nothing behind it: `IsDatasetCached` answers true and the blob is gone.
|
|
5502
|
+
* Expiring the claim first makes the pair fail in the safe direction — the cache reads as
|
|
5503
|
+
* absent and is refetched.
|
|
5504
|
+
*/
|
|
5505
|
+
static { this.DatasetDateCacheTTLSeconds = ProviderBase.DatasetCacheTTLSeconds - 300; }
|
|
4947
5506
|
/**
|
|
4948
5507
|
* Creates a unique key for the given datasetName and itemFilters combination coupled with the instance connection string to ensure uniqueness when 2+ connections exist
|
|
4949
5508
|
* @param datasetName
|
|
@@ -4975,9 +5534,9 @@ export class ProviderBase {
|
|
|
4975
5534
|
const ls = this.LocalStorageProvider;
|
|
4976
5535
|
if (ls) {
|
|
4977
5536
|
const key = this.GetDatasetCacheKey(datasetName, itemFilters);
|
|
4978
|
-
await ls.Remove(key);
|
|
5537
|
+
await ls.Remove(key, ProviderBase.DatasetCacheCategory);
|
|
4979
5538
|
const dateKey = key + '_date';
|
|
4980
|
-
await ls.Remove(dateKey);
|
|
5539
|
+
await ls.Remove(dateKey, ProviderBase.DatasetCacheCategory);
|
|
4981
5540
|
}
|
|
4982
5541
|
}
|
|
4983
5542
|
/**
|
|
@@ -5148,7 +5707,27 @@ export class ProviderBase {
|
|
|
5148
5707
|
const format = all.get(fmtKey) ?? null;
|
|
5149
5708
|
const raw = all.get(dataKey) ?? null;
|
|
5150
5709
|
const membershipRaw = all.get(membershipKey) ?? null;
|
|
5151
|
-
|
|
5710
|
+
// The timestamps are a freshness claim FOR THE PAYLOAD, so they are only adopted when the
|
|
5711
|
+
// payload is there to adopt with them. Timestamps without a payload happen whenever the
|
|
5712
|
+
// two keys do not disappear together: the timestamps key is written last (and removed
|
|
5713
|
+
// last by a cache clear), so it outlives the payload under any per-key expiry. Adopting
|
|
5714
|
+
// them alone left this process claiming to be current while holding no metadata at all
|
|
5715
|
+
// — the staleness check then confirms "current" and nothing ever reloads.
|
|
5716
|
+
const claimUsable = !!tsRaw && !!raw;
|
|
5717
|
+
if (claimUsable) {
|
|
5718
|
+
this._latestLocalMetadataTimestamps = JSON.parse(tsRaw);
|
|
5719
|
+
}
|
|
5720
|
+
else if (!this._localMetadata?.AllEntities?.length) {
|
|
5721
|
+
// Nothing usable stored and nothing held: fall back to asking the database.
|
|
5722
|
+
this._latestLocalMetadataTimestamps = null;
|
|
5723
|
+
}
|
|
5724
|
+
// A store without timestamps says nothing about the metadata this process already holds
|
|
5725
|
+
// (another process may have just cleared it), so held timestamps survive: the staleness
|
|
5726
|
+
// check then compares what is held with the database instead of assuming it is stale.
|
|
5727
|
+
this._storedTimestampsRaw = claimUsable ? tsRaw : null;
|
|
5728
|
+
if (tsRaw && !raw) {
|
|
5729
|
+
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 });
|
|
5730
|
+
}
|
|
5152
5731
|
// Restore the metadata-member entity set so the event-driven refresh works on a warm
|
|
5153
5732
|
// boot that never calls GetAllMetadata (the stale-while-revalidate fast start). The
|
|
5154
5733
|
// set is only stored after a successful dataset load, so restoring it is exactly as
|
|
@@ -5246,17 +5825,18 @@ export class ProviderBase {
|
|
|
5246
5825
|
* Used for compressed metadata storage/retrieval.
|
|
5247
5826
|
*/
|
|
5248
5827
|
static arrayBufferToBase64(buffer) {
|
|
5249
|
-
// Node encodes natively.
|
|
5250
|
-
//
|
|
5828
|
+
// Node encodes natively. In browsers, process in 32KB chunks with spread to avoid
|
|
5829
|
+
// building a character-by-character rope across multi-MB payloads.
|
|
5251
5830
|
if (typeof Buffer !== 'undefined') {
|
|
5252
5831
|
return Buffer.from(buffer).toString('base64');
|
|
5253
5832
|
}
|
|
5254
5833
|
const bytes = new Uint8Array(buffer);
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
5834
|
+
const chunkSize = 0x8000;
|
|
5835
|
+
const chunks = [];
|
|
5836
|
+
for (let i = 0; i < bytes.byteLength; i += chunkSize) {
|
|
5837
|
+
chunks.push(String.fromCharCode(...bytes.subarray(i, i + chunkSize)));
|
|
5258
5838
|
}
|
|
5259
|
-
return btoa(
|
|
5839
|
+
return btoa(chunks.join(''));
|
|
5260
5840
|
}
|
|
5261
5841
|
/**
|
|
5262
5842
|
* This property will return the prefix to use for local storage keys. This is useful if you have multiple instances of a provider running in the same environment
|
|
@@ -5333,7 +5913,9 @@ export class ProviderBase {
|
|
|
5333
5913
|
}
|
|
5334
5914
|
// Timestamps LAST: they are the freshness claim for everything written above, so they
|
|
5335
5915
|
// must be the final thing to land.
|
|
5336
|
-
|
|
5916
|
+
const timestampsRaw = JSON.stringify(this._latestLocalMetadataTimestamps);
|
|
5917
|
+
await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey, timestampsRaw);
|
|
5918
|
+
this._storedTimestampsRaw = timestampsRaw;
|
|
5337
5919
|
this.logMetadataSaveComplete(Date.now() - start, jsonString.length, snapshot);
|
|
5338
5920
|
}
|
|
5339
5921
|
catch (e) {
|