@memberjunction/core 6.1.0-edge.5 → 6.1.0-edge.6
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/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts +27 -0
- package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js +6 -0
- package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js.map +1 -0
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +2 -3
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +119 -0
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +522 -87
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseInfo.d.ts +6 -0
- package/dist/generic/baseInfo.d.ts.map +1 -1
- package/dist/generic/baseInfo.js +19 -1
- package/dist/generic/baseInfo.js.map +1 -1
- package/dist/generic/compositeKey.d.ts +108 -0
- package/dist/generic/compositeKey.d.ts.map +1 -1
- package/dist/generic/compositeKey.js +225 -10
- package/dist/generic/compositeKey.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +49 -0
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +90 -11
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/embeddedRecord.d.ts +2 -1
- package/dist/generic/embeddedRecord.d.ts.map +1 -1
- package/dist/generic/embeddedRecord.js +9 -2
- package/dist/generic/embeddedRecord.js.map +1 -1
- package/dist/generic/entityCompanion.d.ts +6 -1
- package/dist/generic/entityCompanion.d.ts.map +1 -1
- package/dist/generic/entityCompanion.js +7 -0
- package/dist/generic/entityCompanion.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +89 -7
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +127 -14
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entitySubtypeResolver.d.ts +19 -0
- package/dist/generic/entitySubtypeResolver.d.ts.map +1 -0
- package/dist/generic/entitySubtypeResolver.js +24 -0
- package/dist/generic/entitySubtypeResolver.js.map +1 -0
- package/dist/generic/entityTransactionScope.d.ts +6 -3
- package/dist/generic/entityTransactionScope.d.ts.map +1 -1
- package/dist/generic/entityTransactionScope.js +6 -3
- package/dist/generic/entityTransactionScope.js.map +1 -1
- package/dist/generic/extendedTypeValue.d.ts +47 -0
- package/dist/generic/extendedTypeValue.d.ts.map +1 -0
- package/dist/generic/extendedTypeValue.js +152 -0
- package/dist/generic/extendedTypeValue.js.map +1 -0
- package/dist/generic/filters/compositeFilter.d.ts +77 -0
- package/dist/generic/filters/compositeFilter.d.ts.map +1 -0
- package/dist/generic/filters/compositeFilter.js +324 -0
- package/dist/generic/filters/compositeFilter.js.map +1 -0
- package/dist/generic/filters/filter.types.d.ts +29 -0
- package/dist/generic/filters/filter.types.d.ts.map +1 -0
- package/dist/generic/filters/filter.types.js +28 -0
- package/dist/generic/filters/filter.types.js.map +1 -0
- package/dist/generic/filters/index.d.ts +3 -0
- package/dist/generic/filters/index.d.ts.map +1 -0
- package/dist/generic/filters/index.js +3 -0
- package/dist/generic/filters/index.js.map +1 -0
- package/dist/generic/geoFields.d.ts +64 -0
- package/dist/generic/geoFields.d.ts.map +1 -0
- package/dist/generic/geoFields.js +108 -0
- package/dist/generic/geoFields.js.map +1 -0
- package/dist/generic/interfaces.d.ts +11 -2
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +4 -2
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +5 -3
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +118 -3
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +356 -70
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/relatedRecordBatchLoader.js +2 -2
- package/dist/generic/relatedRecordBatchLoader.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +2 -1
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +16 -4
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -5,7 +5,7 @@ import { ComputeRRF } from "./scoring/ReciprocalRankFusion.js";
|
|
|
5
5
|
import { LocalCacheManager } from "./localCacheManager.js";
|
|
6
6
|
import { ApplicationInfo } from "../generic/applicationInfo.js";
|
|
7
7
|
import { AuditLogTypeInfo, AuthorizationInfo, AuthorizationRoleInfo, RoleInfo, RowLevelSecurityFilterInfo, UserInfo } from "./securityInfo.js";
|
|
8
|
-
import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, MJLruCache } from "@memberjunction/global";
|
|
8
|
+
import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, MJLruCache, EscapeSQLString, ordinalCompare } from "@memberjunction/global";
|
|
9
9
|
import { TelemetryManager } from "./telemetryManager.js";
|
|
10
10
|
import { LogError, LogStatus, LogStatusEx } from "./logging.js";
|
|
11
11
|
import { QueryCategoryInfo, QueryFieldInfo, QueryInfo, QueryPermissionInfo, QueryEntityInfo, QueryParameterInfo, QueryDependencyInfo, SQLDialectInfo, QuerySQLInfo } from "./queryInfo.js";
|
|
@@ -189,6 +189,10 @@ export class ProviderBase {
|
|
|
189
189
|
// it can't be reset on metadata refresh; it needs its own eviction policy.
|
|
190
190
|
this._entityRecordNameCache = new MJLruCache({ maxSize: 10000, ttlMs: 60 * 60 * 1000 });
|
|
191
191
|
this._refresh = false;
|
|
192
|
+
/** Single-flight guard for the full metadata reload in {@link Config} — see the comment there. */
|
|
193
|
+
this._metadataReloadInFlight = null;
|
|
194
|
+
/** Set when a refresh request arrives while a reload is in flight; the reload loop reruns once. */
|
|
195
|
+
this._metadataReloadQueued = false;
|
|
192
196
|
this._lastRefreshCheckAt = 0;
|
|
193
197
|
/**
|
|
194
198
|
* Pending coalesced requests waiting to be flushed.
|
|
@@ -213,6 +217,16 @@ export class ProviderBase {
|
|
|
213
217
|
*/
|
|
214
218
|
this._inflightViews = new Map();
|
|
215
219
|
this._lingerInvalidationWired = false;
|
|
220
|
+
/**
|
|
221
|
+
* Lowercased names of the entities whose rows COMPOSE this provider's metadata — the
|
|
222
|
+
* MJ_Metadata dataset's item entities, recorded each time the dataset is loaded (and restored
|
|
223
|
+
* from local storage on warm boot). Membership is DATA: adding a `DatasetItem` row to
|
|
224
|
+
* MJ_Metadata extends refresh coverage with no code change, which is why no entity names are
|
|
225
|
+
* hardcoded anywhere in this mechanism.
|
|
226
|
+
*/
|
|
227
|
+
this._metadataDatasetEntityNames = null;
|
|
228
|
+
/** Debounce timer for {@link scheduleMetadataMemberRefresh}. */
|
|
229
|
+
this._metadataMemberRefreshTimer = null;
|
|
216
230
|
this._clientFingerprintMemo = new WeakMap();
|
|
217
231
|
this._cachedVisibleExplorerNavigationItems = null;
|
|
218
232
|
}
|
|
@@ -355,6 +369,7 @@ export class ProviderBase {
|
|
|
355
369
|
continue;
|
|
356
370
|
}
|
|
357
371
|
instance.invalidateInflightViewsForEntity(entityName);
|
|
372
|
+
instance.handleMetadataMemberEntityEvent(entityName, entityEvent);
|
|
358
373
|
}
|
|
359
374
|
});
|
|
360
375
|
}
|
|
@@ -405,6 +420,155 @@ export class ProviderBase {
|
|
|
405
420
|
}
|
|
406
421
|
return names;
|
|
407
422
|
}
|
|
423
|
+
// ── Metadata-Dataset Membership Refresh ────────────────────────────
|
|
424
|
+
/**
|
|
425
|
+
* Coalescing window, in milliseconds, for metadata refreshes triggered by writes to the
|
|
426
|
+
* entities that compose this provider's metadata. A single administrative action produces a
|
|
427
|
+
* burst (enabling field security writes one permission row per field/role pair, each raising
|
|
428
|
+
* its own event) — one refresh per burst, not one per row. The window also gives an enclosing
|
|
429
|
+
* entity transaction time to COMMIT before the re-read: every event in the burst re-arms the
|
|
430
|
+
* timer, so the refresh runs no earlier than this long after the LAST write.
|
|
431
|
+
*/
|
|
432
|
+
static { this.MetadataDatasetRefreshDebounceMs = 500; }
|
|
433
|
+
/**
|
|
434
|
+
* Records which entities compose this provider's metadata, from the loaded MJ_Metadata
|
|
435
|
+
* dataset result, and registers this instance with the static event fan-out so writes to any
|
|
436
|
+
* of them schedule a debounced metadata refresh. Called from {@link GetAllMetadata} on every
|
|
437
|
+
* successful load, so the set tracks the dataset definition as it changes.
|
|
438
|
+
*/
|
|
439
|
+
registerMetadataDatasetMembership(dataset) {
|
|
440
|
+
const names = new Set();
|
|
441
|
+
for (const item of dataset.Results ?? []) {
|
|
442
|
+
const n = item.EntityName?.trim().toLowerCase();
|
|
443
|
+
if (n) {
|
|
444
|
+
names.add(n);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (names.size === 0) {
|
|
448
|
+
return; // a failed/empty load must not erase a previously recorded set
|
|
449
|
+
}
|
|
450
|
+
this._metadataDatasetEntityNames = names;
|
|
451
|
+
this.ensureInflightViewInvalidation();
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Static fan-out callback: a BaseEntity save/delete (or a remote-invalidate from another
|
|
455
|
+
* server) touched `lowerEntityName`. If that entity is one of the entities this provider's
|
|
456
|
+
* metadata is BUILT FROM, the metadata this provider is serving — and, on the server, the
|
|
457
|
+
* metadata every per-request provider adopts from it — is now stale, so schedule a debounced
|
|
458
|
+
* refresh. Permission metadata is the load-bearing case: field-level security is enforced
|
|
459
|
+
* FROM metadata at every enforcement point, so a rule an administrator just tightened is
|
|
460
|
+
* simply not enforced until this re-read happens.
|
|
461
|
+
*/
|
|
462
|
+
handleMetadataMemberEntityEvent(lowerEntityName, entityEvent) {
|
|
463
|
+
if (!this._metadataDatasetEntityNames?.has(lowerEntityName)) {
|
|
464
|
+
return; // the overwhelming majority of writes
|
|
465
|
+
}
|
|
466
|
+
if (!this.eventTargetsThisProviderBackend(entityEvent)) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
this.scheduleMetadataMemberRefresh();
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Whether the write described by `entityEvent` happened against the backend THIS provider's
|
|
473
|
+
* metadata comes from. In a multi-provider process (a client connected to several MJ servers,
|
|
474
|
+
* a server connected to several databases) a write on one backend must not refresh another's
|
|
475
|
+
* metadata. Deliberately fails OPEN — when the event does not identify its provider, or a
|
|
476
|
+
* connection string is unavailable, the answer is "yes": a spurious refresh is a bounded
|
|
477
|
+
* cost, a suppressed one is a stale-permissions window.
|
|
478
|
+
*/
|
|
479
|
+
eventTargetsThisProviderBackend(entityEvent) {
|
|
480
|
+
try {
|
|
481
|
+
if (entityEvent.type === 'remote-invalidate') {
|
|
482
|
+
// The transport that received the message attaches itself; other providers in the
|
|
483
|
+
// same process are connected to other servers.
|
|
484
|
+
return !entityEvent.provider || entityEvent.provider === this;
|
|
485
|
+
}
|
|
486
|
+
const self = this;
|
|
487
|
+
const saveProvider = entityEvent.baseEntity?.ProviderToUse;
|
|
488
|
+
if (saveProvider instanceof ProviderBase && saveProvider !== self) {
|
|
489
|
+
const theirs = saveProvider.InstanceConnectionString;
|
|
490
|
+
const mine = this.InstanceConnectionString;
|
|
491
|
+
if (theirs && mine && theirs !== mine) {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
return true; // a connection-string getter with no live pool must not veto the refresh
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* How long a member-entity write waits before this provider's refresh runs. The base value
|
|
503
|
+
* is the short debounce window — right for the server, where the writer is the refresher and
|
|
504
|
+
* the delay only exists to coalesce a burst and let the enclosing transaction commit.
|
|
505
|
+
* Transport providers override this with a much longer, RANDOMIZED window: every browser
|
|
506
|
+
* receives every write broadcast, so the delay is what turns "N clients each re-pull the
|
|
507
|
+
* metadata graph within the same half-second of any member write" into "each client pays at
|
|
508
|
+
* most one staleness check per window, at a moment no other client shares".
|
|
509
|
+
*/
|
|
510
|
+
get MetadataMemberRefreshDelayMs() {
|
|
511
|
+
return ProviderBase.MetadataDatasetRefreshDebounceMs;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Whether a member-entity write arriving while the refresh timer is already armed RESTARTS
|
|
515
|
+
* the timer (debounce) or joins the pending window (coalesce/throttle).
|
|
516
|
+
*
|
|
517
|
+
* The base is a true debounce (`true`): the server's refresh must run AFTER the last write
|
|
518
|
+
* of the unit of work, so every event pushes the timer out — a burst costs one refresh, run
|
|
519
|
+
* once the burst ends. Transport providers return `false`: with a long window, re-arming
|
|
520
|
+
* would let steady org-wide write activity postpone the refresh indefinitely (starvation);
|
|
521
|
+
* joining the armed window guarantees at most one refresh per window regardless of write
|
|
522
|
+
* rate, which is the whole point of the window.
|
|
523
|
+
*/
|
|
524
|
+
get MetadataMemberRefreshRearmsOnNewEvents() {
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Schedules this provider's metadata refresh after a write to a metadata member entity.
|
|
529
|
+
* Delay and re-arm semantics come from {@link MetadataMemberRefreshDelayMs} and
|
|
530
|
+
* {@link MetadataMemberRefreshRearmsOnNewEvents} — debounce on the server, long jittered
|
|
531
|
+
* coalescing window on clients. The refresh targets THIS instance — the provider that loaded
|
|
532
|
+
* the dataset owns the metadata built from it; short-lived per-request providers never load
|
|
533
|
+
* the dataset (they adopt the global's metadata as a shared shell), so on the server only
|
|
534
|
+
* the process-global provider ever gets here.
|
|
535
|
+
*/
|
|
536
|
+
scheduleMetadataMemberRefresh() {
|
|
537
|
+
if (this._metadataMemberRefreshTimer) {
|
|
538
|
+
if (!this.MetadataMemberRefreshRearmsOnNewEvents) {
|
|
539
|
+
return; // coalesce: this write joins the already-armed window
|
|
540
|
+
}
|
|
541
|
+
clearTimeout(this._metadataMemberRefreshTimer);
|
|
542
|
+
}
|
|
543
|
+
this._metadataMemberRefreshTimer = setTimeout(() => {
|
|
544
|
+
this._metadataMemberRefreshTimer = null;
|
|
545
|
+
this.RefreshAfterMetadataMemberChange().catch((e) => {
|
|
546
|
+
LogError(`Metadata refresh after a member-entity change failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
547
|
+
});
|
|
548
|
+
}, this.MetadataMemberRefreshDelayMs);
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* How this provider refreshes after a metadata member entity changed. The base behavior is a
|
|
552
|
+
* hard {@link Refresh} — correct for database providers, where the process that PERFORMED the
|
|
553
|
+
* write is the one refreshing, so re-checking staleness first is wasted work and the re-read
|
|
554
|
+
* must bypass every cache layer. Transport providers (GraphQL) override this with a staleness
|
|
555
|
+
* check so a browser doesn't re-pull the full metadata graph for a change the server-side
|
|
556
|
+
* timestamp comparison can disconfirm.
|
|
557
|
+
*/
|
|
558
|
+
async RefreshAfterMetadataMemberChange() {
|
|
559
|
+
return this.Refresh();
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Cancels any pending debounced metadata refresh. Call during teardown (logout, provider
|
|
563
|
+
* disposal) so a timer armed just before teardown doesn't fire a refresh against a
|
|
564
|
+
* connection that no longer has a valid session.
|
|
565
|
+
*/
|
|
566
|
+
CancelPendingMetadataMemberRefresh() {
|
|
567
|
+
if (this._metadataMemberRefreshTimer) {
|
|
568
|
+
clearTimeout(this._metadataMemberRefreshTimer);
|
|
569
|
+
this._metadataMemberRefreshTimer = null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
408
572
|
/******** ABSTRACT SECTION ****************************************************************** */
|
|
409
573
|
/**
|
|
410
574
|
* When true, cached RunView/RunQuery results are returned immediately on a
|
|
@@ -932,7 +1096,10 @@ export class ProviderBase {
|
|
|
932
1096
|
const record = result.Results[j];
|
|
933
1097
|
allResults.push({
|
|
934
1098
|
EntityName: entity.Name,
|
|
935
|
-
|
|
1099
|
+
// Compact CompositeKey segment: the bare value for a single-column key (any
|
|
1100
|
+
// column name), "F1|v1||F2|v2" for a composite key — what
|
|
1101
|
+
// CompositeKey.FromURLSegment(entity, RecordID) reads back.
|
|
1102
|
+
RecordID: CompositeKey.FromEntityRecord(entity, record).ToCompactURLSegment(),
|
|
936
1103
|
Title: String(record[titleField] ?? 'Untitled'),
|
|
937
1104
|
Snippet: String(record[snippetField] ?? '').substring(0, 200),
|
|
938
1105
|
Score: 1.0 / (j + 1) // Rank-based scoring for RRF compatibility
|
|
@@ -987,7 +1154,7 @@ export class ProviderBase {
|
|
|
987
1154
|
}
|
|
988
1155
|
// Fallback to first text field
|
|
989
1156
|
const textField = entity.Fields.find(f => f.Type.toLowerCase().includes('varchar') || f.Type.toLowerCase().includes('text'));
|
|
990
|
-
return textField?.Name ?? entity.FirstPrimaryKey
|
|
1157
|
+
return textField?.Name ?? entity.FirstPrimaryKey.Name; // first-pk-ok: display-column fallback for FTS results, not a key construction
|
|
991
1158
|
}
|
|
992
1159
|
async RunViewsUncoalesced(params, contextUser) {
|
|
993
1160
|
const key = this.GenerateDedupKey(params, contextUser);
|
|
@@ -1456,7 +1623,9 @@ export class ProviderBase {
|
|
|
1456
1623
|
const nameField = entity.NameField?.Name ?? entity.Fields.find(f => f.IsNameField)?.Name ?? null;
|
|
1457
1624
|
const out = [];
|
|
1458
1625
|
for (const row of (r.Results ?? [])) {
|
|
1459
|
-
|
|
1626
|
+
// The entity is arbitrary: read the key off its primary-key metadata, not a hardcoded
|
|
1627
|
+
// `ID` column. Compact segment so single-column keys stay the raw value.
|
|
1628
|
+
const id = CompositeKey.FromEntityRecord(entity, row).ToCompactURLSegment();
|
|
1460
1629
|
if (!id)
|
|
1461
1630
|
continue;
|
|
1462
1631
|
const nameVal = nameField ? String(row[nameField] ?? '').toLowerCase() : '';
|
|
@@ -1495,17 +1664,23 @@ export class ProviderBase {
|
|
|
1495
1664
|
async searchEntitiesFilterByPermission(entity, ids, contextUser) {
|
|
1496
1665
|
if (ids.length === 0)
|
|
1497
1666
|
return new Set();
|
|
1498
|
-
|
|
1667
|
+
// `ids` are compact CompositeKey segments (see searchEntitiesLexicalPass): a single-column
|
|
1668
|
+
// key — whatever the column is called — uses one IN(); a composite key needs one
|
|
1669
|
+
// (F1=.. AND F2=..) term per record. Hardcoding `ID` here returned nothing for every entity
|
|
1670
|
+
// whose key isn't named ID, so SearchEntity silently produced zero results for them.
|
|
1671
|
+
const filter = entity.PrimaryKeys.length === 1
|
|
1672
|
+
? `${entity.FirstPrimaryKey.Name} IN (${ids.map(id => `'${EscapeSQLString(id)}'`).join(',')})` // first-pk-ok: guarded by PrimaryKeys.length === 1
|
|
1673
|
+
: ids.map(id => `(${CompositeKey.FromURLSegment(entity, id).ToWhereClause()})`).join(' OR ');
|
|
1499
1674
|
const r = await this.RunView({
|
|
1500
1675
|
EntityName: entity.Name,
|
|
1501
|
-
ExtraFilter:
|
|
1502
|
-
Fields:
|
|
1676
|
+
ExtraFilter: filter,
|
|
1677
|
+
Fields: entity.PrimaryKeys.map(pk => pk.Name),
|
|
1503
1678
|
ResultType: 'simple',
|
|
1504
1679
|
MaxRows: ids.length,
|
|
1505
1680
|
}, contextUser);
|
|
1506
1681
|
if (!r.Success)
|
|
1507
1682
|
return new Set();
|
|
1508
|
-
return new Set((r.Results ?? []).map(row => row.
|
|
1683
|
+
return new Set((r.Results ?? []).map(row => CompositeKey.FromEntityRecord(entity, row).ToCompactURLSegment()));
|
|
1509
1684
|
}
|
|
1510
1685
|
/**
|
|
1511
1686
|
* Returns true if any param in the batch has SaveViewResults set,
|
|
@@ -2402,13 +2577,21 @@ export class ProviderBase {
|
|
|
2402
2577
|
else if (checkResult.status === 'differential') {
|
|
2403
2578
|
// Cache is stale but we have differential data - merge with cached data
|
|
2404
2579
|
const fingerprint = this.clientCacheFingerprint(param);
|
|
2405
|
-
//
|
|
2580
|
+
// Every primary key column, in order. The server's `deletedRecordIDs` are the full
|
|
2581
|
+
// RecordChanges.RecordID segments (`F1|v1||F2|v2` for a composite key), and the merge
|
|
2582
|
+
// keys cached and updated rows the same way — so keying on the first column alone made
|
|
2583
|
+
// composite-key deletes never match and collapsed rows sharing that column. An entity the
|
|
2584
|
+
// server described but this provider cannot resolve is not merged: fall through to the
|
|
2585
|
+
// full refetch below rather than inventing an `ID` key.
|
|
2406
2586
|
const entity = this.EntityByName(param.EntityName);
|
|
2407
|
-
const
|
|
2587
|
+
const primaryKeyFieldNames = entity?.PrimaryKeys.map(pk => pk.Name) ?? [];
|
|
2588
|
+
if (primaryKeyFieldNames.length === 0) {
|
|
2589
|
+
LogError(`ProviderBase: server returned differential data for '${param.EntityName}' but the entity is not in this provider's metadata — refetching in full.`);
|
|
2590
|
+
}
|
|
2408
2591
|
// Apply differential update to cache (runViewCacheEligible, not raw CacheLocal — see the
|
|
2409
2592
|
// cacheable-gate note in prepareSmartCacheCheckParams; keeps Materialized/count_only/etc. out).
|
|
2410
|
-
if (this.runViewCacheEligibleForWrite(param) && checkResult.differentialData && LocalCacheManager.Instance.IsInitialized) {
|
|
2411
|
-
const merged = await LocalCacheManager.Instance.ApplyDifferentialUpdate(fingerprint, param, checkResult.differentialData.updatedRows, checkResult.differentialData.deletedRecordIDs,
|
|
2593
|
+
if (primaryKeyFieldNames.length > 0 && this.runViewCacheEligibleForWrite(param) && checkResult.differentialData && LocalCacheManager.Instance.IsInitialized) {
|
|
2594
|
+
const merged = await LocalCacheManager.Instance.ApplyDifferentialUpdate(fingerprint, param, checkResult.differentialData.updatedRows, checkResult.differentialData.deletedRecordIDs, primaryKeyFieldNames, checkResult.maxUpdatedAt || new Date().toISOString(), checkResult.rowCount || 0, checkResult.aggregateResults, // Pass fresh aggregate results (can't be differentially computed)
|
|
2412
2595
|
this);
|
|
2413
2596
|
if (merged) {
|
|
2414
2597
|
const mergedResult = {
|
|
@@ -3357,24 +3540,62 @@ export class ProviderBase {
|
|
|
3357
3540
|
// first, make sure we reset the flag to false so that if another call to this function happens
|
|
3358
3541
|
// while we are waiting for the async call to finish, we dont do it again
|
|
3359
3542
|
this._refresh = false;
|
|
3360
|
-
//
|
|
3361
|
-
//
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
this.UpdateLocalMetadata(res);
|
|
3372
|
-
this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps; // update this since we just used server to get all the stuff
|
|
3373
|
-
await this.SaveLocalMetadataToStorage();
|
|
3543
|
+
// SINGLE-FLIGHT: at most one full metadata reload runs at a time. Without this, a
|
|
3544
|
+
// second refresh request arriving while a reload is still awaiting its queries starts
|
|
3545
|
+
// a CONCURRENT reload, and whichever finishes LAST wins the atomic swap — an older
|
|
3546
|
+
// snapshot can overwrite a newer one. A joiner must not simply await and return,
|
|
3547
|
+
// either: the in-flight reload's queries may predate the write that prompted the
|
|
3548
|
+
// joiner, so it flags ONE follow-up; the loop below reruns after the current pass,
|
|
3549
|
+
// guaranteeing the final swap comes from a read that started after the last request.
|
|
3550
|
+
if (this._metadataReloadInFlight) {
|
|
3551
|
+
this._metadataReloadQueued = true;
|
|
3552
|
+
await this._metadataReloadInFlight;
|
|
3553
|
+
return true;
|
|
3374
3554
|
}
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3555
|
+
this._metadataReloadInFlight = (async () => {
|
|
3556
|
+
let effectiveHardRefresh = hardRefresh;
|
|
3557
|
+
do {
|
|
3558
|
+
this._metadataReloadQueued = false;
|
|
3559
|
+
// The local timestamps must describe the snapshot about to be loaded. On the
|
|
3560
|
+
// hard-refresh path the staleness check was SKIPPED, so the cached remote
|
|
3561
|
+
// timestamps predate this pass — copying them as-is would make the next
|
|
3562
|
+
// periodic check see a mismatch and reload once more for nothing. Re-read
|
|
3563
|
+
// them (one cheap status query, authoritative) BEFORE the load, not after:
|
|
3564
|
+
// a write landing DURING the load then leaves the stamped timestamps looking
|
|
3565
|
+
// stale and the next tick reloads — the safe direction. Reading after could
|
|
3566
|
+
// stamp the snapshot as containing a write it does not.
|
|
3567
|
+
if (effectiveHardRefresh) {
|
|
3568
|
+
await this.RefreshRemoteMetadataTimestamps(providerToUse);
|
|
3569
|
+
}
|
|
3570
|
+
// Fetch new metadata without clearing current metadata
|
|
3571
|
+
// This ensures readers always see valid data (old until new is ready)
|
|
3572
|
+
const start = new Date().getTime();
|
|
3573
|
+
const res = await this.GetAllMetadata(providerToUse, effectiveHardRefresh);
|
|
3574
|
+
const end = new Date().getTime();
|
|
3575
|
+
LogStatusEx({ message: `GetAllMetadata() took ${end - start} ms`, verboseOnly: true });
|
|
3576
|
+
if (res) {
|
|
3577
|
+
// Atomic swap via UpdateLocalMetadata: single property assignment is atomic in JavaScript
|
|
3578
|
+
// Readers now see new metadata instead of old
|
|
3579
|
+
// Uses UpdateLocalMetadata() to maintain consistency with LoadLocalMetadataFromStorage()
|
|
3580
|
+
// and allow potential subclass overrides for extensibility
|
|
3581
|
+
this.UpdateLocalMetadata(res);
|
|
3582
|
+
this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps; // update this since we just used server to get all the stuff
|
|
3583
|
+
await this.SaveLocalMetadataToStorage();
|
|
3584
|
+
}
|
|
3585
|
+
else {
|
|
3586
|
+
// GetAllMetadata failed - log error but keep existing metadata
|
|
3587
|
+
LogError('GetAllMetadata() returned undefined - metadata not updated');
|
|
3588
|
+
}
|
|
3589
|
+
// A queued follow-up exists only because another refresh request arrived
|
|
3590
|
+
// mid-reload; rerun hard so the re-read cannot be served by any cache layer.
|
|
3591
|
+
effectiveHardRefresh = true;
|
|
3592
|
+
} while (this._metadataReloadQueued);
|
|
3593
|
+
})();
|
|
3594
|
+
try {
|
|
3595
|
+
await this._metadataReloadInFlight;
|
|
3596
|
+
}
|
|
3597
|
+
finally {
|
|
3598
|
+
this._metadataReloadInFlight = null;
|
|
3378
3599
|
}
|
|
3379
3600
|
}
|
|
3380
3601
|
return true;
|
|
@@ -3594,6 +3815,10 @@ export class ProviderBase {
|
|
|
3594
3815
|
if (d && d.Success) {
|
|
3595
3816
|
// cache the dataset for anyone who wants to use it
|
|
3596
3817
|
await this.CacheDataset(ProviderBase._mjMetadataDatasetName, null, d);
|
|
3818
|
+
// Record which entities this metadata is built from, so a write to any of them
|
|
3819
|
+
// schedules a debounced refresh of this provider's metadata (see
|
|
3820
|
+
// registerMetadataDatasetMembership — membership is the dataset definition, not a list).
|
|
3821
|
+
this.registerMetadataDatasetMembership(d);
|
|
3597
3822
|
// got the results, let's build our response in the format we need
|
|
3598
3823
|
const simpleMetadata = {};
|
|
3599
3824
|
for (let r of d.Results) {
|
|
@@ -3656,7 +3881,7 @@ export class ProviderBase {
|
|
|
3656
3881
|
const result = [];
|
|
3657
3882
|
// Sort entities alphabetically by name to ensure deterministic ordering
|
|
3658
3883
|
// This prevents non-deterministic output in CodeGen and other metadata consumers
|
|
3659
|
-
const sortedEntities = entities.sort((a, b) => a.Name.
|
|
3884
|
+
const sortedEntities = entities.sort((a, b) => ordinalCompare(a.Name, b.Name) || ordinalCompare(a.ID, b.ID));
|
|
3660
3885
|
if (fieldValues && fieldValues.length > 0) {
|
|
3661
3886
|
const fieldValuesByFieldId = this.groupByNormalizedUUID(fieldValues, fv => fv.EntityFieldID);
|
|
3662
3887
|
for (const f of fields) {
|
|
@@ -3680,7 +3905,7 @@ export class ProviderBase {
|
|
|
3680
3905
|
for (const e of sortedEntities) {
|
|
3681
3906
|
const entityIdKey = NormalizeUUID(e.ID);
|
|
3682
3907
|
const entityFields = fieldsByEntityId.get(entityIdKey) || [];
|
|
3683
|
-
e.EntityFields = entityFields.sort((a, b) => a.Sequence - b.Sequence);
|
|
3908
|
+
e.EntityFields = entityFields.sort((a, b) => (a.Sequence - b.Sequence) || ordinalCompare(a.Name, b.Name) || ordinalCompare(a.ID, b.ID));
|
|
3684
3909
|
e.EntityPermissions = permissionsByEntityId.get(entityIdKey) || [];
|
|
3685
3910
|
e.EntityRelationships = relationshipsByEntityId.get(entityIdKey) || [];
|
|
3686
3911
|
e.EntitySettings = settingsByEntityId.get(entityIdKey) || [];
|
|
@@ -3867,13 +4092,18 @@ export class ProviderBase {
|
|
|
3867
4092
|
/**
|
|
3868
4093
|
* Checks if local metadata is out of date and needs refreshing.
|
|
3869
4094
|
* Compares local timestamps with server timestamps.
|
|
4095
|
+
* @param bypassMinCheckInterval - When true, skips the {@link MinRefreshCheckIntervalMs}
|
|
4096
|
+
* throttle. Event-driven callers pass true: they hold positive evidence that a metadata
|
|
4097
|
+
* member entity was just written, and the throttle otherwise answers "fresh" for any check
|
|
4098
|
+
* arriving within the window of the previous one — which would silently drop the second of
|
|
4099
|
+
* two permission changes made less than the window apart.
|
|
3870
4100
|
* @returns True if refresh is needed, false otherwise
|
|
3871
4101
|
*/
|
|
3872
|
-
async CheckToSeeIfRefreshNeeded(providerToUse) {
|
|
4102
|
+
async CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval) {
|
|
3873
4103
|
if (!this.AllowRefresh)
|
|
3874
4104
|
return false;
|
|
3875
4105
|
const now = Date.now();
|
|
3876
|
-
if ((now - this._lastRefreshCheckAt) < ProviderBase.MinRefreshCheckIntervalMs) {
|
|
4106
|
+
if (!bypassMinCheckInterval && (now - this._lastRefreshCheckAt) < ProviderBase.MinRefreshCheckIntervalMs) {
|
|
3877
4107
|
LogStatusEx({
|
|
3878
4108
|
message: `[RefreshCheck] Skipped — last check was ${now - this._lastRefreshCheckAt}ms ago (min interval ${ProviderBase.MinRefreshCheckIntervalMs}ms)`,
|
|
3879
4109
|
verboseOnly: true
|
|
@@ -3888,10 +4118,13 @@ export class ProviderBase {
|
|
|
3888
4118
|
/**
|
|
3889
4119
|
* Refreshes metadata only if needed based on timestamp comparison.
|
|
3890
4120
|
* Combines check and refresh into a single operation.
|
|
4121
|
+
* @param bypassMinCheckInterval - Passed through to {@link CheckToSeeIfRefreshNeeded};
|
|
4122
|
+
* event-driven callers set true so the throttle cannot eat a check they have positive
|
|
4123
|
+
* evidence for.
|
|
3891
4124
|
* @returns True if refresh was successful or not needed
|
|
3892
4125
|
*/
|
|
3893
|
-
async RefreshIfNeeded(providerToUse) {
|
|
3894
|
-
if (await this.CheckToSeeIfRefreshNeeded(providerToUse))
|
|
4126
|
+
async RefreshIfNeeded(providerToUse, bypassMinCheckInterval) {
|
|
4127
|
+
if (await this.CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval))
|
|
3895
4128
|
return this.Refresh(providerToUse);
|
|
3896
4129
|
else
|
|
3897
4130
|
return true;
|
|
@@ -3943,6 +4176,11 @@ export class ProviderBase {
|
|
|
3943
4176
|
// to a downstream `.constructor`/`.LoadFromData` crash.
|
|
3944
4177
|
throw new Error(`Entity '${entityName}' could not be instantiated — MJGlobal ClassFactory returned null. Ensure LoadGeneratedEntities()/LoadCoreEntities() has run so the entity's class is registered.`);
|
|
3945
4178
|
}
|
|
4179
|
+
// Always rebind. ClassFactory passes `(Entity, this)` into the constructor,
|
|
4180
|
+
// but a 1-arg subclass (`constructor(Entity) { super(Entity); }`) silently
|
|
4181
|
+
// drops the provider. Without this, ProviderToUse falls back to the global
|
|
4182
|
+
// host and a nested save on an independent instance deadlocks on FKs.
|
|
4183
|
+
newObject.BindProvider(this);
|
|
3946
4184
|
await newObject.Config(actualContextUser);
|
|
3947
4185
|
// Initialize IS-A parent entity composition chain before any data operations
|
|
3948
4186
|
await newObject.InitializeParentEntity();
|
|
@@ -4361,13 +4599,31 @@ export class ProviderBase {
|
|
|
4361
4599
|
const tsKey = this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey;
|
|
4362
4600
|
const fmtKey = this.LocalStoragePrefix + ProviderBase.localStorageFormatKey;
|
|
4363
4601
|
const dataKey = this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey;
|
|
4602
|
+
const membershipKey = this.LocalStoragePrefix + ProviderBase.localStorageDatasetMembershipKey;
|
|
4364
4603
|
const readStart = Date.now();
|
|
4365
|
-
const all = await ls.GetItems([tsKey, fmtKey, dataKey]);
|
|
4604
|
+
const all = await ls.GetItems([tsKey, fmtKey, dataKey, membershipKey]);
|
|
4366
4605
|
const readMs = Date.now() - readStart;
|
|
4367
4606
|
const tsRaw = all.get(tsKey) ?? null;
|
|
4368
4607
|
const format = all.get(fmtKey) ?? null;
|
|
4369
4608
|
const raw = all.get(dataKey) ?? null;
|
|
4609
|
+
const membershipRaw = all.get(membershipKey) ?? null;
|
|
4370
4610
|
this._latestLocalMetadataTimestamps = tsRaw ? JSON.parse(tsRaw) : null;
|
|
4611
|
+
// Restore the metadata-member entity set so the event-driven refresh works on a warm
|
|
4612
|
+
// boot that never calls GetAllMetadata (the stale-while-revalidate fast start). The
|
|
4613
|
+
// set is only stored after a successful dataset load, so restoring it is exactly as
|
|
4614
|
+
// trustworthy as restoring the metadata itself.
|
|
4615
|
+
if (membershipRaw) {
|
|
4616
|
+
try {
|
|
4617
|
+
const names = JSON.parse(membershipRaw);
|
|
4618
|
+
if (Array.isArray(names) && names.length > 0) {
|
|
4619
|
+
this._metadataDatasetEntityNames = new Set(names);
|
|
4620
|
+
this.ensureInflightViewInvalidation();
|
|
4621
|
+
}
|
|
4622
|
+
}
|
|
4623
|
+
catch (membershipErr) {
|
|
4624
|
+
LogError(`[Metadata Cache] Failed to restore dataset membership: ${membershipErr instanceof Error ? membershipErr.message : String(membershipErr)}`);
|
|
4625
|
+
}
|
|
4626
|
+
}
|
|
4371
4627
|
if (!raw)
|
|
4372
4628
|
return;
|
|
4373
4629
|
// Decompress if stored in compressed format, otherwise parse directly
|
|
@@ -4413,10 +4669,12 @@ export class ProviderBase {
|
|
|
4413
4669
|
static { this.localStorageTimestampsKey = this.localStorageRootKey + '_Timestamps'; }
|
|
4414
4670
|
static { this.localStorageAllMetadataKey = this.localStorageRootKey + '_AllMetadata'; }
|
|
4415
4671
|
static { this.localStorageFormatKey = this.localStorageRootKey + '_Format'; }
|
|
4672
|
+
static { this.localStorageDatasetMembershipKey = this.localStorageRootKey + '_DatasetMembership'; }
|
|
4416
4673
|
static { this.localStorageKeys = [
|
|
4417
4674
|
ProviderBase.localStorageTimestampsKey,
|
|
4418
4675
|
ProviderBase.localStorageAllMetadataKey,
|
|
4419
4676
|
ProviderBase.localStorageFormatKey,
|
|
4677
|
+
ProviderBase.localStorageDatasetMembershipKey,
|
|
4420
4678
|
]; }
|
|
4421
4679
|
/**
|
|
4422
4680
|
* Converts a base64-encoded string to an ArrayBuffer.
|
|
@@ -4460,46 +4718,74 @@ export class ProviderBase {
|
|
|
4460
4718
|
if (!ls)
|
|
4461
4719
|
return;
|
|
4462
4720
|
const start = Date.now();
|
|
4463
|
-
//
|
|
4464
|
-
//
|
|
4465
|
-
//
|
|
4466
|
-
|
|
4467
|
-
//
|
|
4721
|
+
// Serialize the AllMetadata object FIRST. If this throws (or the payload write below
|
|
4722
|
+
// fails), nothing else has been written yet, so the previously stored timestamps still
|
|
4723
|
+
// describe the previously stored payload and LocalMetadataObsolete() will report
|
|
4724
|
+
// obsolete on the next boot — which is what makes the save retry. Writing timestamps
|
|
4725
|
+
// before the payload left the cache claiming freshness with no payload behind it, and
|
|
4726
|
+
// that state was never retried.
|
|
4468
4727
|
const jsonString = JSON.stringify(this._localMetadata);
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4728
|
+
const snapshot = await this.writeMetadataSnapshot(ls, jsonString);
|
|
4729
|
+
// Persist the metadata-member entity set beside the snapshot it belongs to, so the
|
|
4730
|
+
// event-driven refresh survives a warm boot (see LoadLocalMetadataFromStorage).
|
|
4731
|
+
if (this._metadataDatasetEntityNames?.size) {
|
|
4732
|
+
await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageDatasetMembershipKey, JSON.stringify([...this._metadataDatasetEntityNames]));
|
|
4733
|
+
}
|
|
4734
|
+
// Timestamps LAST: they are the freshness claim for everything written above, so they
|
|
4735
|
+
// must be the final thing to land.
|
|
4736
|
+
await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey, JSON.stringify(this._latestLocalMetadataTimestamps));
|
|
4737
|
+
this.logMetadataSaveComplete(Date.now() - start, jsonString.length, snapshot);
|
|
4738
|
+
}
|
|
4739
|
+
catch (e) {
|
|
4740
|
+
LogError(`[Metadata Cache] SaveLocalMetadataToStorage failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
4741
|
+
}
|
|
4742
|
+
}
|
|
4743
|
+
/**
|
|
4744
|
+
* Writes the serialized metadata payload and its format marker. Compressed (gzip+base64) when
|
|
4745
|
+
* CompressionStream is available, otherwise plain JSON. The snapshot path intentionally uses
|
|
4746
|
+
* string storage so the compressed form round-trips cleanly through providers that don't
|
|
4747
|
+
* support binary natively. A compression failure falls back to the uncompressed write; a
|
|
4748
|
+
* failure of the write itself propagates to the caller so nothing after it is stored.
|
|
4749
|
+
*/
|
|
4750
|
+
async writeMetadataSnapshot(ls, jsonString) {
|
|
4751
|
+
const dataKey = this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey;
|
|
4752
|
+
const formatKey = this.LocalStoragePrefix + ProviderBase.localStorageFormatKey;
|
|
4753
|
+
// Attempt compressed storage using native CompressionStream (available in modern browsers and Node 18+)
|
|
4754
|
+
if (typeof CompressionStream !== 'undefined') {
|
|
4755
|
+
try {
|
|
4756
|
+
const blob = new Blob([jsonString]);
|
|
4757
|
+
const cs = new CompressionStream('gzip');
|
|
4758
|
+
const compressedStream = blob.stream().pipeThrough(cs);
|
|
4759
|
+
const compressedBuffer = await new Response(compressedStream).arrayBuffer();
|
|
4760
|
+
const base64 = ProviderBase.arrayBufferToBase64(compressedBuffer);
|
|
4761
|
+
await ls.SetItem(dataKey, base64);
|
|
4762
|
+
await ls.SetItem(formatKey, 'gzip');
|
|
4763
|
+
return { compressed: true, storedLength: base64.length };
|
|
4764
|
+
}
|
|
4765
|
+
catch (compressErr) {
|
|
4766
|
+
// Compression failed — fall through to uncompressed save
|
|
4767
|
+
LogError(`[Metadata Cache] Compression failed, falling back to uncompressed: ${compressErr instanceof Error ? compressErr.message : String(compressErr)}`);
|
|
4491
4768
|
}
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
4769
|
+
}
|
|
4770
|
+
// Fallback: uncompressed save (older environments without CompressionStream)
|
|
4771
|
+
await ls.SetItem(dataKey, jsonString);
|
|
4772
|
+
await ls.SetItem(formatKey, 'json');
|
|
4773
|
+
return { compressed: false, storedLength: jsonString.length };
|
|
4774
|
+
}
|
|
4775
|
+
logMetadataSaveComplete(elapsedMs, rawLength, snapshot) {
|
|
4776
|
+
const mb = (n) => (n / 1024 / 1024).toFixed(1);
|
|
4777
|
+
if (snapshot.compressed) {
|
|
4778
|
+
const ratio = rawLength > 0 ? (snapshot.storedLength / rawLength * 100).toFixed(1) : '?';
|
|
4496
4779
|
LogStatusEx({
|
|
4497
|
-
message: `[Metadata Cache] Save complete
|
|
4780
|
+
message: `[Metadata Cache] Save complete: ${elapsedMs}ms, raw=${mb(rawLength)}MB, compressed=${mb(snapshot.storedLength)}MB (${ratio}%)`,
|
|
4498
4781
|
verboseOnly: true
|
|
4499
4782
|
});
|
|
4500
4783
|
}
|
|
4501
|
-
|
|
4502
|
-
|
|
4784
|
+
else {
|
|
4785
|
+
LogStatusEx({
|
|
4786
|
+
message: `[Metadata Cache] Save complete (uncompressed): ${elapsedMs}ms, size=${mb(rawLength)}MB`,
|
|
4787
|
+
verboseOnly: true
|
|
4788
|
+
});
|
|
4503
4789
|
}
|
|
4504
4790
|
}
|
|
4505
4791
|
/**
|