@memberjunction/core 6.2.0-edge.6 → 6.2.0-edge.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/generic/EntityFieldRules.d.ts.map +1 -1
  2. package/dist/generic/EntityFieldRules.js +1 -0
  3. package/dist/generic/EntityFieldRules.js.map +1 -1
  4. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +32 -0
  5. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  6. package/dist/generic/baseEngine.d.ts +165 -12
  7. package/dist/generic/baseEngine.d.ts.map +1 -1
  8. package/dist/generic/baseEngine.js +622 -98
  9. package/dist/generic/baseEngine.js.map +1 -1
  10. package/dist/generic/baseEngineRegistry.d.ts +10 -2
  11. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  12. package/dist/generic/baseEngineRegistry.js +33 -5
  13. package/dist/generic/baseEngineRegistry.js.map +1 -1
  14. package/dist/generic/baseEntity.d.ts +121 -6
  15. package/dist/generic/baseEntity.d.ts.map +1 -1
  16. package/dist/generic/baseEntity.js +1144 -64
  17. package/dist/generic/baseEntity.js.map +1 -1
  18. package/dist/generic/baseRemotableOperation.d.ts +95 -6
  19. package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
  20. package/dist/generic/baseRemotableOperation.js +112 -8
  21. package/dist/generic/baseRemotableOperation.js.map +1 -1
  22. package/dist/generic/databaseProviderBase.d.ts +37 -0
  23. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  24. package/dist/generic/databaseProviderBase.js +262 -46
  25. package/dist/generic/databaseProviderBase.js.map +1 -1
  26. package/dist/generic/entityInfo.d.ts +25 -1
  27. package/dist/generic/entityInfo.d.ts.map +1 -1
  28. package/dist/generic/entityInfo.js +70 -12
  29. package/dist/generic/entityInfo.js.map +1 -1
  30. package/dist/generic/externalDataSourceReadRouter.d.ts +10 -0
  31. package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
  32. package/dist/generic/externalDataSourceReadRouter.js +12 -0
  33. package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
  34. package/dist/generic/fieldSecuritySQLGate.d.ts +11 -2
  35. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -1
  36. package/dist/generic/fieldSecuritySQLGate.js +6 -2
  37. package/dist/generic/fieldSecuritySQLGate.js.map +1 -1
  38. package/dist/generic/interfaces.d.ts +50 -1
  39. package/dist/generic/interfaces.d.ts.map +1 -1
  40. package/dist/generic/interfaces.js.map +1 -1
  41. package/dist/generic/listMembershipFilter.d.ts +5 -0
  42. package/dist/generic/listMembershipFilter.d.ts.map +1 -0
  43. package/dist/generic/listMembershipFilter.js +23 -0
  44. package/dist/generic/listMembershipFilter.js.map +1 -0
  45. package/dist/generic/localCacheManager.d.ts +69 -5
  46. package/dist/generic/localCacheManager.d.ts.map +1 -1
  47. package/dist/generic/localCacheManager.js +253 -18
  48. package/dist/generic/localCacheManager.js.map +1 -1
  49. package/dist/generic/logging.d.ts +105 -38
  50. package/dist/generic/logging.d.ts.map +1 -1
  51. package/dist/generic/logging.js +196 -5
  52. package/dist/generic/logging.js.map +1 -1
  53. package/dist/generic/providerBase.d.ts +198 -3
  54. package/dist/generic/providerBase.d.ts.map +1 -1
  55. package/dist/generic/providerBase.js +721 -223
  56. package/dist/generic/providerBase.js.map +1 -1
  57. package/dist/generic/relatedRecordCollection.d.ts +5 -0
  58. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  59. package/dist/generic/relatedRecordCollection.js +11 -2
  60. package/dist/generic/relatedRecordCollection.js.map +1 -1
  61. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
  62. package/dist/generic/remoteOperationDispatch.js +14 -2
  63. package/dist/generic/remoteOperationDispatch.js.map +1 -1
  64. package/dist/generic/runQuerySQLFilterImplementations.d.ts +33 -6
  65. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  66. package/dist/generic/runQuerySQLFilterImplementations.js +81 -27
  67. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  68. package/dist/generic/sharedLoad.d.ts +42 -0
  69. package/dist/generic/sharedLoad.d.ts.map +1 -0
  70. package/dist/generic/sharedLoad.js +74 -0
  71. package/dist/generic/sharedLoad.js.map +1 -0
  72. package/dist/generic/telemetryManager.d.ts +31 -0
  73. package/dist/generic/telemetryManager.d.ts.map +1 -1
  74. package/dist/generic/telemetryManager.js +85 -9
  75. package/dist/generic/telemetryManager.js.map +1 -1
  76. package/dist/generic/transactionGroup.d.ts +19 -0
  77. package/dist/generic/transactionGroup.d.ts.map +1 -1
  78. package/dist/generic/transactionGroup.js +55 -7
  79. package/dist/generic/transactionGroup.js.map +1 -1
  80. package/dist/generic/transportError.d.ts +41 -0
  81. package/dist/generic/transportError.d.ts.map +1 -0
  82. package/dist/generic/transportError.js +167 -0
  83. package/dist/generic/transportError.js.map +1 -0
  84. package/dist/generic/util.d.ts +13 -1
  85. package/dist/generic/util.d.ts.map +1 -1
  86. package/dist/generic/util.js +53 -4
  87. package/dist/generic/util.js.map +1 -1
  88. package/dist/index.d.ts +3 -0
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +3 -0
  91. package/dist/index.js.map +1 -1
  92. package/dist/views/runView.d.ts +23 -10
  93. package/dist/views/runView.d.ts.map +1 -1
  94. package/dist/views/runView.js +5 -1
  95. package/dist/views/runView.js.map +1 -1
  96. package/package.json +3 -3
@@ -14,7 +14,7 @@ import { BaseEngineRegistry } from "./baseEngineRegistry.js";
14
14
  import { LocalCacheManager } from "./localCacheManager.js";
15
15
  import { ProviderBase } from "./providerBase.js";
16
16
  import { DatabaseProviderBase } from "./databaseProviderBase.js";
17
- import { TransformSimpleObjectToEntityObject } from "./util.js";
17
+ import { TransformSimpleObjectToEntityObject, CanonicalPrimaryKeyValue } from "./util.js";
18
18
  import { WellKnownUserSource } from "./wellKnownUserSource.js";
19
19
  /**
20
20
  * Property configuration for the BaseEngine class to automatically load/set properties on the class.
@@ -127,6 +127,21 @@ export class BaseEngine extends BaseSingleton {
127
127
  });
128
128
  return this._derivedStateRebuild;
129
129
  }
130
+ /**
131
+ * The least time, in milliseconds, between the end of one reload from
132
+ * {@link reloadAfterExternalChange} and the start of the next for the same property. A change
133
+ * that arrives after this long since the last reload reloads at once; one that arrives sooner
134
+ * (including while a reload is running) waits out the rest of it, and every change that arrives
135
+ * meanwhile shares that one reload.
136
+ *
137
+ * Without it, a steady stream of changes (a server writing rows of one entity a few times a
138
+ * second) keeps a property reloading back to back. Each full reload costs time proportional to
139
+ * the table, so the process spends most of its time reloading and the cost of the stream grows
140
+ * with the square of its length. With it, a property reloads at most once per this delay plus
141
+ * one reload's duration, and its data is never more than that far behind the last change.
142
+ * 0 restores back-to-back reloads.
143
+ */
144
+ static { this.ExternalChangeFollowUpDelayMs = 1000; }
130
145
  /**
131
146
  * Returns an Observable for a specific engine array property. Subscribers receive the
132
147
  * current array immediately (BehaviorSubject semantics), then re-receive the same array
@@ -165,6 +180,11 @@ export class BaseEngine extends BaseSingleton {
165
180
  super();
166
181
  this._loaded = false;
167
182
  this._loadingSubject = new BehaviorSubject(false);
183
+ /**
184
+ * The forced reload queued behind the load that is running, shared by every `Load(…, true)`
185
+ * call that arrives before it starts. Null when none is queued. See {@link Load}.
186
+ */
187
+ this._forcedReloadAfterCurrent = null;
168
188
  /**
169
189
  * True once {@link ResolveContextUser} has put this engine on the MJ system user. Makes the
170
190
  * server-side identity sticky (a later caller cannot displace it) and memoizes the lookup so
@@ -192,18 +212,40 @@ export class BaseEngine extends BaseSingleton {
192
212
  * against overlapping full refreshes clobbering each other: when several full-refresh
193
213
  * RunViews for the same property are in flight at once (e.g. a burst of saves each
194
214
  * landing in its own debounce window because the round-trip exceeds the DebounceTime),
195
- * only the latest-INITIATED refresh may commit its results. Without this, whichever
215
+ * an earlier-INITIATED refresh may never commit over a later one. Without this, whichever
196
216
  * RunView happens to RESOLVE last wins — which can be an earlier-initiated request that
197
217
  * read a staler snapshot, leaving the cache "one operation behind" until a full reload
198
- * reconciles it. Keyed by config PropertyName. See {@link beginConfigRefresh}.
218
+ * reconciles it. Keyed by config PropertyName. See {@link beginConfigRefresh} and
219
+ * {@link mayCommitConfigRefresh}.
199
220
  */
200
221
  this._configRefreshGeneration = new Map();
201
222
  /**
202
- * Reloads started by {@link OnExternalCacheChange}, keyed by config PropertyName. While one is
203
- * running, further events for that property set `Again` instead of starting another, so a
204
- * burst of peer events costs at most two reloads per property. See {@link reloadAfterExternalChange}.
223
+ * Per-property generation of the newest full refresh that has committed rows. A failed read
224
+ * commits none, so it never counts. A refresh that is no longer the latest started may still
225
+ * commit while nothing newer has, so a caller awaiting it never gets back rows older than its
226
+ * own read. See {@link mayCommitConfigRefresh}.
227
+ */
228
+ this._configCommittedGeneration = new Map();
229
+ /**
230
+ * Reloads started by {@link OnExternalCacheChange} and by the no-row fallback of
231
+ * {@link HandleRemoteInvalidateEvent}, keyed by config PropertyName. While one is running,
232
+ * further events for that property set `Again` instead of starting another, so a burst of
233
+ * events costs at most two reloads per property. See {@link reloadAfterExternalChange}.
205
234
  */
206
235
  this._externalChangeReloads = new Map();
236
+ /**
237
+ * Per-property count of in-flight full refreshes (`LoadSingleEntityConfig` or full `'set'`
238
+ * cache payload materialization). When positive, incoming `row_delta` events defer to a
239
+ * coalesced full reload rather than racing an in-progress snapshot replacement.
240
+ */
241
+ this._inFlightFullRefreshCount = new Map();
242
+ /**
243
+ * Per-property promise chain serializing `row_delta` applications so back-to-back row deltas
244
+ * for distinct rows apply in arrival order without dropping earlier mutations.
245
+ */
246
+ this._rowDeltaQueues = new Map();
247
+ /** When the last reload from {@link reloadAfterExternalChange} ended, per config PropertyName (epoch ms). */
248
+ this._externalChangeReloadEndedAt = new Map();
207
249
  this._entityEventDebounceTime = 1500; // Default debounce time in milliseconds (1.5 seconds)
208
250
  this._eventRefreshRetryTimers = new Map();
209
251
  /**
@@ -296,6 +338,15 @@ export class BaseEngine extends BaseSingleton {
296
338
  const entry = this._dataMap.get(propertyName);
297
339
  return entry?.permissionDenied === true;
298
340
  }
341
+ /**
342
+ * True when the named config property has finished loading with valid data (not failed and
343
+ * not skipped due to permission denial), even if other configs on the same engine are still
344
+ * loading.
345
+ */
346
+ IsPropertyLoaded(propertyName) {
347
+ const entry = this._dataMap.get(propertyName);
348
+ return entry?.loadedSuccessfully === true && !entry.readDenied && !entry.permissionDenied;
349
+ }
299
350
  /**
300
351
  * List of entity names that were skipped due to permission denial.
301
352
  * Empty if not permission-constrained. Useful for logging/diagnostics.
@@ -405,6 +456,11 @@ export class BaseEngine extends BaseSingleton {
405
456
  /**
406
457
  * This method should be called by sub-classes to load up their specific metadata requirements. For more complex metadata
407
458
  * loading or for post-processing of metadata loading done here, overide the AdditionalLoading method to add your logic.
459
+ *
460
+ * When a load is already running, a call without `forceRefresh` waits for it and returns. A call
461
+ * with `forceRefresh` waits for it and then reads again, because the running load may have read
462
+ * before the caller's own writes. Forced calls that arrive while that reload is still queued share
463
+ * it, so a burst of `Config(true)` calls costs at most one extra read.
408
464
  * @param configs
409
465
  * @param contextUser
410
466
  * @returns
@@ -413,14 +469,14 @@ export class BaseEngine extends BaseSingleton {
413
469
  if (this.ProviderToUse?.ProviderType === ProviderType.Database && !contextUser)
414
470
  throw new Error('For server-side use of all engine classes, you must provide the contextUser parameter');
415
471
  if (this._loadingSubject.value) {
416
- return new Promise((resolve) => {
417
- const subscription = this._loadingSubject.subscribe((loading) => {
418
- if (!loading) {
419
- subscription.unsubscribe();
420
- resolve();
421
- }
422
- });
472
+ if (!forceRefresh) {
473
+ return this.waitForCurrentLoad();
474
+ }
475
+ this._forcedReloadAfterCurrent ??= this.waitForCurrentLoad().then(() => {
476
+ this._forcedReloadAfterCurrent = null;
477
+ return this.Load(configs, provider, true, contextUser);
423
478
  });
479
+ return this._forcedReloadAfterCurrent;
424
480
  }
425
481
  if (!this._loaded || forceRefresh) {
426
482
  // Reset permission-constrained state on fresh load or force refresh
@@ -478,6 +534,17 @@ export class BaseEngine extends BaseSingleton {
478
534
  }
479
535
  }
480
536
  }
537
+ /** Resolves when the load that is running finishes. */
538
+ waitForCurrentLoad() {
539
+ return new Promise((resolve) => {
540
+ const subscription = this._loadingSubject.subscribe((loading) => {
541
+ if (!loading) {
542
+ subscription.unsubscribe();
543
+ resolve();
544
+ }
545
+ });
546
+ });
547
+ }
481
548
  /**
482
549
  * Engine classes that have already warned that the system user could not be resolved, so a
483
550
  * misconfigured deployment says so once per class instead of on every load.
@@ -769,9 +836,7 @@ export class BaseEngine extends BaseSingleton {
769
836
  if (!entityName) {
770
837
  return true; // No entity name, nothing to do
771
838
  }
772
- const matchingConfigs = this._metadataConfigs.filter((config) => {
773
- return config.AutoRefresh && config.EntityName && config.EntityName.trim().toLowerCase() === entityName;
774
- });
839
+ const matchingConfigs = this.AutoRefreshConfigsForEntity(entityName);
775
840
  if (matchingConfigs.length === 0) {
776
841
  return true;
777
842
  }
@@ -794,22 +859,9 @@ export class BaseEngine extends BaseSingleton {
794
859
  }
795
860
  // Fall through to server fetch if direct delete failed
796
861
  }
797
- // Fallback: re-fetch from server (missing data or apply failure). Bypass the cache
798
- // for the same reason as the local-event path: this fetch runs BECAUSE a
799
- // (cross-server) write signaled the cache stale, so reading back through it would
800
- // re-sync the pre-write snapshot.
801
- let refreshCount = 0;
862
+ // Fallback: re-fetch from the server (no row, or it could not be applied).
802
863
  for (const config of matchingConfigs) {
803
- await this.LoadSingleConfig(config, this._contextUser, /*bypassCache*/ true);
804
- if (!this.configLoadedSuccessfully(config.PropertyName)) {
805
- // Same one-shot hazard as the debounced path: the invalidation event is
806
- // consumed, so a transient failure here needs a bounded retry too.
807
- this.scheduleEventRefreshRetry(config, 1);
808
- }
809
- refreshCount++;
810
- }
811
- if (refreshCount > 0) {
812
- await this.AdditionalLoading(this._contextUser);
864
+ await this.reloadAfterRemoteInvalidate(config);
813
865
  }
814
866
  return true;
815
867
  }
@@ -818,6 +870,38 @@ export class BaseEngine extends BaseSingleton {
818
870
  return false;
819
871
  }
820
872
  }
873
+ /**
874
+ * Reloads one config because a remote-invalidate event said it changed but carried no row to
875
+ * apply. A burst of such events shares reloads (see {@link reloadAfterExternalChange}), so a
876
+ * server that saves many rows of one entity costs this engine at most two reloads per burst
877
+ * instead of one per row, and the reload that runs last began after the last event arrived.
878
+ *
879
+ * The reload reads past the cache: it runs because a write elsewhere made the cached copy
880
+ * stale, and reading back through the cache would restore the pre-write snapshot. A transient
881
+ * failure gets the bounded retry, since the event that asked for this reload is consumed.
882
+ */
883
+ async reloadAfterRemoteInvalidate(config) {
884
+ let succeeded;
885
+ try {
886
+ await this.reloadAfterExternalChange(config, /*bypassCache*/ true);
887
+ succeeded = this.configLoadedSuccessfully(config.PropertyName);
888
+ }
889
+ catch (e) {
890
+ LogError(e);
891
+ succeeded = false;
892
+ }
893
+ if (!succeeded) {
894
+ this.scheduleEventRefreshRetry(config, 1);
895
+ }
896
+ }
897
+ /**
898
+ * The entity configs that refresh automatically when a record of `entityName` changes.
899
+ * Matching ignores case and surrounding whitespace.
900
+ */
901
+ AutoRefreshConfigsForEntity(entityName) {
902
+ const name = entityName.trim().toLowerCase();
903
+ return this._metadataConfigs.filter((config) => config.AutoRefresh && config.EntityName && config.EntityName.trim().toLowerCase() === name);
904
+ }
821
905
  /**
822
906
  * Applies record data from a remote-invalidate event directly to the engine's in-memory arrays.
823
907
  * Creates a BaseEntity instance, loads the JSON data into it, then updates the matching
@@ -1427,7 +1511,12 @@ export class BaseEngine extends BaseSingleton {
1427
1511
  if (!fresh) {
1428
1512
  return null;
1429
1513
  }
1430
- await fresh.LoadFromData(source.GetAll());
1514
+ if (source.EntityInfo?.IsParentType) {
1515
+ await BaseEntity.CloneEntityWithISAChain(source, fresh, provider, this._contextUser);
1516
+ }
1517
+ else {
1518
+ await fresh.LoadFromData(source.GetAll());
1519
+ }
1431
1520
  return fresh;
1432
1521
  }
1433
1522
  catch (e) {
@@ -1549,16 +1638,15 @@ export class BaseEngine extends BaseSingleton {
1549
1638
  */
1550
1639
  async LoadConfigs(configs, contextUser, bypassCache = false) {
1551
1640
  this._metadataConfigs = configs.map(c => this.UpgradeObjectToConfig(c));
1552
- // now, break up the configs into two chunks, datasets and views of entities so we can load all the views in a single network call via RunViews()
1553
1641
  const entityConfigs = this._metadataConfigs.filter(c => c.Type === 'entity');
1554
- const datasetConfigs = this._metadataConfigs.filter(c => c.Type === 'dataset');
1555
1642
  this.WarnAboutFieldSecuredEntities(entityConfigs);
1556
- // All-or-nothing permission check: if the user lacks read access on ANY
1557
- // entity config, skip ALL entity configs for this engine. Returns the
1558
- // original array unchanged when permissions pass, or an empty array when
1559
- // any entity is denied (marking the engine as permission-constrained).
1560
- const entityConfigsToLoad = this.CheckPermissionsOrSkipAll(entityConfigs, contextUser);
1561
- await Promise.all([...datasetConfigs.map(c => this.LoadSingleDatasetConfig(c, contextUser, bypassCache)),
1643
+ // All-or-nothing permission check across all configs (both entity configs and
1644
+ // dataset configs that declare their constituent Entities). If the user lacks
1645
+ // read access on ANY configured entity, skip ALL configs for this engine.
1646
+ const configsToLoad = this.CheckPermissionsOrSkipAll(this._metadataConfigs, contextUser);
1647
+ const entityConfigsToLoad = configsToLoad.filter(c => c.Type === 'entity');
1648
+ const datasetConfigsToLoad = configsToLoad.filter(c => c.Type === 'dataset');
1649
+ await Promise.all([...datasetConfigsToLoad.map(c => this.LoadSingleDatasetConfig(c, contextUser, bypassCache)),
1562
1650
  this.LoadMultipleEntityConfigs(entityConfigsToLoad, contextUser, bypassCache)]);
1563
1651
  // Register cross-server cache change callbacks for entity configs
1564
1652
  this.RegisterCacheChangeCallbacks(entityConfigs);
@@ -1613,18 +1701,20 @@ export class BaseEngine extends BaseSingleton {
1613
1701
  const missingEntities = this.findMissingEntities(configs);
1614
1702
  if (missingEntities.length > 0) {
1615
1703
  LogStatusEx({
1616
- message: `${this.constructor.name}: Skipping ${configs.length} entity config(s) — entity metadata not installed for: ${missingEntities.join(', ')}`,
1704
+ message: `${this.constructor.name}: Skipping ${configs.length} config(s) — entity metadata not installed for: ${missingEntities.join(', ')}`,
1617
1705
  verboseOnly: true,
1618
1706
  category: 'Cache',
1619
1707
  });
1708
+ // Loaded (so EnsureLoaded doesn't retry-loop) but readDenied: the installed entities'
1709
+ // configs were skipped too, and their [] is not their table. IsPropertyLoaded (and so
1710
+ // BaseEngineRegistry.FindCachedEntity) must not serve it as one.
1620
1711
  for (const config of configs) {
1621
- if (config.AddToObject !== false) {
1622
- this[config.PropertyName] = [];
1623
- }
1712
+ this.initializeSkippedConfigProperty(config);
1624
1713
  this._dataMap.set(config.PropertyName, {
1625
- entityName: config.EntityName,
1714
+ ...(config.Type === 'dataset' ? { datasetName: config.DatasetName } : { entityName: config.EntityName }),
1626
1715
  data: [],
1627
1716
  loadedSuccessfully: true,
1717
+ readDenied: true,
1628
1718
  });
1629
1719
  }
1630
1720
  return [];
@@ -1636,17 +1726,15 @@ export class BaseEngine extends BaseSingleton {
1636
1726
  return configs; // Can't check without a user — proceed with normal loading
1637
1727
  const deniedEntities = this.findUnreadableEntities(configs, user);
1638
1728
  if (deniedEntities.length > 0) {
1639
- LogWarning(`${this.constructor.name}: Skipping ${configs.length} entity config(s) — user ${user.Email} lacks read permission on: ${deniedEntities.join(', ')}`, 'Cache');
1640
- // Mark all entity configs as successfully loaded with empty data.
1729
+ LogWarning(`${this.constructor.name}: Skipping ${configs.length} config(s) — user ${user.Email} lacks read permission on: ${deniedEntities.join(', ')}`, 'Cache');
1730
+ // Mark all configs as successfully loaded with empty data.
1641
1731
  // This is not a failure — it's expected behavior for limited-permission users.
1642
1732
  // Marking as successful allows the engine to set _loaded = true and avoid
1643
1733
  // endless retry loops from EnsureLoaded().
1644
1734
  for (const config of configs) {
1645
- if (config.AddToObject !== false) {
1646
- this[config.PropertyName] = [];
1647
- }
1735
+ this.initializeSkippedConfigProperty(config);
1648
1736
  this._dataMap.set(config.PropertyName, {
1649
- entityName: config.EntityName,
1737
+ ...(config.Type === 'dataset' ? { datasetName: config.DatasetName } : { entityName: config.EntityName }),
1650
1738
  data: [],
1651
1739
  loadedSuccessfully: true,
1652
1740
  permissionDenied: true,
@@ -1654,10 +1742,42 @@ export class BaseEngine extends BaseSingleton {
1654
1742
  }
1655
1743
  this._isPermissionConstrained = true;
1656
1744
  this._deniedEntityNames = deniedEntities;
1657
- return []; // Return empty — don't attempt any RunView calls
1745
+ return []; // Return empty — don't attempt any RunView or dataset calls
1658
1746
  }
1659
1747
  return configs;
1660
1748
  }
1749
+ /**
1750
+ * Initializes the target property on `this` when a config is skipped before loading.
1751
+ * Entity configs receive `[]`; `single_property` dataset configs receive `{}` only when
1752
+ * the subclass has not already initialized the property to a default container.
1753
+ */
1754
+ initializeSkippedConfigProperty(config) {
1755
+ if (config.AddToObject === false) {
1756
+ return;
1757
+ }
1758
+ if (config.Type === 'dataset') {
1759
+ if (config.DatasetResultHandling === 'single_property' &&
1760
+ this[config.PropertyName] == null) {
1761
+ this[config.PropertyName] = {};
1762
+ }
1763
+ }
1764
+ else {
1765
+ this[config.PropertyName] = [];
1766
+ }
1767
+ }
1768
+ /**
1769
+ * Returns the entity name(s) governed by `config`: `Entities` (or `EntityName`) for
1770
+ * dataset configs, and `EntityName` for entity configs.
1771
+ */
1772
+ configEntityNames(config) {
1773
+ if (config.Type === 'dataset') {
1774
+ if (config.Entities && config.Entities.length > 0) {
1775
+ return config.Entities;
1776
+ }
1777
+ return config.EntityName ? [config.EntityName] : [];
1778
+ }
1779
+ return config.EntityName ? [config.EntityName] : [];
1780
+ }
1661
1781
  /**
1662
1782
  * Returns entity names among `configs` that do not exist in the loaded metadata provider.
1663
1783
  * Only checks when the metadata provider actually has entities loaded (`md.Entities.length > 0`)
@@ -1670,10 +1790,10 @@ export class BaseEngine extends BaseSingleton {
1670
1790
  }
1671
1791
  const missing = [];
1672
1792
  for (const config of configs) {
1673
- if (!config.EntityName)
1674
- continue;
1675
- if (!md.EntityByName(config.EntityName)) {
1676
- missing.push(config.EntityName);
1793
+ for (const entityName of this.configEntityNames(config)) {
1794
+ if (!md.EntityByName(entityName) && !missing.includes(entityName)) {
1795
+ missing.push(entityName);
1796
+ }
1677
1797
  }
1678
1798
  }
1679
1799
  return missing;
@@ -1683,14 +1803,14 @@ export class BaseEngine extends BaseSingleton {
1683
1803
  const md = this.ProviderToUse;
1684
1804
  const denied = [];
1685
1805
  for (const config of configs) {
1686
- if (!config.EntityName)
1687
- continue;
1688
- const entityInfo = md.EntityByName(config.EntityName);
1689
- if (!entityInfo)
1690
- continue; // Entity not in metadata — let RunView handle it
1691
- const perms = entityInfo.GetUserPermisions(user);
1692
- if (!perms || !perms.CanRead) {
1693
- denied.push(config.EntityName);
1806
+ for (const entityName of this.configEntityNames(config)) {
1807
+ const entityInfo = md.EntityByName(entityName);
1808
+ if (!entityInfo)
1809
+ continue; // Entity not in metadata — let RunView/dataset handle it
1810
+ const perms = entityInfo.GetUserPermisions(user);
1811
+ if ((!perms || !perms.CanRead) && !denied.includes(entityName)) {
1812
+ denied.push(entityName);
1813
+ }
1694
1814
  }
1695
1815
  }
1696
1816
  return denied;
@@ -1726,6 +1846,29 @@ export class BaseEngine extends BaseSingleton {
1726
1846
  isLatestConfigRefresh(propertyName, generation) {
1727
1847
  return this._configRefreshGeneration.get(propertyName) === generation;
1728
1848
  }
1849
+ /**
1850
+ * True when a refresh holding `generation` may apply its result: it is the latest started, or
1851
+ * its read succeeded and nothing newer has committed rows yet. A superseded refresh that
1852
+ * commits first is overwritten when the newer one lands, so the property never goes
1853
+ * backwards, and the superseded refresh's caller (a forced `Load`, `RefreshItem`) still
1854
+ * returns with rows at least as new as its read. Dropping them instead would leave the
1855
+ * property on its previous rows until the newer refresh landed, which can be after that
1856
+ * caller has returned, or never when that refresh fails. A superseded refresh whose read
1857
+ * failed applies nothing: the newer one owns the property's load state.
1858
+ */
1859
+ mayCommitConfigRefresh(propertyName, generation, succeeded) {
1860
+ return this.isLatestConfigRefresh(propertyName, generation)
1861
+ || (succeeded && (this._configCommittedGeneration.get(propertyName) ?? 0) < generation);
1862
+ }
1863
+ /**
1864
+ * Records that the refresh holding `generation` committed `propertyName`'s rows. Call it only
1865
+ * for a successful read: a failed one leaves the held rows as they were.
1866
+ */
1867
+ markConfigRefreshCommitted(propertyName, generation) {
1868
+ if ((this._configCommittedGeneration.get(propertyName) ?? 0) < generation) {
1869
+ this._configCommittedGeneration.set(propertyName, generation);
1870
+ }
1871
+ }
1729
1872
  /**
1730
1873
  * Resolves {@link BaseEnginePropertyConfig.IncludeBinaryFields} for this engine's provider:
1731
1874
  * `'DatabaseProviderOnly'` becomes true on a database provider and false on any other.
@@ -1777,17 +1920,28 @@ export class BaseEngine extends BaseSingleton {
1777
1920
  // UserInfoEngine's per-user '_UserApplications') apply out of initiation-order and the
1778
1921
  // cache ends up "one operation behind". See _configRefreshGeneration.
1779
1922
  const generation = this.beginConfigRefresh(config.PropertyName);
1780
- const p = this.RunViewProviderToUse;
1781
- const rv = new RunView(p);
1782
- const result = await rv.RunView(this.BuildRunViewParamsForConfig(config, bypassCache), contextUser);
1783
- // A newer full refresh superseded us while we awaited — drop this (staler) snapshot
1784
- // rather than clobber the newer one. The newer refresh owns the assignment, the
1785
- // observer notification, and (on failure) the retry, so bailing here leaves no
1786
- // observer un-notified. The single-refresh path is unaffected: its generation is
1787
- // still the latest when its RunView returns.
1788
- if (!this.isLatestConfigRefresh(config.PropertyName, generation)) {
1923
+ this.incrementInFlightFullRefresh(config.PropertyName);
1924
+ let result;
1925
+ try {
1926
+ const p = this.RunViewProviderToUse;
1927
+ const rv = new RunView(p);
1928
+ result = await rv.RunView(this.BuildRunViewParamsForConfig(config, bypassCache), contextUser);
1929
+ }
1930
+ finally {
1931
+ this.decrementInFlightFullRefresh(config.PropertyName);
1932
+ }
1933
+ // A newer full refresh superseded us while we awaited. When it has already committed rows,
1934
+ // or our read failed, drop this result rather than clobber the newer one: the newer
1935
+ // refresh owns the assignment, the observer notification, and (on failure) the retry, so
1936
+ // bailing here leaves no observer un-notified. When our read succeeded and the newer one
1937
+ // hasn't committed yet, ours commits and the newer one overwrites it when it lands (see
1938
+ // mayCommitConfigRefresh).
1939
+ if (!this.mayCommitConfigRefresh(config.PropertyName, generation, result.Success)) {
1789
1940
  return;
1790
1941
  }
1942
+ if (result.Success) {
1943
+ this.markConfigRefreshCommitted(config.PropertyName, generation);
1944
+ }
1791
1945
  this.HandleSingleViewResult(config, result, contextUser);
1792
1946
  if (!suppressEmit) {
1793
1947
  // A caller that rebuilds derived state afterwards emits itself, once, AFTER the
@@ -1884,6 +2038,35 @@ export class BaseEngine extends BaseSingleton {
1884
2038
  this._dataMap.set(config.PropertyName, { entityName: config.EntityName, data: [], loadedSuccessfully: true, readDenied: true });
1885
2039
  this.NotifyDataChange(config, []);
1886
2040
  }
2041
+ /**
2042
+ * Returns true when a failed dataset load was caused by the current user lacking Read
2043
+ * permission on one of the dataset's constituent entities — either per local metadata
2044
+ * (`config.Entities` / `config.EntityName`) or per the server's dataset status message
2045
+ * (`does not have read permissions on ...`).
2046
+ */
2047
+ IsDatasetReadDenied(config, result, contextUser) {
2048
+ for (const entityName of this.configEntityNames(config)) {
2049
+ if (!this.ContextUserCanReadConfigEntity(entityName, contextUser)) {
2050
+ return true;
2051
+ }
2052
+ }
2053
+ return typeof result.Status === 'string' && /does not have read permissions? on/i.test(result.Status);
2054
+ }
2055
+ /**
2056
+ * Records a dataset config as successfully loaded in an empty state after a permanent
2057
+ * permission denial, so `BaseEngine.Load` marks the engine loaded and does not loop on
2058
+ * `EnsureLoaded()` / `Config()`.
2059
+ */
2060
+ MarkDatasetConfigEmptyLoaded(config) {
2061
+ this.initializeSkippedConfigProperty(config);
2062
+ this._dataMap.set(config.PropertyName, {
2063
+ datasetName: config.DatasetName,
2064
+ data: [],
2065
+ loadedSuccessfully: true,
2066
+ readDenied: true,
2067
+ });
2068
+ this.NotifyDataChange(config, []);
2069
+ }
1887
2070
  /**
1888
2071
  * Handles the process of loading multiple entity configs in a single network call via RunViews()
1889
2072
  * @param configs
@@ -1892,13 +2075,36 @@ export class BaseEngine extends BaseSingleton {
1892
2075
  */
1893
2076
  async LoadMultipleEntityConfigs(configs, contextUser, bypassCache = false) {
1894
2077
  if (configs && configs.length > 0) {
1895
- const p = this.RunViewProviderToUse;
1896
- const rv = new RunView(p);
1897
- const viewConfigs = configs.map(c => this.BuildRunViewParamsForConfig(c, bypassCache));
1898
- const results = await rv.RunViews(viewConfigs, contextUser);
2078
+ // Same protocol as LoadSingleEntityConfig, per property: claim a refresh generation and
2079
+ // mark a full refresh in flight BEFORE the awaited RunViews. Without it a `row_delta`
2080
+ // that arrives mid-flight is applied to the held array and then overwritten by this
2081
+ // (pre-delta) snapshot when the batch lands; with it the delta path defers to a
2082
+ // coalesced reload instead, and a newer per-property refresh supersedes our slot.
2083
+ const generations = configs.map(c => this.beginConfigRefresh(c.PropertyName));
2084
+ for (const c of configs) {
2085
+ this.incrementInFlightFullRefresh(c.PropertyName);
2086
+ }
2087
+ let results;
2088
+ try {
2089
+ const p = this.RunViewProviderToUse;
2090
+ const rv = new RunView(p);
2091
+ const viewConfigs = configs.map(c => this.BuildRunViewParamsForConfig(c, bypassCache));
2092
+ results = await rv.RunViews(viewConfigs, contextUser);
2093
+ }
2094
+ finally {
2095
+ for (const c of configs) {
2096
+ this.decrementInFlightFullRefresh(c.PropertyName);
2097
+ }
2098
+ }
1899
2099
  // Process results and record entity loads for redundancy detection
1900
2100
  const entityNames = [];
1901
2101
  for (let i = 0; i < configs.length; i++) {
2102
+ if (!this.mayCommitConfigRefresh(configs[i].PropertyName, generations[i], results[i]?.Success === true)) {
2103
+ continue; // a newer refresh of this property owns it
2104
+ }
2105
+ if (results[i]?.Success === true) {
2106
+ this.markConfigRefreshCommitted(configs[i].PropertyName, generations[i]);
2107
+ }
1902
2108
  this.HandleSingleViewResult(configs[i], results[i], contextUser);
1903
2109
  this.emitPropertyChange(configs[i].PropertyName);
1904
2110
  if (configs[i].EntityName) {
@@ -1981,7 +2187,9 @@ export class BaseEngine extends BaseSingleton {
1981
2187
  let result;
1982
2188
  if (bypassCache) {
1983
2189
  result = await p.GetDatasetByName(config.DatasetName, config.DatasetItemFilters, contextUser, undefined, true);
1984
- await p.CacheDataset(config.DatasetName, config.DatasetItemFilters, result);
2190
+ if (result?.Success) {
2191
+ await p.CacheDataset(config.DatasetName, config.DatasetItemFilters, result);
2192
+ }
1985
2193
  }
1986
2194
  else {
1987
2195
  result = await p.GetAndCacheDatasetByName(config.DatasetName, config.DatasetItemFilters, contextUser);
@@ -1998,6 +2206,11 @@ export class BaseEngine extends BaseSingleton {
1998
2206
  return;
1999
2207
  }
2000
2208
  if (!result.Success) {
2209
+ if (this.IsDatasetReadDenied(config, result, contextUser)) {
2210
+ this.MarkDatasetConfigEmptyLoaded(config);
2211
+ LogWarning(`BaseEngine: Dataset ${config.DatasetName} not readable by current role — loaded empty (restricted-role degradation).`, 'Cache');
2212
+ return;
2213
+ }
2001
2214
  const errorMsg = result.Status || `Dataset "${config.DatasetName}" load returned Success=false`;
2002
2215
  LogError(`BaseEngine: Failed to load dataset ${config.DatasetName} into ${config.PropertyName}: ${errorMsg}`);
2003
2216
  this._dataMap.set(config.PropertyName, {
@@ -2139,18 +2352,26 @@ export class BaseEngine extends BaseSingleton {
2139
2352
  // overlapping cache events (or an event racing a full reload) can resolve out
2140
2353
  // of order and the stale result would be the one that assigns last.
2141
2354
  const generation = this.beginConfigRefresh(config.PropertyName);
2142
- // A burst of payloads for one slot usually arrives together. Let the rest of the
2143
- // burst claim its generations first, so only the newest payload is materialized
2144
- // instead of every one of them (receive side).
2145
- await yieldToEventLoop();
2146
- if (!this.isLatestConfigRefresh(config.PropertyName, generation)) {
2147
- return;
2355
+ this.incrementInFlightFullRefresh(config.PropertyName);
2356
+ let rows = null;
2357
+ try {
2358
+ // A burst of payloads for one slot usually arrives together. Let the rest of the
2359
+ // burst claim its generations first, so only the newest payload is materialized
2360
+ // instead of every one of them (receive side).
2361
+ await yieldToEventLoop();
2362
+ if (!this.isLatestConfigRefresh(config.PropertyName, generation)) {
2363
+ return;
2364
+ }
2365
+ rows = await this.materializeCacheEventRows(config, parsed.results);
2366
+ }
2367
+ finally {
2368
+ this.decrementInFlightFullRefresh(config.PropertyName);
2148
2369
  }
2149
- const rows = await this.materializeCacheEventRows(config, parsed.results);
2150
2370
  if (!this.isLatestConfigRefresh(config.PropertyName, generation)) {
2151
2371
  return; // superseded while materializing — the newer refresh owns the property
2152
2372
  }
2153
2373
  if (rows) {
2374
+ this.markConfigRefreshCommitted(config.PropertyName, generation);
2154
2375
  this.HandleSingleViewResult(config, {
2155
2376
  Success: true,
2156
2377
  Results: rows,
@@ -2186,8 +2407,270 @@ export class BaseEngine extends BaseSingleton {
2186
2407
  LogWarning(`BaseEngine.OnExternalCacheChange: payload for '${config.PropertyName}' could not be applied (${e instanceof Error ? e.message : String(e)}) — falling back to full reload`, 'Cache');
2187
2408
  }
2188
2409
  }
2410
+ else if (event.Data && event.Action === 'row_delta') {
2411
+ const outcome = await this.applyExternalRowDelta(config, event.Data);
2412
+ if (outcome === 'applied' || outcome === 'superseded') {
2413
+ return;
2414
+ }
2415
+ }
2189
2416
  await this.reloadAfterExternalChange(config);
2190
2417
  }
2418
+ incrementInFlightFullRefresh(propertyName) {
2419
+ this._inFlightFullRefreshCount.set(propertyName, (this._inFlightFullRefreshCount.get(propertyName) ?? 0) + 1);
2420
+ }
2421
+ decrementInFlightFullRefresh(propertyName) {
2422
+ const next = (this._inFlightFullRefreshCount.get(propertyName) ?? 1) - 1;
2423
+ if (next <= 0) {
2424
+ this._inFlightFullRefreshCount.delete(propertyName);
2425
+ }
2426
+ else {
2427
+ this._inFlightFullRefreshCount.set(propertyName, next);
2428
+ }
2429
+ }
2430
+ /**
2431
+ * Applies a compact `row_delta` pub/sub event in $O(\Delta)$ without re-materializing
2432
+ * untouched sibling rows. Falls back to `'reload'` whenever schema hash, ordering, or
2433
+ * row-count invariants indicate the engine cannot prove local delta equivalence.
2434
+ */
2435
+ async applyExternalRowDelta(config, rawDeltaJson) {
2436
+ let delta;
2437
+ try {
2438
+ delta = JSON.parse(rawDeltaJson);
2439
+ }
2440
+ catch (e) {
2441
+ LogWarning(`BaseEngine.OnExternalCacheChange: row_delta payload for '${config.PropertyName}' could not be parsed (${e instanceof Error ? e.message : String(e)}) — falling back to reload`, 'Cache');
2442
+ return 'reload';
2443
+ }
2444
+ if (!delta ||
2445
+ !Array.isArray(delta.upsertedRows) ||
2446
+ !Array.isArray(delta.deletedPrimaryKeys) ||
2447
+ !Array.isArray(delta.primaryKeyFields) ||
2448
+ delta.primaryKeyFields.length === 0 ||
2449
+ typeof delta.rowCount !== 'number') {
2450
+ return 'reload';
2451
+ }
2452
+ if (this.payloadSchemaDiffers(config, delta.schemaHash)) {
2453
+ LogStatusEx({ message: `BaseEngine.OnExternalCacheChange: row_delta for '${config.PropertyName}' was written under a different schema — reloading instead`, verboseOnly: true });
2454
+ return 'reload';
2455
+ }
2456
+ // Ordered configs rely on database collation ordering; reload from the warm Redis slot (0 SQL).
2457
+ if (config.OrderBy && delta.upsertedRows.length > 0) {
2458
+ return 'reload';
2459
+ }
2460
+ let stepOutcome = 'reload';
2461
+ const priorQueue = this._rowDeltaQueues.get(config.PropertyName) ?? Promise.resolve();
2462
+ const currentStep = priorQueue.then(async () => {
2463
+ try {
2464
+ stepOutcome = await this.applyExternalRowDeltaSerialized(config, delta);
2465
+ }
2466
+ catch (e) {
2467
+ LogWarning(`BaseEngine.OnExternalCacheChange: row_delta application for '${config.PropertyName}' failed (${e instanceof Error ? e.message : String(e)}) — falling back to reload`, 'Cache');
2468
+ stepOutcome = 'reload';
2469
+ }
2470
+ });
2471
+ const settled = currentStep.catch(() => undefined);
2472
+ this._rowDeltaQueues.set(config.PropertyName, settled);
2473
+ await currentStep;
2474
+ if (this._rowDeltaQueues.get(config.PropertyName) === settled) {
2475
+ this._rowDeltaQueues.delete(config.PropertyName);
2476
+ }
2477
+ return stepOutcome;
2478
+ }
2479
+ async applyExternalRowDeltaSerialized(config, delta) {
2480
+ if ((this._inFlightFullRefreshCount.get(config.PropertyName) ?? 0) > 0) {
2481
+ return 'reload';
2482
+ }
2483
+ const heldRows = this.getHeldRowsForDelta(config);
2484
+ if (!heldRows) {
2485
+ return 'reload';
2486
+ }
2487
+ const entityInfo = config.EntityName ? this.ProviderToUse?.EntityByName?.(config.EntityName) : undefined;
2488
+ const pkFields = delta.primaryKeyFields;
2489
+ const isUuidPk = pkFields.map(fieldName => Boolean(entityInfo?.FieldByName(fieldName)?.IsUniqueIdentifier));
2490
+ const heldIndexByKey = this.indexHeldRowsForDelta(heldRows, pkFields, isUuidPk);
2491
+ if (!heldIndexByKey) {
2492
+ return 'reload';
2493
+ }
2494
+ const keyPlan = this.planRowDeltaKeys(delta, heldRows.length, heldIndexByKey, pkFields, isUuidPk);
2495
+ if (!keyPlan) {
2496
+ return 'reload';
2497
+ }
2498
+ // Echo / duplicate delta suppression: when no rows are added or removed and every
2499
+ // upserted row's __mj_UpdatedAt matches the held row, skip materialization & emit churn.
2500
+ if (keyPlan.matchedDeleteKeys.size === 0 &&
2501
+ keyPlan.netNewInserts === 0 &&
2502
+ this.upsertsMatchHeldTimestamps(heldRows, heldIndexByKey, delta.upsertedRows, keyPlan.upsertKeys, entityInfo)) {
2503
+ LogStatusEx({ message: `BaseEngine.OnExternalCacheChange: '${config.PropertyName}' already holds row_delta versions — skipped`, verboseOnly: true });
2504
+ return 'applied';
2505
+ }
2506
+ const generationBefore = this._configRefreshGeneration.get(config.PropertyName) ?? 0;
2507
+ const materializedUpserts = delta.upsertedRows.length > 0
2508
+ ? await this.materializeCacheEventRows(config, delta.upsertedRows)
2509
+ : [];
2510
+ if (materializedUpserts === null) {
2511
+ return 'reload';
2512
+ }
2513
+ if ((this._configRefreshGeneration.get(config.PropertyName) ?? 0) !== generationBefore ||
2514
+ (this._inFlightFullRefreshCount.get(config.PropertyName) ?? 0) > 0) {
2515
+ return 'superseded';
2516
+ }
2517
+ const nextRows = this.mergeMaterializedDeltaRows(heldRows, pkFields, isUuidPk, keyPlan.matchedDeleteKeys, keyPlan.upsertKeys, materializedUpserts);
2518
+ this.HandleSingleViewResult(config, {
2519
+ Success: true,
2520
+ Results: nextRows,
2521
+ RowCount: nextRows.length,
2522
+ TotalRowCount: delta.totalRowCount ?? nextRows.length,
2523
+ ExecutionTime: 0,
2524
+ ErrorMessage: '',
2525
+ UserViewRunID: '',
2526
+ });
2527
+ await this.RebuildDerivedState(this._contextUser);
2528
+ this.emitPropertyChange(config.PropertyName);
2529
+ return 'applied';
2530
+ }
2531
+ indexHeldRowsForDelta(heldRows, pkFields, isUuidPk) {
2532
+ const heldIndexByKey = new Map();
2533
+ for (let i = 0; i < heldRows.length; i++) {
2534
+ const key = this.canonicalHeldRowPkKey(heldRows[i], pkFields, isUuidPk);
2535
+ if (key === null) {
2536
+ return null;
2537
+ }
2538
+ heldIndexByKey.set(key, i);
2539
+ }
2540
+ return heldIndexByKey.size === heldRows.length ? heldIndexByKey : null;
2541
+ }
2542
+ planRowDeltaKeys(delta, heldRowCount, heldIndexByKey, pkFields, isUuidPk) {
2543
+ const matchedDeleteKeys = new Set();
2544
+ for (const delPk of delta.deletedPrimaryKeys) {
2545
+ const key = this.canonicalPlainRecordPkKey(delPk, pkFields, isUuidPk);
2546
+ if (key === null) {
2547
+ return null;
2548
+ }
2549
+ if (heldIndexByKey.has(key)) {
2550
+ matchedDeleteKeys.add(key);
2551
+ }
2552
+ }
2553
+ const upsertKeys = [];
2554
+ const seenUpsertKeys = new Set();
2555
+ let netNewInserts = 0;
2556
+ for (const upRow of delta.upsertedRows) {
2557
+ const key = this.canonicalPlainRecordPkKey(upRow, pkFields, isUuidPk);
2558
+ if (key === null || seenUpsertKeys.has(key)) {
2559
+ return null;
2560
+ }
2561
+ seenUpsertKeys.add(key);
2562
+ upsertKeys.push(key);
2563
+ if (!heldIndexByKey.has(key) || matchedDeleteKeys.has(key)) {
2564
+ netNewInserts++;
2565
+ }
2566
+ }
2567
+ // Strict cardinality verification: detects uninitialized state, missed pub/sub messages,
2568
+ // or filtered-slot membership divergence and falls back to reading the warm cache slot.
2569
+ const expectedNextLength = heldRowCount - matchedDeleteKeys.size + netNewInserts;
2570
+ if (expectedNextLength !== delta.rowCount) {
2571
+ return null;
2572
+ }
2573
+ return { matchedDeleteKeys, upsertKeys, netNewInserts };
2574
+ }
2575
+ mergeMaterializedDeltaRows(heldRows, pkFields, isUuidPk, matchedDeleteKeys, upsertKeys, materializedUpserts) {
2576
+ const upsertByKey = new Map();
2577
+ for (let i = 0; i < upsertKeys.length; i++) {
2578
+ upsertByKey.set(upsertKeys[i], materializedUpserts[i]);
2579
+ }
2580
+ const consumedUpsertKeys = new Set();
2581
+ const nextRows = [];
2582
+ for (let i = 0; i < heldRows.length; i++) {
2583
+ const existingRow = heldRows[i];
2584
+ const key = this.canonicalHeldRowPkKey(existingRow, pkFields, isUuidPk);
2585
+ if (upsertByKey.has(key)) {
2586
+ nextRows.push(upsertByKey.get(key));
2587
+ consumedUpsertKeys.add(key);
2588
+ }
2589
+ else if (!matchedDeleteKeys.has(key)) {
2590
+ nextRows.push(existingRow);
2591
+ }
2592
+ }
2593
+ for (let i = 0; i < upsertKeys.length; i++) {
2594
+ const key = upsertKeys[i];
2595
+ if (!consumedUpsertKeys.has(key)) {
2596
+ nextRows.push(upsertByKey.get(key));
2597
+ }
2598
+ }
2599
+ return nextRows;
2600
+ }
2601
+ getHeldRowsForDelta(config) {
2602
+ const entry = this._dataMap.get(config.PropertyName);
2603
+ if (entry && entry.loadedSuccessfully === false) {
2604
+ return null;
2605
+ }
2606
+ if (entry?.loadedSuccessfully && Array.isArray(entry.data)) {
2607
+ return entry.data;
2608
+ }
2609
+ const propVal = Reflect.get(this, config.PropertyName);
2610
+ if (Array.isArray(propVal) && (propVal.length > 0 || Boolean(entry))) {
2611
+ return propVal;
2612
+ }
2613
+ return null;
2614
+ }
2615
+ readHeldRowField(row, fieldName) {
2616
+ if (row instanceof BaseEntity) {
2617
+ return row.Get(fieldName);
2618
+ }
2619
+ if (fieldName in row) {
2620
+ return row[fieldName];
2621
+ }
2622
+ const nestedData = row['data'];
2623
+ if (typeof nestedData === 'object' && nestedData !== null && fieldName in nestedData) {
2624
+ return nestedData[fieldName];
2625
+ }
2626
+ return undefined;
2627
+ }
2628
+ canonicalHeldRowPkKey(row, pkFields, isUuidPk) {
2629
+ const parts = [];
2630
+ for (let i = 0; i < pkFields.length; i++) {
2631
+ const val = this.readHeldRowField(row, pkFields[i]);
2632
+ if (val === null || val === undefined) {
2633
+ return null;
2634
+ }
2635
+ const str = String(val);
2636
+ parts.push(CanonicalPrimaryKeyValue(str, isUuidPk[i]));
2637
+ }
2638
+ return parts.join('\u001f');
2639
+ }
2640
+ canonicalPlainRecordPkKey(record, pkFields, isUuidPk) {
2641
+ const parts = [];
2642
+ for (let i = 0; i < pkFields.length; i++) {
2643
+ const val = record[pkFields[i]];
2644
+ if (val === null || val === undefined) {
2645
+ return null;
2646
+ }
2647
+ const str = String(val);
2648
+ parts.push(CanonicalPrimaryKeyValue(str, isUuidPk[i]));
2649
+ }
2650
+ return parts.join('\u001f');
2651
+ }
2652
+ upsertsMatchHeldTimestamps(heldRows, heldIndexByKey, upsertedRows, upsertKeys, entityInfo) {
2653
+ if (!entityInfo || !entityInfo.Fields.some(f => f.IsUpdatedAtField)) {
2654
+ return false;
2655
+ }
2656
+ for (let i = 0; i < upsertedRows.length; i++) {
2657
+ const heldIdx = heldIndexByKey.get(upsertKeys[i]);
2658
+ if (heldIdx === undefined) {
2659
+ return false;
2660
+ }
2661
+ const heldRaw = this.readHeldRowField(heldRows[heldIdx], EntityInfo.UpdatedAtFieldName);
2662
+ const incomingRaw = upsertedRows[i][EntityInfo.UpdatedAtFieldName];
2663
+ if (heldRaw == null || incomingRaw == null) {
2664
+ return false;
2665
+ }
2666
+ const heldMs = heldRaw instanceof Date ? heldRaw.getTime() : new Date(String(heldRaw)).getTime();
2667
+ const incomingMs = incomingRaw instanceof Date ? incomingRaw.getTime() : new Date(String(incomingRaw)).getTime();
2668
+ if (Number.isNaN(heldMs) || Number.isNaN(incomingMs) || heldMs !== incomingMs) {
2669
+ return false;
2670
+ }
2671
+ }
2672
+ return true;
2673
+ }
2191
2674
  /**
2192
2675
  * True when a cache-change payload carries a schema hash and it differs from this engine's
2193
2676
  * hash for the config's entity. A payload without a hash, or an entity this process cannot
@@ -2201,40 +2684,81 @@ export class BaseEngine extends BaseSingleton {
2201
2684
  return ownHash !== undefined && ownHash !== payloadSchemaHash;
2202
2685
  }
2203
2686
  /**
2204
- * Reloads a config after a peer's cache change, coalescing overlapping requests: an event that
2205
- * arrives while a reload for the same property is running asks for one more reload after it
2206
- * instead of starting its own. Every caller's promise settles only after a reload that began
2687
+ * Reloads a config after a change made elsewhere, coalescing overlapping requests: an event
2688
+ * that arrives while a reload for the same property is running asks for one more reload after
2689
+ * it instead of starting its own. Every caller's promise settles only after a reload that began
2207
2690
  * after its event, so no change is missed, but N events in a burst cost at most two reloads.
2691
+ * Successive reloads of one property are at least {@link ExternalChangeFollowUpDelayMs} apart.
2692
+ * Requests for different properties never share a reload.
2693
+ *
2694
+ * @param bypassCache - Read past the cache. When any request folded into a reload asks for
2695
+ * it, that reload bypasses the cache.
2208
2696
  */
2209
- reloadAfterExternalChange(config) {
2697
+ reloadAfterExternalChange(config, bypassCache = false) {
2210
2698
  const running = this._externalChangeReloads.get(config.PropertyName);
2211
2699
  if (running) {
2212
2700
  running.Again = true;
2701
+ running.BypassCache = running.BypassCache || bypassCache;
2213
2702
  return running.Done;
2214
2703
  }
2215
- const reload = { Again: false, Done: Promise.resolve() };
2704
+ const reload = { Again: false, BypassCache: bypassCache, Done: Promise.resolve() };
2216
2705
  this._externalChangeReloads.set(config.PropertyName, reload);
2217
2706
  reload.Done = this.runExternalChangeReloads(config, reload);
2218
2707
  return reload.Done;
2219
2708
  }
2220
- /** Runs reloads for {@link reloadAfterExternalChange} until no further event asked for one. */
2709
+ /**
2710
+ * Runs reloads for {@link reloadAfterExternalChange} until no further event asked for one.
2711
+ * A reload that throws does not end the loop while another is pending, so an event that
2712
+ * arrived during a failed reload is still served; the error surfaces only when the last
2713
+ * reload is the one that threw.
2714
+ */
2221
2715
  async runExternalChangeReloads(config, reload) {
2222
2716
  try {
2717
+ let lastError = null;
2223
2718
  do {
2719
+ const wait = this.reloadSpacingRemainingMs(config.PropertyName);
2720
+ if (wait > 0) {
2721
+ // Requests that arrive during the wait join the reload that follows it.
2722
+ await new Promise(resolve => setTimeout(resolve, wait));
2723
+ }
2724
+ const bypassCache = reload.BypassCache;
2224
2725
  reload.Again = false;
2225
- // The reload replaces the property, so the derived state has to be rebuilt too —
2226
- // and the reload must NOT emit on its own, or a subscriber wakes between the new
2227
- // rows and the rebuild and reads derived state still pointing at the instances
2228
- // this reload discarded.
2229
- await this.LoadSingleConfig(config, this._contextUser, /*bypassCache*/ false, /*suppressEmit*/ true);
2230
- await this.RebuildDerivedState(this._contextUser);
2231
- this.emitPropertyChange(config.PropertyName);
2726
+ reload.BypassCache = false;
2727
+ lastError = await this.runOneExternalChangeReload(config, bypassCache);
2728
+ this._externalChangeReloadEndedAt.set(config.PropertyName, Date.now());
2729
+ if (lastError && reload.Again) {
2730
+ LogError(lastError);
2731
+ }
2232
2732
  } while (reload.Again);
2733
+ if (lastError) {
2734
+ throw lastError;
2735
+ }
2233
2736
  }
2234
2737
  finally {
2235
2738
  this._externalChangeReloads.delete(config.PropertyName);
2236
2739
  }
2237
2740
  }
2741
+ /** How long the next reload of the property must still wait for {@link ExternalChangeFollowUpDelayMs}; 0 when it may start now. */
2742
+ reloadSpacingRemainingMs(propertyName) {
2743
+ const endedAt = this._externalChangeReloadEndedAt.get(propertyName);
2744
+ return endedAt === undefined ? 0 : Math.max(0, endedAt + BaseEngine.ExternalChangeFollowUpDelayMs - Date.now());
2745
+ }
2746
+ /** One reload for {@link runExternalChangeReloads}. Returns the error it threw, or null. */
2747
+ async runOneExternalChangeReload(config, bypassCache) {
2748
+ try {
2749
+ // The reload replaces the property, so the derived state has to be rebuilt too —
2750
+ // and the reload must NOT emit on its own, or a subscriber wakes between the new
2751
+ // rows and the rebuild and reads derived state still pointing at the instances
2752
+ // this reload discarded.
2753
+ await this.LoadSingleConfig(config, this._contextUser, bypassCache, /*suppressEmit*/ true);
2754
+ await this.RebuildDerivedState(this._contextUser);
2755
+ this.emitPropertyChange(config.PropertyName);
2756
+ return null;
2757
+ }
2758
+ catch (e) {
2759
+ return e instanceof Error ? e : new Error(String(e));
2760
+ }
2761
+ }
2238
2762
  /**
2239
2763
  * True when `payloadRows` are the same row versions this config currently holds: the same
2240
2764
  * number of rows, and the same multiset of (primary key, `__mj_UpdatedAt`) pairs.