@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.
- package/dist/generic/RegisterForStartup.js +1 -1
- package/dist/generic/RegisterForStartup.js.map +1 -1
- package/dist/generic/baseEngine.d.ts +64 -1
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +229 -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/databaseProviderBase.d.ts +19 -1
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +44 -1
- package/dist/generic/databaseProviderBase.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 +504 -135
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +106 -2
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +379 -40
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +14 -6
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +15 -10
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/queryInfoInterfaces.d.ts +6 -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 +23 -2
- 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,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
|
-
|
|
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|
|
|
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
|
|
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
|
|
473
|
-
|
|
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
|
-
//
|
|
481
|
-
|
|
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
|
|
491
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
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
|
|
549
|
-
|
|
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(
|
|
555
|
-
// Convert map back to array
|
|
973
|
+
resultMap.set(keyStr, entityData);
|
|
556
974
|
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;
|
|
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
|
-
*
|
|
984
|
+
* Supports composite primary keys via CompositeKey matching.
|
|
584
985
|
*
|
|
585
986
|
* @param fingerprint - The cache fingerprint to update
|
|
586
|
-
* @param
|
|
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,
|
|
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
|
-
|
|
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
|
|
606
|
-
|
|
607
|
-
resultMap.set(rowPkValue, row);
|
|
608
|
-
}
|
|
1005
|
+
const rowKey = this.buildCompositeKeyFromRow(rowObj, pkFieldNames);
|
|
1006
|
+
resultMap.set(rowKey.ToConcatenatedString(), row);
|
|
609
1007
|
}
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
616
|
-
resultMap.delete(primaryKeyValue);
|
|
617
|
-
// Convert map back to array
|
|
1011
|
+
resultMap.delete(keyStr);
|
|
618
1012
|
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;
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
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) {
|