@memberjunction/core 5.8.0 → 5.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/generic/baseEngine.d.ts +64 -1
  2. package/dist/generic/baseEngine.d.ts.map +1 -1
  3. package/dist/generic/baseEngine.js +225 -22
  4. package/dist/generic/baseEngine.js.map +1 -1
  5. package/dist/generic/baseEntity.d.ts +31 -3
  6. package/dist/generic/baseEntity.d.ts.map +1 -1
  7. package/dist/generic/baseEntity.js +46 -0
  8. package/dist/generic/baseEntity.js.map +1 -1
  9. package/dist/generic/hookRegistry.d.ts +83 -0
  10. package/dist/generic/hookRegistry.d.ts.map +1 -0
  11. package/dist/generic/hookRegistry.js +87 -0
  12. package/dist/generic/hookRegistry.js.map +1 -0
  13. package/dist/generic/interfaces.d.ts +16 -0
  14. package/dist/generic/interfaces.d.ts.map +1 -1
  15. package/dist/generic/interfaces.js.map +1 -1
  16. package/dist/generic/localCacheManager.d.ts +200 -19
  17. package/dist/generic/localCacheManager.d.ts.map +1 -1
  18. package/dist/generic/localCacheManager.js +489 -132
  19. package/dist/generic/localCacheManager.js.map +1 -1
  20. package/dist/generic/providerBase.d.ts +10 -0
  21. package/dist/generic/providerBase.d.ts.map +1 -1
  22. package/dist/generic/providerBase.js +55 -5
  23. package/dist/generic/providerBase.js.map +1 -1
  24. package/dist/generic/queryInfo.d.ts +9 -6
  25. package/dist/generic/queryInfo.d.ts.map +1 -1
  26. package/dist/generic/queryInfo.js +10 -10
  27. package/dist/generic/queryInfo.js.map +1 -1
  28. package/dist/generic/queryInfoInterfaces.d.ts +1 -1
  29. package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
  30. package/dist/generic/securityInfo.d.ts +41 -0
  31. package/dist/generic/securityInfo.d.ts.map +1 -1
  32. package/dist/generic/securityInfo.js +16 -0
  33. package/dist/generic/securityInfo.js.map +1 -1
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/views/runView.d.ts +33 -0
  39. package/dist/views/runView.d.ts.map +1 -1
  40. package/dist/views/runView.js.map +1 -1
  41. package/package.json +2 -2
  42. package/readme.md +58 -0
@@ -1,5 +1,8 @@
1
- import { BaseSingleton } from "@memberjunction/global";
2
- import { LogError } from "./logging.js";
1
+ import { BaseSingleton, MJGlobal, MJEventType } from "@memberjunction/global";
2
+ import { LogError, LogStatus } from "./logging.js";
3
+ import { BaseEntity } from "./baseEntity.js";
4
+ import { Metadata } from "./metadata.js";
5
+ import { CompositeKey, KeyValuePair } from "./compositeKey.js";
3
6
  // ============================================================================
4
7
  // DEFAULT CONFIGURATION
5
8
  // ============================================================================
@@ -72,6 +75,21 @@ export class LocalCacheManager extends BaseSingleton {
72
75
  this._stats = { hits: 0, misses: 0 };
73
76
  this._config = { ...DEFAULT_CONFIG };
74
77
  this.REGISTRY_KEY = '__MJ_CACHE_REGISTRY__';
78
+ /**
79
+ * Reverse index from entity name to the set of RunView cache fingerprints
80
+ * that contain data for that entity. Enables O(1) lookup when a BaseEntity
81
+ * event fires so we can update all relevant cached results.
82
+ */
83
+ this._entityFingerprintIndex = new Map();
84
+ // ========================================================================
85
+ // CROSS-SERVER CACHE CHANGE CALLBACKS
86
+ // ========================================================================
87
+ /**
88
+ * Map from cache fingerprint (or category for category_cleared events) to
89
+ * registered {@link CacheChangedEvent} callbacks. Callbacks are invoked when
90
+ * another server instance modifies the corresponding cached entry via Redis pub/sub.
91
+ */
92
+ this._changeCallbacks = new Map();
75
93
  this._persistTimeout = null;
76
94
  }
77
95
  // ========================================================================
@@ -112,6 +130,9 @@ export class LocalCacheManager extends BaseSingleton {
112
130
  }
113
131
  await this.loadRegistry();
114
132
  this._initialized = true;
133
+ // Subscribe to BaseEntity events for universal cache invalidation.
134
+ // When any entity is saved/deleted, update all cached RunView results for that entity.
135
+ this.subscribeToBaseEntityEvents();
115
136
  }
116
137
  /**
117
138
  * Returns whether the cache manager has been initialized
@@ -131,6 +152,407 @@ export class LocalCacheManager extends BaseSingleton {
131
152
  UpdateConfig(config) {
132
153
  this._config = { ...this._config, ...config };
133
154
  }
155
+ /**
156
+ * Replaces the storage provider after initialization. This is needed when
157
+ * the initial provider (e.g., in-memory) needs to be swapped for a
158
+ * persistent provider (e.g., Redis) that becomes available later.
159
+ *
160
+ * Migrates the in-memory registry to the new provider and rebuilds
161
+ * the entity→fingerprint reverse index.
162
+ *
163
+ * @param newProvider - The new storage provider to use
164
+ */
165
+ async SetStorageProvider(newProvider) {
166
+ if (!this._initialized) {
167
+ // Not yet initialized — just set the provider and return
168
+ this._storageProvider = newProvider;
169
+ return;
170
+ }
171
+ const oldProvider = this._storageProvider;
172
+ this._storageProvider = newProvider;
173
+ // Migrate existing cached data from old provider to new provider
174
+ const entries = this.GetAllEntries();
175
+ let migratedCount = 0;
176
+ for (const entry of entries) {
177
+ try {
178
+ const category = this.getCategoryForType(entry.type);
179
+ const data = await oldProvider?.GetItem(entry.key, category);
180
+ if (data) {
181
+ await newProvider.SetItem(entry.key, data, category);
182
+ migratedCount++;
183
+ }
184
+ }
185
+ catch (err) {
186
+ LogError(`LocalCacheManager.SetStorageProvider: Failed to migrate key "${entry.key}": ${err.message}`);
187
+ }
188
+ }
189
+ // Persist the registry to the new provider
190
+ await this.persistRegistry();
191
+ LogStatus(`LocalCacheManager.SetStorageProvider: Migrated ${migratedCount}/${entries.length} entries to new storage provider`);
192
+ }
193
+ // ========================================================================
194
+ // ENTITY → FINGERPRINT REVERSE INDEX
195
+ // ========================================================================
196
+ /**
197
+ * Extracts the entity name from a RunView fingerprint.
198
+ * Fingerprint format: `EntityName|Filter|OrderBy|ResultType|MaxRows|StartRow|AggHash[|Connection]`
199
+ * @param fingerprint - The RunView cache fingerprint
200
+ * @returns The entity name, or null if the fingerprint is malformed
201
+ */
202
+ extractEntityFromFingerprint(fingerprint) {
203
+ const pipeIndex = fingerprint.indexOf('|');
204
+ return pipeIndex > 0 ? fingerprint.substring(0, pipeIndex) : null;
205
+ }
206
+ /**
207
+ * Returns true if the fingerprint includes a non-trivial filter (not just '_' or empty).
208
+ * Unfiltered fingerprints can safely have records upserted in-place; filtered ones
209
+ * must be invalidated conservatively since the new data may not match the filter.
210
+ * @param fingerprint - The RunView cache fingerprint
211
+ */
212
+ isFilteredFingerprint(fingerprint) {
213
+ const parts = fingerprint.split('|');
214
+ return parts.length >= 2 && parts[1] !== '_' && parts[1] !== '';
215
+ }
216
+ /**
217
+ * Adds a fingerprint to the entity→fingerprint reverse index.
218
+ * Called when a RunView result is cached.
219
+ */
220
+ addToEntityIndex(fingerprint) {
221
+ const entity = this.extractEntityFromFingerprint(fingerprint);
222
+ if (!entity)
223
+ return;
224
+ if (!this._entityFingerprintIndex.has(entity)) {
225
+ this._entityFingerprintIndex.set(entity, new Set());
226
+ }
227
+ this._entityFingerprintIndex.get(entity).add(fingerprint);
228
+ }
229
+ /**
230
+ * Removes a fingerprint from the entity→fingerprint reverse index.
231
+ * Called when a RunView result is invalidated.
232
+ */
233
+ removeFromEntityIndex(fingerprint) {
234
+ const entity = this.extractEntityFromFingerprint(fingerprint);
235
+ if (!entity)
236
+ return;
237
+ const set = this._entityFingerprintIndex.get(entity);
238
+ if (set) {
239
+ set.delete(fingerprint);
240
+ if (set.size === 0) {
241
+ this._entityFingerprintIndex.delete(entity);
242
+ }
243
+ }
244
+ }
245
+ /**
246
+ * Returns the set of cached fingerprints for a given entity name.
247
+ * Useful for diagnostics and testing.
248
+ */
249
+ GetFingerprintsForEntity(entityName) {
250
+ return this._entityFingerprintIndex.get(entityName) ?? new Set();
251
+ }
252
+ // ========================================================================
253
+ // UNIVERSAL CACHE INVALIDATION (BaseEntity Events)
254
+ // ========================================================================
255
+ /**
256
+ * Subscribes to MJGlobal BaseEntity events to proactively update all cached
257
+ * RunView results when entities are saved or deleted. This ensures ALL cached
258
+ * data stays consistent, not just engine-managed data.
259
+ */
260
+ subscribeToBaseEntityEvents() {
261
+ LogStatus('LocalCacheManager: Subscribed to BaseEntity events for universal cache invalidation');
262
+ MJGlobal.Instance.GetEventListener(false).subscribe((mjEvent) => {
263
+ if (mjEvent.event !== MJEventType.ComponentEvent)
264
+ return;
265
+ if (mjEvent.eventCode !== BaseEntity.BaseEventCode)
266
+ return;
267
+ const entityEvent = mjEvent.args;
268
+ if (!entityEvent)
269
+ return;
270
+ // Handle remote-invalidate events with embedded record data
271
+ if (entityEvent.type === 'remote-invalidate') {
272
+ this.HandleRemoteInvalidateEvent(entityEvent).catch((err) => {
273
+ LogError(`LocalCacheManager.HandleRemoteInvalidateEvent error: ${err.message}`);
274
+ });
275
+ return;
276
+ }
277
+ // Only react to completed save and delete events
278
+ if (entityEvent.type !== 'save' && entityEvent.type !== 'delete')
279
+ return;
280
+ // Fire-and-forget to avoid blocking the save/delete operation
281
+ this.HandleBaseEntityEvent(entityEvent).catch((err) => {
282
+ LogError(`LocalCacheManager.HandleBaseEntityEvent error: ${err.message}`);
283
+ });
284
+ });
285
+ }
286
+ /**
287
+ * Handles a BaseEntity event by updating all cached RunView results for the
288
+ * affected entity. For unfiltered caches, updates the record in-place.
289
+ * For filtered caches, invalidates the cache entry (conservative approach
290
+ * since we can't verify filter match without re-querying).
291
+ *
292
+ * @param entityEvent - The BaseEntity event payload
293
+ */
294
+ async HandleBaseEntityEvent(entityEvent) {
295
+ const baseEntity = entityEvent.baseEntity;
296
+ if (!baseEntity?.EntityInfo?.Name)
297
+ return;
298
+ const entityName = baseEntity.EntityInfo.Name;
299
+ const fingerprints = this._entityFingerprintIndex.get(entityName);
300
+ if (!fingerprints || fingerprints.size === 0)
301
+ return;
302
+ const primaryKeys = baseEntity.EntityInfo.PrimaryKeys;
303
+ if (!primaryKeys || primaryKeys.length === 0)
304
+ return;
305
+ // Build a CompositeKey from the entity's primary key fields
306
+ const key = new CompositeKey();
307
+ key.LoadFromEntityInfoAndRecord(baseEntity.EntityInfo, baseEntity.GetAll());
308
+ if (key.KeyValuePairs.length === 0 || key.KeyValuePairs.some(kv => kv.Value == null))
309
+ return;
310
+ LogStatus(`LocalCacheManager: BaseEntity ${entityEvent.type} event for "${entityName}" PK=${key.ToConcatenatedString()}, updating ${fingerprints.size} cached fingerprint(s)`);
311
+ const fingerprintSnapshot = [...fingerprints];
312
+ const nowISO = new Date().toISOString();
313
+ for (const fingerprint of fingerprintSnapshot) {
314
+ try {
315
+ await this.processEntityEventForFingerprint(entityEvent.type, fingerprint, baseEntity, key, nowISO);
316
+ }
317
+ catch (err) {
318
+ LogError(`HandleBaseEntityEvent: failed to update fingerprint "${fingerprint}": ${err.message}`);
319
+ }
320
+ }
321
+ }
322
+ /**
323
+ * Handles remote-invalidate events that include recordData (the saved entity as JSON).
324
+ * Updates all cached RunView results for the entity without a server round-trip.
325
+ * For delete events or events without recordData, the cache entries are invalidated
326
+ * so the next RunView call will fetch fresh data from the server.
327
+ */
328
+ async HandleRemoteInvalidateEvent(entityEvent) {
329
+ const payload = entityEvent.payload;
330
+ const entityName = entityEvent.entityName;
331
+ if (!entityName)
332
+ return;
333
+ const fingerprints = this._entityFingerprintIndex.get(entityName);
334
+ if (!fingerprints || fingerprints.size === 0)
335
+ return;
336
+ const action = payload?.action;
337
+ // Look up entity metadata for PK field names
338
+ const md = new Metadata();
339
+ const entityInfo = md.Entities.find(e => e.Name === entityName);
340
+ if (!entityInfo) {
341
+ LogStatus(`LocalCacheManager: remote-invalidate — entity "${entityName}" not found in metadata, invalidating caches`);
342
+ for (const fp of [...fingerprints]) {
343
+ await this.InvalidateRunViewResult(fp);
344
+ }
345
+ return;
346
+ }
347
+ const primaryKeys = entityInfo.PrimaryKeys;
348
+ if (!primaryKeys || primaryKeys.length === 0) {
349
+ LogStatus(`LocalCacheManager: remote-invalidate — no PKs for "${entityName}", invalidating ${fingerprints.size} cached fingerprint(s)`);
350
+ for (const fp of [...fingerprints]) {
351
+ await this.InvalidateRunViewResult(fp);
352
+ }
353
+ return;
354
+ }
355
+ const nowISO = new Date().toISOString();
356
+ const fingerprintSnapshot = [...fingerprints];
357
+ // Handle delete: remove the record from all cached results
358
+ if (action === 'delete') {
359
+ const key = this.parseCompositeKeyFromJSON(payload?.primaryKeyValues);
360
+ if (!key) {
361
+ LogStatus(`LocalCacheManager: remote-invalidate (delete) — no PK values for "${entityName}", invalidating caches`);
362
+ for (const fp of fingerprintSnapshot) {
363
+ await this.InvalidateRunViewResult(fp);
364
+ }
365
+ return;
366
+ }
367
+ LogStatus(`LocalCacheManager: remote-invalidate (delete) for "${entityName}" PK=${key.ToConcatenatedString()}, removing from ${fingerprints.size} cached fingerprint(s)`);
368
+ for (const fingerprint of fingerprintSnapshot) {
369
+ try {
370
+ await this.RemoveSingleEntity(fingerprint, key, nowISO);
371
+ }
372
+ catch (err) {
373
+ LogError(`HandleRemoteInvalidateEvent: failed to remove from "${fingerprint}": ${err.message}`);
374
+ }
375
+ }
376
+ return;
377
+ }
378
+ // Handle save: upsert record data into cached results
379
+ if (action === 'save' && payload?.recordData) {
380
+ try {
381
+ const recordData = JSON.parse(payload.recordData);
382
+ // Build CompositeKey from record data using entity PK fields
383
+ const key = this.buildCompositeKeyFromRow(recordData, primaryKeys.map(pk => pk.Name));
384
+ if (key.KeyValuePairs.some(kv => kv.Value == null))
385
+ return;
386
+ LogStatus(`LocalCacheManager: remote-invalidate (save) for "${entityName}" PK=${key.ToConcatenatedString()}, updating ${fingerprints.size} cached fingerprint(s)`);
387
+ for (const fingerprint of fingerprintSnapshot) {
388
+ try {
389
+ if (!this.isFilteredFingerprint(fingerprint)) {
390
+ await this.UpsertSingleEntity(fingerprint, recordData, key, nowISO);
391
+ }
392
+ else {
393
+ await this.InvalidateRunViewResult(fingerprint);
394
+ }
395
+ }
396
+ catch (err) {
397
+ LogError(`HandleRemoteInvalidateEvent: failed to update "${fingerprint}": ${err.message}`);
398
+ }
399
+ }
400
+ }
401
+ catch (e) {
402
+ LogError(`HandleRemoteInvalidateEvent: failed to parse recordData for "${entityName}": ${e.message}`);
403
+ for (const fp of fingerprintSnapshot) {
404
+ await this.InvalidateRunViewResult(fp);
405
+ }
406
+ }
407
+ return;
408
+ }
409
+ // Fallback: no record data or unrecognized action — invalidate
410
+ LogStatus(`LocalCacheManager: remote-invalidate (${action || 'unknown'}) for "${entityName}", invalidating ${fingerprints.size} cached fingerprint(s)`);
411
+ for (const fp of fingerprintSnapshot) {
412
+ await this.InvalidateRunViewResult(fp);
413
+ }
414
+ }
415
+ /**
416
+ * Parses a JSON-encoded primaryKeyValues string (array of {FieldName, Value} pairs)
417
+ * into a CompositeKey. Returns null if parsing fails or the string is empty.
418
+ */
419
+ parseCompositeKeyFromJSON(primaryKeyValuesJSON) {
420
+ if (!primaryKeyValuesJSON)
421
+ return null;
422
+ try {
423
+ const pairs = JSON.parse(primaryKeyValuesJSON);
424
+ if (!pairs || pairs.length === 0)
425
+ return null;
426
+ return CompositeKey.FromKeyValuePairs(pairs.map(p => new KeyValuePair(p.FieldName, p.Value)));
427
+ }
428
+ catch {
429
+ return null;
430
+ }
431
+ }
432
+ /**
433
+ * Builds a CompositeKey from a plain row object using the specified PK field names.
434
+ */
435
+ buildCompositeKeyFromRow(row, pkFieldNames) {
436
+ const pairs = pkFieldNames.map(fn => new KeyValuePair(fn, row[fn]));
437
+ return CompositeKey.FromKeyValuePairs(pairs);
438
+ }
439
+ /**
440
+ * Processes a single fingerprint for a BaseEntity event.
441
+ * Decomposed from HandleBaseEntityEvent for clarity and testability.
442
+ */
443
+ async processEntityEventForFingerprint(eventType, fingerprint, baseEntity, key, nowISO) {
444
+ const keyStr = key.ToConcatenatedString();
445
+ if (eventType === 'delete') {
446
+ LogStatus(`LocalCacheManager: Removing entity ${keyStr} from cache "${fingerprint.substring(0, 60)}"`);
447
+ await this.RemoveSingleEntity(fingerprint, key, nowISO);
448
+ }
449
+ else if (!this.isFilteredFingerprint(fingerprint)) {
450
+ // Unfiltered cache: update the record in place
451
+ LogStatus(`LocalCacheManager: Upserting entity ${keyStr} in unfiltered cache "${fingerprint.substring(0, 60)}"`);
452
+ const entityData = baseEntity.GetAll();
453
+ await this.UpsertSingleEntity(fingerprint, entityData, key, nowISO);
454
+ }
455
+ else {
456
+ // Filtered cache: conservatively invalidate (can't verify filter match)
457
+ LogStatus(`LocalCacheManager: Invalidating filtered cache "${fingerprint.substring(0, 60)}"`);
458
+ await this.InvalidateRunViewResult(fingerprint);
459
+ }
460
+ }
461
+ /**
462
+ * Registers a callback that fires when a specific cache fingerprint is updated
463
+ * by another server instance. Returns an unsubscribe function to remove the callback.
464
+ *
465
+ * This is the mechanism that powers the `OnDataChanged` callback in {@link RunViewParams}.
466
+ * Engines, components, and other callers can use this to react to cross-server
467
+ * cache invalidation without polling.
468
+ *
469
+ * @param fingerprint - The cache key/fingerprint to watch. For RunView results,
470
+ * use {@link GenerateRunViewFingerprint} to build this.
471
+ * @param callback - Function invoked with the {@link CacheChangedEvent} when
472
+ * the fingerprint's cached data changes on another server.
473
+ * @returns A function that, when called, removes this specific callback registration.
474
+ *
475
+ * @example
476
+ * ```typescript
477
+ * const fingerprint = cache.GenerateRunViewFingerprint(params, connectionPrefix);
478
+ * const unsubscribe = cache.RegisterChangeCallback(fingerprint, (event) => {
479
+ * console.log(`Data changed for ${event.CacheKey}`);
480
+ * // Reload, re-render, etc.
481
+ * });
482
+ *
483
+ * // Later, on cleanup:
484
+ * unsubscribe();
485
+ * ```
486
+ */
487
+ RegisterChangeCallback(fingerprint, callback) {
488
+ if (!this._changeCallbacks.has(fingerprint)) {
489
+ this._changeCallbacks.set(fingerprint, new Set());
490
+ }
491
+ this._changeCallbacks.get(fingerprint).add(callback);
492
+ return () => {
493
+ const callbacks = this._changeCallbacks.get(fingerprint);
494
+ if (callbacks) {
495
+ callbacks.delete(callback);
496
+ if (callbacks.size === 0) {
497
+ this._changeCallbacks.delete(fingerprint);
498
+ }
499
+ }
500
+ };
501
+ }
502
+ /**
503
+ * Dispatches a cache change event to all registered callbacks for the affected
504
+ * fingerprint. Called by infrastructure code (e.g., {@link RedisLocalStorageProvider})
505
+ * when another server modifies a cached entry.
506
+ *
507
+ * For `category_cleared` events, dispatches to ALL registered callbacks whose
508
+ * fingerprints belong to the cleared category (matched by the event's CacheKey
509
+ * which contains the category name).
510
+ *
511
+ * Errors in individual callbacks are caught and logged via {@link LogError}
512
+ * to prevent one bad callback from blocking others.
513
+ *
514
+ * @param event - The cache change event to dispatch
515
+ */
516
+ DispatchCacheChange(event) {
517
+ const sourceShort = event.SourceServerId ? event.SourceServerId.substring(0, 8) : 'unknown';
518
+ LogStatus(`LocalCacheManager: DispatchCacheChange received — action="${event.Action}", key="${event.CacheKey}", source="${sourceShort}"`);
519
+ if (event.Action === 'category_cleared') {
520
+ // For category-level clearing, notify ALL registered callbacks
521
+ // since we can't know which fingerprints belong to which category
522
+ // without parsing them. This is a rare operation so the overhead is acceptable.
523
+ for (const [, callbacks] of this._changeCallbacks) {
524
+ for (const cb of callbacks) {
525
+ try {
526
+ cb(event);
527
+ }
528
+ catch (err) {
529
+ LogError(`OnDataChanged callback error for category_cleared "${event.CacheKey}": ${err.message}`);
530
+ }
531
+ }
532
+ }
533
+ }
534
+ else {
535
+ // For set/removed, dispatch only to callbacks for the specific fingerprint
536
+ const callbacks = this._changeCallbacks.get(event.CacheKey);
537
+ if (callbacks) {
538
+ for (const cb of callbacks) {
539
+ try {
540
+ cb(event);
541
+ }
542
+ catch (err) {
543
+ LogError(`OnDataChanged callback error for key "${event.CacheKey}": ${err.message}`);
544
+ }
545
+ }
546
+ }
547
+ }
548
+ }
549
+ /**
550
+ * Returns the number of fingerprints that have registered change callbacks.
551
+ * Useful for diagnostics and testing.
552
+ */
553
+ get ChangeCallbackCount() {
554
+ return this._changeCallbacks.size;
555
+ }
134
556
  // ========================================================================
135
557
  // DATASET CACHING
136
558
  // ========================================================================
@@ -376,6 +798,9 @@ export class LocalCacheManager extends BaseSingleton {
376
798
  maxUpdatedAt,
377
799
  rowCount: results.length // Registry still tracks this for display/stats, derived from actual results
378
800
  });
801
+ // Maintain entity→fingerprint reverse index for universal cache invalidation
802
+ this.addToEntityIndex(fingerprint);
803
+ LogStatus(`LocalCacheManager.SetRunViewResult: Cached ${results.length} rows for "${fingerprint.substring(0, 60)}" (${sizeBytes} bytes)`);
379
804
  }
380
805
  catch (e) {
381
806
  LogError(`LocalCacheManager.SetRunViewResult failed: ${e}`);
@@ -426,6 +851,8 @@ export class LocalCacheManager extends BaseSingleton {
426
851
  async InvalidateRunViewResult(fingerprint) {
427
852
  if (!this._storageProvider)
428
853
  return;
854
+ // Remove from entity→fingerprint index before removing the cache entry
855
+ this.removeFromEntityIndex(fingerprint);
429
856
  try {
430
857
  await this._storageProvider.Remove(fingerprint, CacheCategory.RunViewCache);
431
858
  this.unregisterEntry(fingerprint);
@@ -465,32 +892,25 @@ export class LocalCacheManager extends BaseSingleton {
465
892
  // No existing cache - can't apply differential, caller should do full fetch
466
893
  return null;
467
894
  }
468
- // Build a map of existing records by primary key for O(1) lookups
895
+ // Build a map of existing records by composite key string for O(1) lookups
896
+ const pkFieldNames = [primaryKeyFieldName];
469
897
  const resultMap = new Map();
470
898
  for (const row of cached.results) {
471
899
  const rowObj = row;
472
- const pkValue = this.extractPrimaryKeyString(rowObj, primaryKeyFieldName);
473
- if (pkValue) {
474
- resultMap.set(pkValue, row);
475
- }
900
+ const rowKey = this.buildCompositeKeyFromRow(rowObj, pkFieldNames);
901
+ resultMap.set(rowKey.ToConcatenatedString(), row);
476
902
  }
477
903
  // Apply deletions - remove records that have been deleted
478
904
  for (const deletedID of deletedRecordIDs) {
479
- // deletedID is in CompositeKey concatenated format: "Field1|Value1||Field2|Value2"
480
- // For single-field PKs, it's just "ID|abc123"
481
- // We need to extract just the value(s) to match against our map
482
- const pkValue = this.extractValueFromConcatenatedKey(deletedID, primaryKeyFieldName);
483
- if (pkValue) {
484
- resultMap.delete(pkValue);
485
- }
905
+ // deletedID is already in CompositeKey concatenated format: "Field1|Value1||Field2|Value2"
906
+ // Use it directly as the map key since ToConcatenatedString() produces the same format
907
+ resultMap.delete(deletedID);
486
908
  }
487
909
  // Apply updates/inserts - add or replace records
488
910
  for (const row of updatedRows) {
489
911
  const rowObj = row;
490
- const pkValue = this.extractPrimaryKeyString(rowObj, primaryKeyFieldName);
491
- if (pkValue) {
492
- resultMap.set(pkValue, row);
493
- }
912
+ const rowKey = this.buildCompositeKeyFromRow(rowObj, pkFieldNames);
913
+ resultMap.set(rowKey.ToConcatenatedString(), row);
494
914
  }
495
915
  // Convert map back to array
496
916
  const mergedResults = Array.from(resultMap.values());
@@ -524,54 +944,29 @@ export class LocalCacheManager extends BaseSingleton {
524
944
  * @param newMaxUpdatedAt - New maxUpdatedAt timestamp (from entity's __mj_UpdatedAt)
525
945
  * @returns true if cache was updated, false if cache not found or update failed
526
946
  */
527
- async UpsertSingleEntity(fingerprint, entityData, primaryKeyFieldName, newMaxUpdatedAt) {
947
+ async UpsertSingleEntity(fingerprint, entityData, key, newMaxUpdatedAt) {
528
948
  if (!this._storageProvider || !this._config.enabled)
529
949
  return false;
530
950
  try {
531
- // Get existing cached data
532
951
  const cached = await this.GetRunViewResult(fingerprint);
533
952
  if (!cached) {
534
- // No existing cache - nothing to update
535
- // The next RunView call will populate the cache
536
- return false;
537
- }
538
- // Get the primary key value from the entity
539
- const pkValue = this.extractPrimaryKeyString(entityData, primaryKeyFieldName);
540
- if (!pkValue) {
541
- LogError(`LocalCacheManager.UpsertSingleEntity: Could not extract primary key from entity data`);
953
+ LogStatus(`LocalCacheManager.UpsertSingleEntity: No cached data found for fingerprint "${fingerprint.substring(0, 60)}" — skipping (cache will be populated on next RunView)`);
542
954
  return false;
543
955
  }
544
- // Build a map of existing records by primary key
956
+ LogStatus(`LocalCacheManager.UpsertSingleEntity: Found cached data with ${cached.results.length} rows, updating...`);
957
+ const pkFieldNames = key.KeyValuePairs.map(kv => kv.FieldName);
958
+ const keyStr = key.ToConcatenatedString();
959
+ // Build a map of existing records by composite key string
545
960
  const resultMap = new Map();
546
961
  for (const row of cached.results) {
547
962
  const rowObj = row;
548
- const rowPkValue = this.extractPrimaryKeyString(rowObj, primaryKeyFieldName);
549
- if (rowPkValue) {
550
- resultMap.set(rowPkValue, row);
551
- }
963
+ const rowKey = this.buildCompositeKeyFromRow(rowObj, pkFieldNames);
964
+ resultMap.set(rowKey.ToConcatenatedString(), row);
552
965
  }
553
966
  // Upsert the entity (add or replace)
554
- resultMap.set(pkValue, entityData);
555
- // Convert map back to array
967
+ resultMap.set(keyStr, entityData);
556
968
  const updatedResults = Array.from(resultMap.values());
557
- // Store the updated cache - rowCount is derived from results.length
558
- const data = {
559
- results: updatedResults,
560
- maxUpdatedAt: newMaxUpdatedAt
561
- };
562
- const value = JSON.stringify(data);
563
- const sizeBytes = this.estimateSize(value);
564
- await this._storageProvider.SetItem(fingerprint, value, CacheCategory.RunViewCache);
565
- // Update registry entry with derived rowCount
566
- const existingEntry = this._registry.get(fingerprint);
567
- if (existingEntry) {
568
- existingEntry.maxUpdatedAt = newMaxUpdatedAt;
569
- existingEntry.rowCount = updatedResults.length;
570
- existingEntry.sizeBytes = sizeBytes;
571
- existingEntry.lastAccessedAt = Date.now();
572
- this.debouncedPersistRegistry();
573
- }
574
- return true;
969
+ return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt);
575
970
  }
576
971
  catch (e) {
577
972
  LogError(`LocalCacheManager.UpsertSingleEntity failed: ${e}`);
@@ -580,60 +975,36 @@ export class LocalCacheManager extends BaseSingleton {
580
975
  }
581
976
  /**
582
977
  * Removes a single entity from a cached RunView result.
583
- * Used by BaseEngine for immediate cache sync when an entity is deleted.
978
+ * Supports composite primary keys via CompositeKey matching.
584
979
  *
585
980
  * @param fingerprint - The cache fingerprint to update
586
- * @param primaryKeyValue - The primary key value of the entity to remove
587
- * @param primaryKeyFieldName - Name of the primary key field
981
+ * @param key - CompositeKey identifying the entity to remove
588
982
  * @param newMaxUpdatedAt - New maxUpdatedAt timestamp
589
983
  * @returns true if cache was updated, false if cache not found or update failed
590
984
  */
591
- async RemoveSingleEntity(fingerprint, primaryKeyValue, primaryKeyFieldName, newMaxUpdatedAt) {
985
+ async RemoveSingleEntity(fingerprint, key, newMaxUpdatedAt) {
592
986
  if (!this._storageProvider || !this._config.enabled)
593
987
  return false;
594
988
  try {
595
- // Get existing cached data
596
989
  const cached = await this.GetRunViewResult(fingerprint);
597
990
  if (!cached) {
598
- // No existing cache - nothing to update
599
991
  return false;
600
992
  }
601
- // Build a map of existing records by primary key
993
+ const pkFieldNames = key.KeyValuePairs.map(kv => kv.FieldName);
994
+ const keyStr = key.ToConcatenatedString();
995
+ // Build a map of existing records by composite key string
602
996
  const resultMap = new Map();
603
997
  for (const row of cached.results) {
604
998
  const rowObj = row;
605
- const rowPkValue = this.extractPrimaryKeyString(rowObj, primaryKeyFieldName);
606
- if (rowPkValue) {
607
- resultMap.set(rowPkValue, row);
608
- }
999
+ const rowKey = this.buildCompositeKeyFromRow(rowObj, pkFieldNames);
1000
+ resultMap.set(rowKey.ToConcatenatedString(), row);
609
1001
  }
610
- // Check if entity exists in cache
611
- if (!resultMap.has(primaryKeyValue)) {
612
- // Entity not in cache, nothing to remove
613
- return true; // Not an error, just a no-op
1002
+ if (!resultMap.has(keyStr)) {
1003
+ return true; // Not in cache, no-op
614
1004
  }
615
- // Remove the entity
616
- resultMap.delete(primaryKeyValue);
617
- // Convert map back to array
1005
+ resultMap.delete(keyStr);
618
1006
  const updatedResults = Array.from(resultMap.values());
619
- // Store the updated cache - rowCount is derived from results.length
620
- const data = {
621
- results: updatedResults,
622
- maxUpdatedAt: newMaxUpdatedAt
623
- };
624
- const value = JSON.stringify(data);
625
- const sizeBytes = this.estimateSize(value);
626
- await this._storageProvider.SetItem(fingerprint, value, CacheCategory.RunViewCache);
627
- // Update registry entry with derived rowCount
628
- const existingEntry = this._registry.get(fingerprint);
629
- if (existingEntry) {
630
- existingEntry.maxUpdatedAt = newMaxUpdatedAt;
631
- existingEntry.rowCount = updatedResults.length;
632
- existingEntry.sizeBytes = sizeBytes;
633
- existingEntry.lastAccessedAt = Date.now();
634
- this.debouncedPersistRegistry();
635
- }
636
- return true;
1007
+ return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt);
637
1008
  }
638
1009
  catch (e) {
639
1010
  LogError(`LocalCacheManager.RemoveSingleEntity failed: ${e}`);
@@ -641,51 +1012,26 @@ export class LocalCacheManager extends BaseSingleton {
641
1012
  }
642
1013
  }
643
1014
  /**
644
- * Extracts the primary key value as a string from a row object.
645
- * Handles both single-field and composite primary keys.
646
- * @param row - The row object
647
- * @param primaryKeyFieldName - The primary key field name (first field for composite keys)
648
- * @returns The primary key value as a string, or null if not found
649
- */
650
- extractPrimaryKeyString(row, primaryKeyFieldName) {
651
- const value = row[primaryKeyFieldName];
652
- if (value === null || value === undefined) {
653
- return null;
654
- }
655
- return String(value);
656
- }
657
- /**
658
- * Extracts the primary key value from a CompositeKey concatenated string.
659
- * Format: "Field1|Value1||Field2|Value2" for composite keys, or "ID|abc123" for single keys.
660
- * @param concatenatedKey - The concatenated key string from RecordChange.RecordID
661
- * @param primaryKeyFieldName - The primary key field name to extract
662
- * @returns The value for the specified field, or the first value if field not found
1015
+ * Stores updated results array back to the cache and updates the registry.
1016
+ * Shared by UpsertSingleEntity and RemoveSingleEntity to avoid duplication.
663
1017
  */
664
- extractValueFromConcatenatedKey(concatenatedKey, primaryKeyFieldName) {
665
- if (!concatenatedKey) {
666
- return null;
667
- }
668
- // Split by field delimiter (||)
669
- const fieldPairs = concatenatedKey.split('||');
670
- for (const pair of fieldPairs) {
671
- // Split by value delimiter (|)
672
- const parts = pair.split('|');
673
- if (parts.length >= 2) {
674
- const fieldName = parts[0];
675
- const value = parts.slice(1).join('|'); // Rejoin in case value contained |
676
- if (fieldName === primaryKeyFieldName) {
677
- return value;
678
- }
679
- }
680
- }
681
- // If field name not found, return the first value (fallback for simple keys)
682
- if (fieldPairs.length > 0) {
683
- const parts = fieldPairs[0].split('|');
684
- if (parts.length >= 2) {
685
- return parts.slice(1).join('|');
686
- }
1018
+ async storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt) {
1019
+ const data = {
1020
+ results: updatedResults,
1021
+ maxUpdatedAt: newMaxUpdatedAt
1022
+ };
1023
+ const value = JSON.stringify(data);
1024
+ const sizeBytes = this.estimateSize(value);
1025
+ await this._storageProvider.SetItem(fingerprint, value, CacheCategory.RunViewCache);
1026
+ const existingEntry = this._registry.get(fingerprint);
1027
+ if (existingEntry) {
1028
+ existingEntry.maxUpdatedAt = newMaxUpdatedAt;
1029
+ existingEntry.rowCount = updatedResults.length;
1030
+ existingEntry.sizeBytes = sizeBytes;
1031
+ existingEntry.lastAccessedAt = Date.now();
1032
+ this.debouncedPersistRegistry();
687
1033
  }
688
- return null;
1034
+ return true;
689
1035
  }
690
1036
  /**
691
1037
  * Invalidates all cached RunView results for a specific entity.
@@ -1055,6 +1401,13 @@ export class LocalCacheManager extends BaseSingleton {
1055
1401
  if (stored) {
1056
1402
  const parsed = JSON.parse(stored);
1057
1403
  this._registry = new Map(parsed.map(e => [e.key, e]));
1404
+ // Rebuild entity→fingerprint reverse index from persisted registry
1405
+ // so that BaseEntity events can find cached entries after a server restart
1406
+ for (const entry of this._registry.values()) {
1407
+ if (entry.fingerprint) {
1408
+ this.addToEntityIndex(entry.fingerprint);
1409
+ }
1410
+ }
1058
1411
  }
1059
1412
  }
1060
1413
  catch (e) {
@@ -1146,6 +1499,10 @@ export class LocalCacheManager extends BaseSingleton {
1146
1499
  if (entry?.type === 'dataset') {
1147
1500
  await this._storageProvider.Remove(key + '_date', category);
1148
1501
  }
1502
+ // Clean up entity→fingerprint index for evicted entries
1503
+ if (entry?.fingerprint) {
1504
+ this.removeFromEntityIndex(entry.fingerprint);
1505
+ }
1149
1506
  this._registry.delete(key);
1150
1507
  }
1151
1508
  catch (e) {