@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.
- package/dist/generic/baseEngine.d.ts +64 -1
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +225 -22
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +31 -3
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +46 -0
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/hookRegistry.d.ts +83 -0
- package/dist/generic/hookRegistry.d.ts.map +1 -0
- package/dist/generic/hookRegistry.js +87 -0
- package/dist/generic/hookRegistry.js.map +1 -0
- package/dist/generic/interfaces.d.ts +16 -0
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +200 -19
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +489 -132
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +10 -0
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +55 -5
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +9 -6
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +10 -10
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/queryInfoInterfaces.d.ts +1 -1
- package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
- package/dist/generic/securityInfo.d.ts +41 -0
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +16 -0
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +33 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js.map +1 -1
- package/package.json +2 -2
- 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
|
|
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
|
|
473
|
-
|
|
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
|
-
//
|
|
481
|
-
|
|
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
|
|
491
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
549
|
-
|
|
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(
|
|
555
|
-
// Convert map back to array
|
|
967
|
+
resultMap.set(keyStr, entityData);
|
|
556
968
|
const updatedResults = Array.from(resultMap.values());
|
|
557
|
-
|
|
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
|
-
*
|
|
978
|
+
* Supports composite primary keys via CompositeKey matching.
|
|
584
979
|
*
|
|
585
980
|
* @param fingerprint - The cache fingerprint to update
|
|
586
|
-
* @param
|
|
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,
|
|
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
|
-
|
|
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
|
|
606
|
-
|
|
607
|
-
resultMap.set(rowPkValue, row);
|
|
608
|
-
}
|
|
999
|
+
const rowKey = this.buildCompositeKeyFromRow(rowObj, pkFieldNames);
|
|
1000
|
+
resultMap.set(rowKey.ToConcatenatedString(), row);
|
|
609
1001
|
}
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
616
|
-
resultMap.delete(primaryKeyValue);
|
|
617
|
-
// Convert map back to array
|
|
1005
|
+
resultMap.delete(keyStr);
|
|
618
1006
|
const updatedResults = Array.from(resultMap.values());
|
|
619
|
-
|
|
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
|
-
*
|
|
645
|
-
*
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
const
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
|
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) {
|