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