Skip to content

MemberJunction Caching & Real-Time Synchronization Guide

This guide covers the complete caching, pub/sub, and real-time data synchronization architecture in MemberJunction. It explains how data stays fresh across multiple servers and connected browser clients, with or without Redis.

Related packages: @memberjunction/core (LocalCacheManager, BaseEngine), @memberjunction/redis-provider, @memberjunction/graphql-dataprovider, @memberjunction/server (MJServer)


  1. Architecture Overview
  2. LocalCacheManager
  3. Fingerprint System
  4. Cache Registry
  5. Differential Updates
  6. Single-Entity Operations
  7. Universal Cache Invalidation
  8. Eviction Policies
  9. Storage Providers
  10. BaseEngine Integration
  11. Smart Cache Validation (RunViewsWithCacheCheck)
  12. Client-Side Fast-Start & Pre-Validation
  13. Server-Side Opportunistic Cache (All RunView/RunViews Calls)
  14. Cross-Server Synchronization (Redis)
  15. Server-to-Browser Synchronization (GraphQL Subscriptions)
  16. Deployment Topologies
  17. PubSubManager
  18. Cache Statistics and Monitoring
  19. Configuration Reference
  20. Troubleshooting
  21. Server-Side Dataset Caching

MemberJunction’s caching system operates at three tiers, each serving a different latency and persistence need:

graph TB
    subgraph "Browser Layer"
        BE[BaseEngine<br/>In-Memory Arrays]
        LCM_B[LocalCacheManager<br/>IndexedDB / localStorage]
        GQL_SUB[GraphQL Subscription<br/>WebSocket]
    end

    subgraph "Server Layer (MJAPI)"
        LCM_S[LocalCacheManager<br/>Redis or In-Memory]
        RES[ResolverBase<br/>Entity Save/Delete]
        PSM[PubSubManager<br/>GraphQL Pub/Sub]
    end

    subgraph "Data Layer"
        DB[(SQL Server /<br/>PostgreSQL)]
        REDIS[(Redis)]
    end

    BE -->|RunView with CacheLocal| LCM_B
    LCM_B -->|Smart Cache Check| LCM_S
    LCM_S -->|Cache Miss| DB

    RES -->|Entity Save| DB
    RES -->|MJGlobal Event| LCM_S
    LCM_S -->|Pub/Sub| REDIS
    REDIS -->|Pub/Sub| LCM_S

    LCM_S -->|Cache Change| PSM
    PSM -->|WebSocket| GQL_SUB
    GQL_SUB -->|MJGlobal Event| BE
TierLocationStorageLifetimePurpose
L1Browser in-memoryBaseEngine arraysSessionInstant access, reactive UI
L2Browser persistentIndexedDB / localStorageCross-sessionSurvive page refresh, reduce server load
L3ServerRedis or in-memoryProcess lifetime (or Redis TTL)Shared across requests, cross-server sync

A critical architectural distinction: server-side providers trust the cache completely, while client-side providers validate before trusting.

EnvironmentProviderCache Hit BehaviorWhy
Server (MJAPI)SQLServerDataProvider / PostgreSQLDataProviderReturn cached data immediately, zero DB queriesCache is kept perfectly in sync via BaseEntity save/delete events + Redis pub/sub
Client (Browser)GraphQLDataProviderLightweight smart cache check against server before returningBrowser cache doesn’t have the same event-driven sync guarantees

This is controlled by the TrustLocalCacheCompletely property on ProviderBase:

  • Default (false): Client-side behavior — uses smart cache check (see Smart Cache Validation)
  • Overridden to true in DatabaseProviderBase: Server-side behavior — cache hits return instantly

This means that on the server, once data is loaded into the cache (Redis or in-memory), subsequent RunView calls for the same query fingerprint are served entirely from cache with no database interaction whatsoever. The cache stays accurate because:

  1. All entity mutations flow through BaseEntity.Save() / BaseEntity.Delete()
  2. These fire MJGlobal events that LocalCacheManager catches
  3. LocalCacheManager updates or invalidates affected cached query results in-place
  4. In multi-server deployments, Redis pub/sub propagates changes to all MJAPI nodes

When a user saves an entity in their browser, the following chain executes:

sequenceDiagram
    participant BA as Browser A (saver)
    participant SA as MJAPI-A
    participant DB as Database
    participant Redis
    participant SB as MJAPI-B
    participant BB as Browser B
    participant BC as Browser C (on Server A)

    BA->>SA: entity.Save() via GraphQL mutation
    SA->>DB: UPDATE/INSERT
    DB-->>SA: Success

    par Local events (instant)
        SA->>SA: MJGlobal BaseEntity event
        SA->>SA: LocalCacheManager upsert
        SA->>SA: ResolverBase publishes CACHE_INVALIDATION<br/>(with originSessionId)
    and Redis pub/sub (cross-server)
        SA->>Redis: PUBLISH mj:__pubsub__
    end

    par Browser notifications (include RecordData for saves)
        SA-->>BA: WebSocket CACHE_INVALIDATION<br/>originSessionId matches → SKIP
        SA-->>BC: WebSocket CACHE_INVALIDATION + RecordData<br/>originSessionId differs → PROCESS
    end

    Redis->>SB: SUBSCRIBE receives
    SB->>SB: OnCacheChanged → CACHE_INVALIDATION publish (no originSessionId)
    SB-->>BB: WebSocket CACHE_INVALIDATION + RecordData → PROCESS

    Note over BC: Apply RecordData directly to<br/>BaseEngine arrays + LocalCacheManager<br/>(zero server round-trip)
    Note over BB: Apply RecordData directly to<br/>BaseEngine arrays + LocalCacheManager<br/>(zero server round-trip)

LocalCacheManager is a singleton (extending BaseSingleton) that provides unified caching for RunView, RunQuery, and Dataset results. It runs on both browser and server, using pluggable storage providers.

Location: packages/MJCore/src/generic/localCacheManager.ts

import { LocalCacheManager } from '@memberjunction/core';
// Initialize with a storage provider
await LocalCacheManager.Instance.Initialize(storageProvider, {
enabled: true,
maxSizeBytes: 50 * 1024 * 1024, // 50 MB
maxEntries: 1000,
defaultTTLMs: 5 * 60 * 1000, // 5 minutes
evictionPolicy: 'lru',
});

Initialize() is idempotent — subsequent calls return the same promise. It loads the persisted registry from the storage provider on first call.

The system supports three cache entry types, each stored in its own category namespace:

TypeCategoryDescription
datasetDatasetCacheCached Dataset results
runviewRunViewCacheCached RunView results (entity queries)
runqueryRunQueryCacheCached RunQuery results (raw SQL/named queries)
// RunView cache
SetRunViewResult(fingerprint, params, results, maxUpdatedAt, aggregateResults?): Promise<void>
GetRunViewResult(fingerprint): Promise<CachedRunViewResult | null>
InvalidateRunViewResult(fingerprint): Promise<void>
// RunQuery cache
SetRunQueryResult(fingerprint, queryName, results, maxUpdatedAt, rowCount?, queryId?, ttlMs?): Promise<void>
GetRunQueryResult(fingerprint): Promise<CachedRunQueryResult | null>
InvalidateRunQueryResult(fingerprint): Promise<void>
// Entity-level invalidation (invalidates ALL matching RunView results)
InvalidateEntityCaches(entityName): Promise<void>
// Query-level invalidation (invalidates ALL matching RunQuery results)
InvalidateQueryCaches(queryName): Promise<void>
// Differential updates
ApplyDifferentialUpdate(fingerprint, params, updatedRows, deletedRecordIDs,
primaryKeyFieldName, newMaxUpdatedAt, serverRowCount?, aggregateResults?): Promise<CachedRunViewResult | null>
// Single-entity operations (use CompositeKey for primary key matching)
UpsertSingleEntity(fingerprint, entityData, key: CompositeKey, newMaxUpdatedAt): Promise<boolean>
RemoveSingleEntity(fingerprint, key: CompositeKey, newMaxUpdatedAt): Promise<boolean>
// Storage provider hot-swap
SetStorageProvider(newProvider: ILocalStorageProvider): Promise<void>
// Fingerprint generation
GenerateRunViewFingerprint(params: RunViewParams, connectionPrefix?: string): string
GenerateRunQueryFingerprint(queryNameOrSQL, params?, connectionPrefix?): string
// Statistics
GetStats(): CacheStats
GetHitRate(): number
GetAllEntries(): CacheEntryInfo[]
GetEntriesByType(type: CacheEntryType): CacheEntryInfo[]
GetRunViewCacheStatus(fingerprint): { maxUpdatedAt, rowCount } | null
GetRunQueryCacheStatus(fingerprint): { maxUpdatedAt, rowCount } | null

Every cached result is identified by a fingerprint — a deterministic, human-readable string generated from the query parameters. Fingerprints enable efficient cache lookups and debugging.

Format:

EntityName|Filter|OrderBy|MaxRows|StartRow|AggregateHash[|ConnectionPrefix]

Note: ResultType is intentionally excluded from the fingerprint. The cache always stores plain JSON objects regardless of whether the caller requested 'simple' or 'entity_object'. Transformation to BaseEntity objects happens post-cache at consumption time via TransformSimpleObjectToEntityObject(). This ensures that an engine loading with ResultType: 'entity_object' and a GraphQL resolver requesting ResultType: 'simple' share the same cached data.

Examples:

Users|Status='Active'|Name ASC|100|0|_|localhost_4000
AI Models|_|_|-1|0|_|localhost_4000
Users|_|_|50|100|a1b2c3d4|prod-db

Key rules:

  • Empty values are represented as _ for readability
  • The aggregate hash uses a djb2 hash function when aggregate expressions are present (otherwise _)
  • Connection prefix is appended only when provided — enables multi-connection cache isolation
  • Pipe-delimited for easy parsing and debugging

When RunViewParams includes aggregate expressions, they’re hashed deterministically:

// Aggregates are sorted before hashing for consistency
const aggString = aggregates
.map(a => `${a.expression}:${a.alias || ''}`)
.sort()
.join(';');
// DJB2 hash algorithm
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) + str.charCodeAt(i); // hash * 33 + char
}
return (hash >>> 0).toString(16); // Unsigned 32-bit hex string

This means SUM(Amount):total;COUNT(*):cnt always produces the same hash regardless of the order the aggregates are defined.

Similar pattern but keyed on query name/SQL and query parameters instead of entity/filter.

The connectionPrefix parameter ensures cache isolation when the same browser or server connects to multiple backends:

Users|Active=1|Name ASC|100|0|_|prod-server
Users|Active=1|Name ASC|100|0|_|dev-server

These are treated as completely separate cache entries even though the query is identical.


The registry is a persistent metadata index (Map<string, CacheEntryInfo>) that tracks all cached items without loading the actual data into memory.

interface CacheEntryInfo {
key: string; // Storage key (same as fingerprint)
type: 'dataset' | 'runview' | 'runquery';
name: string; // Entity/Query/Dataset name
fingerprint?: string; // For reverse lookups
params?: Record<string, unknown>; // Original query parameters
cachedAt: number; // Cache timestamp (ms since epoch)
lastAccessedAt: number; // Last read timestamp (for LRU)
accessCount: number; // Hit count (for LFU)
sizeBytes: number; // Approximate size in bytes
maxUpdatedAt?: string; // Latest __mj_UpdatedAt from data
rowCount?: number; // Number of cached rows (always derived)
expiresAt?: number; // TTL expiry timestamp (ms since epoch)
}

The registry is persisted to the storage provider under key __MJ_CACHE_REGISTRY__ in the Metadata category. Writes are debounced to 1 second to avoid thrashing on rapid operations:

private debouncedPersistRegistry(): void {
if (this._persistTimeout) clearTimeout(this._persistTimeout);
this._persistTimeout = setTimeout(() => {
this.persistRegistry();
}, 1000);
}

On startup, Initialize() loads the registry from storage and rebuilds the entity-to-fingerprint reverse index.

The reverse index maps entity names (lowercase, trimmed) to Sets of fingerprint strings. This powers instant invalidation — when an entity changes, all cached RunView results for that entity are found in O(1):

graph LR
    subgraph "Reverse Index"
        E1["'users'"] --> F1["Users|Status='Active'|Name ASC|...<br/>Users|Role='Admin'|LastLogin DESC|..."]
        E2["'ai models'"] --> F2["AI Models|_|_|-1|0|_<br/>AI Models|Vendor='OpenAI'|Name|..."]
    end

The index is rebuilt from the registry on startup and maintained during Set/Invalidate/Evict operations.

Important: Derived rowCount vs. maintained totalRowCount

Section titled “Important: Derived rowCount vs. maintained totalRowCount”

Two distinct counts, and the distinction is load-bearing:

  • rowCount — the number of cached rows. This is always derived from results.length, never persisted independently. On every read it’s computed fresh from the stored results array, so the count and the actual cached data can never diverge.
  • totalRowCount — the total number of rows matching the query in the database. For a full-dataset slot this equals rowCount, but for a subset slot (a paginated / MaxRows-limited RunView) the total is larger than the cached-rows count. It therefore must be maintained separately — carried from the server’s authoritative COUNT(*) on the initial store, and updated across the net row delta on differential updates and in-place upsert/remove. Deriving totalRowCount from results.length is a bug: on a subset slot it collapses the reported total to the cached-subset size the first time a mutation touches the entry, so a cached paginated read under-reports its total (while count_only, which is cache-ineligible and always runs a fresh COUNT, stays correct). See the localCacheManager totalRowCount maintenance in ApplyDifferentialUpdate / storeCachedResults.

The differential update system enables efficient cache updates by merging only changed records into the existing cache, rather than replacing the entire dataset.

flowchart TD
    A[Receive updatedRows + deletedRecordIDs] --> B[Load existing cache by fingerprint]
    B --> C{Cache found?}
    C -->|No| Z[Return null]
    C -->|Yes| D[Build primary key Map from cached results]
    D --> E[Apply deletions: remove by parsed composite key]
    E --> F[Apply updates/inserts: merge into Map]
    F --> G[Convert Map back to array]
    G --> H[Persist with new maxUpdatedAt]
    H --> I[Update registry: derived rowCount + maintained totalRowCount across the net delta]
    I --> J[Return merged CachedRunViewResult]

MemberJunction entities can have composite primary keys. The differential update system handles these using a concatenated key format:

Format: Field1|Value1||Field2|Value2

  • Fields are separated by || (double pipe)
  • Field name and value are separated by | (single pipe)
  • Values containing | are handled via re-joining after split
// Parsing composite keys
private extractValueFromConcatenatedKey(
concatenatedKey: string, // "OrderID|123||ProductID|456"
primaryKeyFieldName: string // "OrderID"
): string | null {
const fieldPairs = concatenatedKey.split('||');
for (const pair of fieldPairs) {
const parts = pair.split('|');
if (parts.length >= 2) {
const fieldName = parts[0];
const value = parts.slice(1).join('|'); // Handle | in values
if (fieldName === primaryKeyFieldName) return value;
}
}
return null; // Fallback for simple keys
}

For entities with composite primary keys, the system falls back to full invalidation rather than attempting in-place upsert when it can’t reliably match records. This prevents data corruption from incorrect key matching.

// Initial cache: 10 records
await cacheManager.SetRunViewResult(fp, params, rows10, '2026-01-01T00:00:00Z');
// Server reports 3 changed rows and 1 deletion
const result = await cacheManager.ApplyDifferentialUpdate(
fp,
params,
changedRows, // 2 updated + 1 new = 3 rows
['ID|deleted-id'], // 1 deletion
'ID', // Primary key field
'2026-01-02T00:00:00Z' // New maxUpdatedAt
);
// Result: 10 - 1 + 1 = 10 rows (1 deleted, 2 updated, 1 inserted)
// maxUpdatedAt: '2026-01-02T00:00:00Z'

For immediate, fine-grained cache updates when a single entity is saved or deleted (no round-trip to server). Both methods use CompositeKey for primary key matching, fully supporting entities with any number of primary key fields.

Adds or replaces a single record in a cached RunView result:

import { CompositeKey, KeyValuePair } from '@memberjunction/core';
// Single-field primary key
const key = CompositeKey.FromKeyValuePairs([new KeyValuePair('ID', entity.ID)]);
const success = await LocalCacheManager.Instance.UpsertSingleEntity(
fingerprint,
entity.GetAll(), // Plain object from BaseEntity
key, // CompositeKey identifying the record
entity.__mj_UpdatedAt // New maxUpdatedAt
);
// Composite primary key (e.g., junction table)
const compositeKey = CompositeKey.FromKeyValuePairs([
new KeyValuePair('UserID', 'u1'),
new KeyValuePair('RoleID', 'r2'),
]);
await LocalCacheManager.Instance.UpsertSingleEntity(
fingerprint, entityData, compositeKey, updatedAt
);
// Returns false if fingerprint not found in cache

Algorithm:

  1. Load cached results by fingerprint
  2. Build a Map<string, unknown> keyed by CompositeKey.ToConcatenatedString() for O(1) lookups
  3. Upsert: set the key → entity data in the map
  4. Persist updated array back to storage
  5. Update registry rowCount (derived from array length)

Removes a single record from a cached RunView result:

const key = CompositeKey.FromKeyValuePairs([new KeyValuePair('ID', 'some-uuid')]);
const success = await LocalCacheManager.Instance.RemoveSingleEntity(
fingerprint,
key, // CompositeKey identifying the record to remove
new Date().toISOString() // New maxUpdatedAt
);
// Returns true even if entity wasn't in cache (no-op is OK)

When an entity is saved or deleted, ALL cached RunView results containing that entity must be invalidated or updated. This is powered by the entity-to-fingerprint reverse index.

await LocalCacheManager.Instance.InvalidateEntityCaches('Users');

This:

  1. Normalizes the entity name (lowercase, trim)
  2. Looks up the reverse index for all fingerprints associated with that entity
  3. Removes each cached result from storage
  4. Removes each entry from the registry
  5. Removes fingerprints from the reverse index
  6. Persists the updated registry

BaseEngine subscribes to MJGlobal events and triggers cache operations automatically:

flowchart TD
    A[BaseEntity.Save completes] -->|MJGlobal ComponentEvent| B[BaseEngine.HandleIndividualEvent]
    B --> C{Event matches<br/>engine config?}
    C -->|No| Z[Ignore]
    C -->|Yes| D{Can use immediate mutation?}
    D -->|"Yes: No Filter, No OrderBy,<br/>No AdditionalLoading override"| E[Synchronous array mutation]
    D -->|No| F[Buffer ALL events<br/>in the debounce window]

    E --> G[Update in-memory array<br/>push/splice/replace]
    G --> H[NotifyDataChange via DataChange$<br/>+ emitPropertyChange]
    G --> I{CacheLocal enabled?}
    I -->|Yes| J[syncLocalCacheForConfig]
    J --> K[UpsertSingleEntity or RemoveSingleEntity<br/>using CompositeKey]

    F -->|"debounce elapses<br/>(default 1.5s, per-config DebounceTime)"| N{Any event in the batch NOT yet<br/>reflected in the array?}
    N -->|Yes| L[LoadSingleConfig from server<br/>one refresh covers the whole batch]
    L --> M[RunView → updates cache naturally]
    L --> H
    L -.->|transient failure| R[scheduleEventRefreshRetry<br/>bounded backoff 2s/4s]
    R -.-> L
    N -->|"No — every change already applied<br/>(in-place save / manual push)"| S[notifyAlreadyAppliedMutation]
    S --> H
    N -->|"Only deletes of rows<br/>absent from the array"| T[Silent — manual-splice engine code<br/>owns the notification]

Note: LocalCacheManager also independently subscribes to MJGlobal events (including remote-invalidate) and updates its own caches. The syncLocalCacheForConfig path is only for local save/delete events where BaseEngine handles the immediate mutation — remote events are handled by LocalCacheManager’s own HandleRemoteInvalidateEvent.

When immediate mutation is used and CacheLocal is enabled, BaseEngine synchronizes the change to LocalCacheManager using CompositeKey:

protected async syncLocalCacheForConfig(
config: BaseEnginePropertyConfig,
event: BaseEntityEvent
): Promise<void> {
if (!LocalCacheManager.Instance.IsInitialized) return;
const entity = event.baseEntity;
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint({
EntityName: config.EntityName,
ExtraFilter: config.Filter || '',
OrderBy: config.OrderBy || '',
MaxRows: -1, StartRow: 0,
}, connectionPrefix);
// Uses entity.PrimaryKey (CompositeKey) — works with any number of PK fields
const key = entity.PrimaryKey;
const updatedAt = entity.Get('__mj_UpdatedAt') || new Date().toISOString();
if (event.type === 'delete') {
await LocalCacheManager.Instance.RemoveSingleEntity(fingerprint, key, updatedAt);
} else {
await LocalCacheManager.Instance.UpsertSingleEntity(
fingerprint, entity.GetAll(), key, updatedAt);
}
}

When the cache exceeds maxSizeBytes or maxEntries, entries are evicted to make room.

Entry sizeBytes is an approximate figure used only for eviction accounting — it is never exact and never affects correctness. Rather than JSON.stringify the entire result array on every cache write (which previously ran an O(rows × fields) serialization on the per-save/delete hot path), estimateResultsSize() samples a few random rows and scales:

  • sample count = clamp(ceil(rowCount × 0.10), 3, 10) distinct random row indexes
  • average JSON.stringify(row).length across the sample, multiply by rowCount, ×2 for UTF-16
  • rowCount === 0 → 0; rowCount ≤ 3 → measure every row (no sampling)

Random (not first-N) sampling avoids systematic skew when rows are heterogeneous (e.g. a nullable large JSON/text column whose head rows happen to be null or oversized). Single-row cache mutations (UpsertSingleEntity/RemoveSingleEntity) likewise avoid per-row CompositeKey allocation by keying their internal dedup Map with a cheap delimiter-joined PK string.

// Before every Set operation:
private async evictIfNeeded(neededBytes: number): Promise<void> {
const stats = this.GetStats();
const wouldExceedSize = (stats.totalSizeBytes + neededBytes) > this._config.maxSizeBytes;
const wouldExceedCount = stats.totalEntries >= this._config.maxEntries;
if (!wouldExceedSize && !wouldExceedCount) return;
// Free at least 10% of max to avoid evicting on every write
const targetFreeBytes = Math.max(neededBytes, this._config.maxSizeBytes * 0.1);
const targetFreeCount = Math.max(1, Math.floor(this._config.maxEntries * 0.1));
await this.evict(targetFreeBytes, targetFreeCount);
}
PolicySort OrderBest For
lru (default)Oldest lastAccessedAt firstGeneral purpose — evicts data nobody is using
lfuLowest accessCount firstHot/cold data patterns — keeps frequently-used data
fifoOldest cachedAt firstUniform access patterns — simple age-based expiry

When entries are evicted, the reverse index is also cleaned up to prevent memory leaks from stale mappings:

// During eviction:
this._entityIndex.get(normalizedName)?.delete(fingerprint);
// If the Set is now empty, remove the entity key entirely
if (this._entityIndex.get(normalizedName)?.size === 0) {
this._entityIndex.delete(normalizedName);
}

LocalCacheManager delegates actual storage to an ILocalStorageProvider implementation. The interface is generic-typedT flows from caller through to the retrieved value:

interface ILocalStorageProvider {
GetItem<T = unknown>(key: string, category?: string): Promise<T | null>;
GetItems<T = unknown>(keys: string[], category?: string): Promise<Map<string, T | null>>;
SetItem<T>(key: string, value: T, category?: string): Promise<void>;
Remove(key: string, category?: string): Promise<void>;
ClearCategory?(category: string): Promise<void>;
GetCategoryKeys?(category: string): Promise<string[]>;
}

Native object storage vs. JSON serialization

Section titled “Native object storage vs. JSON serialization”

Each implementation handles serialization for its medium internally — callers see the same object-typed interface regardless of backend:

ProviderStorage formatType fidelity
BrowserIndexedDBStorageProviderNative objects via structured clonePreserves Date, Map, Set, typed arrays, nested objects
BrowserLocalStorageProviderJSON-serialized internallyDate → ISO string; Map/Set → plain objects
RedisLocalStorageProviderJSON-serialized internallySame JSON limitations as localStorage
InMemoryLocalStorageProviderNative references (no copy)Full identity preserved

Why this matters: IndexedDB’s structured clone is implemented in browser-native C++ — significantly faster than JS-level JSON.parse/JSON.stringify. For cache-heavy workloads (engine warm load, dashboard refreshes) this is a measurable win. The previous string-based interface forced a sandwich of JSON.stringify (write) + JSON.parse (read) that we no longer pay for IDB-backed cache hits.

// Generic typing flows end-to-end — no manual JSON.parse, no casting
interface CachedRunViewData { results: unknown[]; maxUpdatedAt: string; }
await provider.SetItem<CachedRunViewData>(fp, { results, maxUpdatedAt }, 'RunViewCache');
const cached = await provider.GetItem<CachedRunViewData>(fp, 'RunViewCache');
// cached: CachedRunViewData | null

Class instances lose their prototype on retrieval across all providers. Store the underlying data shape (e.g. via entity.GetAll() for BaseEntity) — methods aren’t preserved by structured clone or JSON.

Hot paths that need many cached entries at once — most notably the smart-cache-check warm-load path that reads ~85 fingerprints per coalesced engine bundle — use GetItems<T>(keys, category?) to batch reads through the backend’s native batching primitive:

ProviderBacking primitiveCost saved
BrowserIndexedDBStorageProviderSingle read transaction with N parallel get() calls~3–10ms per per-key transaction overhead × N keys → one transaction’s commit cost
RedisLocalStorageProviderSingle MGET command(N – 1) network round-trips
BrowserLocalStorageProvider / InMemoryLocalStorageProviderTight loopNegligible (synchronous backends)

LocalCacheManager exposes a typed wrapper, GetRunViewResults(fingerprints: string[]), that delegates to GetItems and threads the cache hit/miss accounting through. The smart-cache-check flow uses it in two passes:

  1. Pre-network pass in prepareSmartCacheCheckParams: read all per-fingerprint cache statuses in one batch to build the GraphQL request payload
  2. Post-network pass in executeSmartCacheCheck: read all ‘current’ fingerprints’ actual cached data in one batch, distribute to per-param processors

Before this, both passes did per-fingerprint reads inside Promise.all — looked parallel, but IDB serializes transactions on the same object store and Redis paid full RTT per GET. For an 8-engine warm-load bundle of ~85 fingerprints this dropped from ~170 IDB transactions / network round-trips to 2.

The deserialization cost on retrieval is also avoided per pass — the batched implementation deserializes (or structured-clones) once per backend call instead of N times.

Categories organize cache data and enable bulk operations:

CategoryContents
RunViewCacheCached entity query results
RunQueryCacheCached raw SQL/named query results
DatasetCacheCached dataset results
MetadataRegistry and internal state
defaultUncategorized data
graph TD
    ILocalStorageProvider --> InMemory[InMemoryLocalStorageProvider]
    ILocalStorageProvider --> BrowserLS[BrowserLocalStorageProvider]
    ILocalStorageProvider --> BrowserIDB[BrowserIndexedDBStorageProvider]
    ILocalStorageProvider --> RedisLSP[RedisLocalStorageProvider]

    InMemory -->|"Server default<br/>(no Redis)"| ServerSide
    BrowserIDB -->|"Browser default<br/>(recommended)"| ClientSide
    BrowserLS -->|"Browser fallback<br/>(if IndexedDB unavailable)"| ClientSide
    RedisLSP -->|"Server with Redis<br/>(shared cache + pub/sub)"| ServerSide

Location: packages/MJCore/src/generic/InMemoryLocalStorageProvider.ts

  • Simple Map<category, Map<key, value>> structure
  • No persistence across process restarts
  • Used as server default when Redis is not configured
  • Also used as the initial provider before Redis connects (then hot-swapped)

Location: packages/GraphQLDataProvider/src/storage-providers.ts

  • Database name: MJ_Metadata
  • DB version: auto-derived from package version as major * 1000 + minor (e.g. 5.30.x5030, 5.31.x5031, 6.0.x6000). See “Version-bumped wipe” below.
  • Native object storage via IndexedDB’s structured clone algorithm — Date, Map, Set, typed arrays, and nested objects are preserved. No JSON.parse/JSON.stringify on the hot path.
  • Dedicated object stores for known categories: mj:default, mj:Metadata, mj:RunViewCache, mj:RunQueryCache, mj:DatasetCache
  • Unknown categories: prefixed keys stored in the default store
  • Cross-tab handling: when another tab triggers a DB upgrade, the local connection’s onversionchange fires and we close gracefully so the upgrade isn’t blocked
  • This is what GraphQLDataProvider.LocalStorageProvider resolves to in browser environments. The LocalStorageProvider / _localStorageProvider / localStorageRootKey names are historical — the actual backend is IndexedDB.

Version-bumped wipe (zero-config schema migrations)

Section titled “Version-bumped wipe (zero-config schema migrations)”

Bumping any minor version of @memberjunction/graphql-dataprovider automatically increments the IDB DB_VERSION, which fires onupgradeneeded on the next page load. The handler drops every object store (cached data may be from any prior schema/format) and recreates them fresh — caches repopulate on first use after the upgrade.

This is intentional design:

  • Patch releases share the DB version (5.30.0, 5.30.1, 5.30.2 all use DB version 5030) — frequent patch deploys don’t trigger cold-cache loads
  • Minor releases get a clean cache — sidesteps the entire “did this PR change cache format?” review burden
  • Cost: one slow page load per user per minor (~1s back to the cold-load path) — negligible for monthly LTS cadence
  • Emergency override: bump MANUAL_CACHE_REVISION in storage-providers.ts to force a wipe within a single minor release

The version number is generated at build time by packages/GraphQLDataProvider/scripts/generate-version.mjs (a prebuild/pretest script that reads package.json and writes src/version.generated.ts). No human action required when bumping versions through the normal changeset flow.

The framework metadata payload (AllMetadata — typically 6–10 MB of JSON) is gzip-compressed before being written to the Metadata object store. SaveLocalMetadataToStorage uses the native CompressionStream('gzip') API; the compressed bytes are base64-encoded for storage. LoadLocalMetadataFromStorage reverses the process via DecompressionStream('gzip').

The format used for the current write is recorded in a separate key (localStorageFormatKey — values: 'gzip' or 'json') so the read path can dispatch to the right deserializer. An uncompressed fallback path runs when CompressionStream is unavailable.

Why compress? The dominant cost of loading the metadata cache is JSON.parse on the main thread. Parsing ~1 MB is dramatically faster than parsing ~10 MB, and it’s synchronous — directly affecting TTI on warm load. Secondary benefits: faster IndexedDB reads, better headroom on storage-constrained devices.

After load, _localMetadata is held in memory on the provider instance for the session lifetime. Decompress + parse + MetadataFromSimpleObject only runs once per cold load (and again if backgroundValidateAndRefresh swaps in fresh data) — never per metadata access.

Location: packages/GraphQLDataProvider/src/storage-providers.ts

  • Uses browser localStorage API
  • Key format: [mj]:[category]:[key]
  • Falls back to in-memory if localStorage is unavailable
  • Limited by browser storage quotas (~5-10 MB)
  • Not used by GraphQLDataProvider by defaultLocalStorageProvider resolves to BrowserIndexedDBStorageProvider whenever indexedDB is defined. Available for opt-in scenarios.

Location: packages/RedisProvider/

  • Redis key structure: mj:<category>:<key>
  • Pub/sub channel: mj:__pubsub__
  • Cross-process shared cache (all MJAPI instances share the same data)
  • Built-in pub/sub for cache invalidation notifications
  • Self-message filtering via MJGlobal.Instance.ProcessUUID
  • OnCacheChanged callback for reacting to remote cache changes
  • Configurable logging for debugging pub/sub flow

Pub/sub message format:

{
"CacheKey": "AI Models|_|_|-1|0|_",
"Action": "set",
"Category": "RunViewCache",
"SourceServerId": "b931917f-...",
"Timestamp": "2026-03-08T01:36:49.433Z"
}

During server startup, engines load and cache data before Redis connects. The SetStorageProvider() method solves this initialization order problem:

sequenceDiagram
    participant Engines as Engine Loading
    participant LCM as LocalCacheManager
    participant Mem as InMemoryProvider
    participant Redis as RedisProvider

    Note over Engines,LCM: Server Startup Phase 1
    Engines->>LCM: Initialize(InMemoryProvider)
    Engines->>LCM: Cache 64 entries to memory

    Note over LCM,Redis: Server Startup Phase 2
    Redis->>Redis: Connect to Redis server
    LCM->>LCM: SetStorageProvider(RedisProvider)
    LCM->>Mem: Read all 64 entries
    LCM->>Redis: Write all 64 entries
    Note over LCM: "Migrated 64/64 entries"

SetStorageProvider() migrates all existing cache entries (by iterating the registry) from the old provider to the new one. The registry is also persisted to the new provider. Any entries that fail to migrate are logged but don’t block the swap.


All MemberJunction engine singletons (AIEngineBase, DashboardEngine, UserInfoEngine, etc.) extend BaseEngine<T>, which provides automatic data loading, caching, and real-time refresh.

Reactive UI consumption — ObserveProperty (the API to know about)

Section titled “Reactive UI consumption — ObserveProperty (the API to know about)”

Engines expose every array property as a lazy RxJS observable. This is the canonical way to keep an Angular UI in sync with cached entities — no manual reload loops, no loadX() calls after every mutation, no race conditions with BaseEntity events.

// From inside the engine — declare a Configs entry (BaseEngine wires the events):
public async Config(forceRefresh = false, contextUser?, provider?) {
await this.Load([
{ Type: 'entity', EntityName: 'MJ: My Things',
PropertyName: '_things',
CacheLocal: true },
], provider, forceRefresh, contextUser);
}
public get Things(): MyThingEntity[] { return this._things ?? []; }
public get Things$(): Observable<MyThingEntity[]> {
return this.ObserveProperty<MyThingEntity>('_things');
}
// From a consumer (Angular component, service, anywhere):
await MyEngine.Instance.Config(false, user, provider); // lazy — no-op if loaded
MyEngine.Instance.Things$.subscribe(things => {
// Fires immediately with the current array (BehaviorSubject semantics),
// and again on every save / delete / remote-invalidate.
this.things = things;
this.cdr.markForCheck();
});

Behind the scenes:

  • ObserveProperty(propertyName) is lazy-created on first call — engines whose properties are never observed pay zero runtime cost for the observable.
  • BehaviorSubject semantics: subscribers receive the current array immediately on subscribe, then re-receive the same array reference on every mutation.
  • The mutation triggers come from BaseEngine’s built-in BaseEntity event handler — DebounceIndividualBaseEntityEvent buffers all events in the debounce window and delivers the batch to ProcessEntityEvents (ProcessEntityEvent is a back-compat single-event wrapper), which per config either applies in-place array mutations (when no Filter/OrderBy/AdditionalLoading are set, so MJ can safely splice), runs one full refresh covering the batch (when a filter is set so MJ can’t determine whether the saved row still belongs), or — when every event’s changes are already reflected in the array (an in-place save of the array’s own cached instance, a manually-pushed create) — skips the redundant refresh but still notifies via notifyAlreadyAppliedMutation. In all three cases emitPropertyChange(propertyName) fires and the observable re-emits. The only silent case is a delete of a row absent from the config’s array (indistinguishable from “never matched the Filter”) — engine code that manually splices must call notifyAlreadyAppliedMutation(config, 'delete', entity) itself (see UserInfoEngine.UninstallApplication for the reference pattern).
  • DataChange$: Observable<EngineDataChangeEvent> is the engine-wide complement — emits one event per refresh with the config + new data.

When to build a new engine vs. reuse an existing service:

  • Build an engine when (a) the entity set is small (a few hundred rows max, or filter-narrowable to that), (b) more than one surface needs the same data, and (c) you want UIs / runtime resolvers to auto-update when the data changes. Examples in the repo: ConversationEngine, InteractiveFormsEngine, UserInfoEngine, KnowledgeHubMetadataEngine, ComponentMetadataEngine.
  • Don’t bulk-load if the entity has a huge column (e.g., NVARCHAR(MAX) JSON, vector embeddings) AND no good filter exists to narrow the set — you’d poison LocalCacheManager’s memory budget. See ComponentMetadataEngine’s comment about deliberately omitting MJ: Components for that reason; InteractiveFormsEngine solves it by filtering to Type='Form' so only the form-role rows (small dataset) get cached.
  • Don’t add an engine for one-off ad-hoc queries — that’s what RunView/RunViews is for. Engines are for entities consumed in many places.

Each engine declares its data needs as BaseEnginePropertyConfig entries:

export class BaseEnginePropertyConfig extends BaseInfo {
EntityName?: string; // Entity to load
Filter?: string; // Optional WHERE filter
OrderBy?: string; // Optional ORDER BY
PropertyName!: string; // Property name on engine class to populate
AutoRefresh?: boolean; // React to entity save/delete events
DebounceTime?: number; // Override debounce (default 1.5s)
CacheLocal?: boolean; // Use LocalCacheManager
CacheLocalTTL?: number; // Per-config TTL override (ms)
ResultType?: 'simple' | 'entity_object'; // See note below — undefined → engine default
}

The ResultType per-config and the engine-wide EngineDefaultResultType getter together control whether BaseEngine.LoadSingleEntityConfig / LoadMultipleEntityConfigs request 'entity_object' (full BaseEntity instances — slower, supports .Save()/.Delete()/dirty-tracking) or 'simple' (plain JS objects — much faster, read-only).

Resolution order when config.ResultType is left undefined:

  1. The engine subclass’s EngineDefaultResultType getter (override per engine for a blanket policy)
  2. The base getter, which returns 'entity_object'

In other words, the effective default is 'entity_object' — leaving ResultType undefined preserves the historical behavior. Engines that load purely read-only configuration data (where no caller will mutate the rows via .Save() / .Delete()) can override EngineDefaultResultType to skip BaseEntity construction across all their configs:

export class MyReadOnlyEngine extends BaseEngine<MyReadOnlyEngine> {
protected override get EngineDefaultResultType(): 'simple' | 'entity_object' {
return 'simple'; // every config loads plain objects unless individually overridden
}
// ...
}

Read-only contract for 'simple': configs using 'simple' should be considered read-only. The immediate-mutation path (applyImmediateMutation) operates on BaseEntity[] arrays — pushing or replacing with a real BaseEntity instance into a plain-object array would create a heterogeneous array. canUseImmediateMutation enforces this at runtime by returning false when the effective ResultType resolves to 'simple', forcing a full refresh on save/delete events instead. See Immediate Mutation vs Debounced Refresh below.

The loading flow differs between server-side and client-side because of TrustLocalCacheCompletely:

Server-Side (MJAPI) — Cache Trusted Completely

Section titled “Server-Side (MJAPI) — Cache Trusted Completely”

On the server, cached data is returned immediately with zero database queries. The cache is guaranteed accurate by BaseEntity event-driven invalidation and Redis pub/sub.

sequenceDiagram
    participant Caller as Server Code / Engine
    participant PB as ProviderBase
    participant LCM as LocalCacheManager
    participant DB as Database

    Caller->>PB: RunView({CacheLocal: true, ...})
    PB->>LCM: GetRunViewResult(fingerprint)

    alt Cache Hit
        LCM-->>PB: Cached results (plain objects)
        PB->>PB: TransformSimpleObjectToEntityObject()
        PB-->>Caller: Instant response — zero DB queries
    else Cache Miss (first load)
        PB->>DB: Execute SQL query
        DB-->>PB: Raw results
        PB->>LCM: SetRunViewResult (cache plain objects)
        PB->>PB: TransformSimpleObjectToEntityObject()
        PB-->>Caller: Fresh results (now cached for next time)
    end

Client-Side (Browser) — Smart Cache Validation

Section titled “Client-Side (Browser) — Smart Cache Validation”

In the browser, cached data is validated against the server via a lightweight check before being trusted:

sequenceDiagram
    participant UI as Angular Component
    participant Engine as BaseEngine
    participant LCM as LocalCacheManager
    participant Server as MJAPI (GraphQL)
    participant DB as Database

    UI->>Engine: engine.Config(false, user)
    Engine->>LCM: GetRunViewResult(fingerprint)

    alt Cache Hit (within TTL)
        Engine->>Server: RunViewsWithCacheCheck<br/>{maxUpdatedAt, rowCount}
        Server->>DB: SELECT MAX(__mj_UpdatedAt), COUNT(*)

        alt Data unchanged
            Server-->>Engine: status: 'current'
            Engine-->>UI: Use cache as-is
        else Data changed (differential)
            Server-->>Engine: status: 'differential' + delta
            Engine->>LCM: ApplyDifferentialUpdate
            Engine-->>UI: Merged results
        else Full refresh needed
            Server-->>Engine: status: 'stale' + full results
            Engine->>LCM: SetRunViewResult
            Engine-->>UI: Fresh results
        end
    else Cache Miss
        Engine->>Server: RunViews (full query)
        Server->>DB: SELECT * ...
        Server-->>Engine: Full results
        Engine->>LCM: SetRunViewResult
        Engine-->>UI: Fresh results
    end

The server-side cache is kept in perfect sync through this chain:

  1. All writes go through BaseEntitySave() and Delete() fire MJGlobal events
  2. LocalCacheManager subscribes to all BaseEntity events and updates/invalidates affected cached queries
  3. Redis pub/sub (when configured) propagates changes across all MJAPI nodes
  4. No external writes bypass this — in normal MJ operation, all DB mutations flow through BaseEntity

This eliminates the need for the lightweight MAX(__mj_UpdatedAt) + COUNT(*) validation query that client-side providers use. The result is that server-side engine loads (which happen frequently during MJAPI startup and on-demand) are served from cache with zero database overhead after the initial load.

The cache stores plain JSON objects, not BaseEntity instances. This is critical because BaseEntity objects contain RxJS Subject instances with circular subscriber references that cannot be serialized with JSON.stringify.

The processing order in PostRunView / PostRunViews is:

  1. Cache plain objects via LocalCacheManager.SetRunViewResult() — before any transformation
  2. Transform to entity objects via TransformSimpleObjectToEntityObject() — only if ResultType === 'entity_object'
  3. Run post-hooks via RunPostRunViewHooks()

On cache read, TransformSimpleObjectToEntityObject() is called to restore BaseEntity instances from the cached plain objects when ResultType === 'entity_object'.

When a BaseEntity is saved or deleted anywhere, BaseEngine reacts via MJGlobal events:

// BaseEngine subscribes during LoadConfigs
this._eventListener = MJGlobal.Instance.GetEventListener(false);
this._eventListener.subscribe(async (event) => {
if (event.event === MJEventType.ComponentEvent
&& event.eventCode === BaseEntity.BaseEventCode) {
await this.HandleIndividualBaseEntityEvent(event.args);
}
});

Immediate mutation (synchronous, no server round-trip):

  • Conditions checked by canUseImmediateMutation(config):
    1. Config has no Filter (can’t verify new/updated records match the filter)
    2. Config has no OrderBy (can’t maintain sort order without full data)
    3. Effective ResultType is not 'simple' (the in-memory array holds plain JS objects; pushing a BaseEntity instance from the event would create a heterogeneous array)
    4. Engine doesn’t override AdditionalLoading (no post-processing dependencies)
  • On save + create: push new entity to array
  • On save + update: replace entity in array by primary key match
  • On delete: splice entity from array by primary key match
  • Syncs change to LocalCacheManager via UpsertSingleEntity / RemoveSingleEntity

skipAdditionalLoadingCheck parameter: Callers that invoke AdditionalLoading() themselves after applying mutations can pass canUseImmediateMutation(config, true) to skip the override check. This is used by applyRemoteRecordData which applies all config mutations first, then calls AdditionalLoading() once at the end.

Debounced batch refresh (server round-trip; default 1.5s debounce, per-config DebounceTime override):

  • Used when immediate mutation isn’t safe (filtered/sorted data, post-processing)
  • An RxJS Subject per entity name buffers every event raised during the window and delivers the batch to ProcessEntityEvents (the override seam; ProcessEntityEvent is a back-compat single-event wrapper)
  • Per config, the decision is an OR over the whole batch — judging only the last event would let an already-applied write mask a coalesced fresh-instance save:
    • ANY event not yet reflected in the array → one LoadSingleConfig refresh covers the batch
    • else, any event already applied (in-place save of the array’s own cached instance, manual push after create) → notifyAlreadyAppliedMutation emits DataChange$ + ObserveProperty without a refresh — the refresh is redundant but the notification is not
    • deletes of rows absent from the array stay silent (“already spliced by engine code” is indistinguishable from “never matched the Filter”, and notifying would assert phantom deletes to filtered configs’ observers) — engine code that manually splices must call notifyAlreadyAppliedMutation(config, 'delete', entity) itself; UserInfoEngine.UninstallApplication is the reference pattern
  • Delete membership checks key off the event payload’s pre-delete OldValues snapshot — BaseEntity.Delete() calls NewRecord() (regenerating the PK) before the debounced handler runs, so matching by the live entity’s key can never identify the deleted row
  • A transiently-failed refresh schedules a bounded, backed-off retry (scheduleEventRefreshRetry: 2s/4s, max MaxEventRefreshRetries = 2 overridable, one pending per property). Permission denials are recorded as loaded-empty and never retried

After any array change (immediate mutation, debounced refresh, or a skipped refresh whose changes were already applied), BaseEngine emits through DataChange$:

// Components can subscribe for reactive updates
engine.DataChange$.subscribe(change => {
if (change.configName === 'myConfig') {
this.refreshGrid();
}
});

When a remote-invalidate event arrives (from another server via GraphQL subscription), the engine applies changes directly to its in-memory arrays — no server round-trip needed when the event includes record data:

flowchart TD
    A[remote-invalidate event arrives] --> B{action?}
    B -->|save + recordData| C[applyRemoteRecordData]
    B -->|delete + primaryKeyValues| D[applyRemoteDelete]
    B -->|missing data| E[Fallback: LoadSingleConfig]

    C --> F{canUseImmediateMutation?<br/>skipAdditionalLoadingCheck=true}
    F -->|Yes| G[Create BaseEntity from JSON<br/>LoadFromData]
    F -->|No: Filter/OrderBy| E
    G --> H[Find by PrimaryKey → replace<br/>or append to array]
    H --> I[AdditionalLoading + NotifyDataChange]

    D --> J[Parse CompositeKey from JSON]
    J --> K[Find by PrimaryKey.Equals → splice]
    K --> I

    E --> L[RunView from server<br/>updates cache naturally]
    L --> I

For save events with record data, the engine creates a BaseEntity instance from the JSON, then upserts it into matching config arrays. This avoids a server round-trip entirely:

// Event payload includes full record data as JSON
const payload = event.payload as RemoteInvalidatePayload;
// payload.recordData = '{"ID":"abc","Name":"Updated Name",...}'
// Engine creates entity, loads JSON, and mutates arrays directly
const entity = await md.GetEntityObject(entityName, contextUser);
entity.LoadFromData(JSON.parse(payload.recordData));
// Uses canUseImmediateMutation(config, true) — skips AdditionalLoading check
// because applyRemoteRecordData calls AdditionalLoading() itself after all mutations

For delete events, the engine parses the primary key values and removes matching records:

// payload.primaryKeyValues = '[{"FieldName":"ID","Value":"abc"}]'
const targetKey = CompositeKey.FromKeyValuePairs(
rawPairs.map(kv => new KeyValuePair(kv.FieldName, kv.Value))
);
// Finds record by entity.PrimaryKey.Equals(targetKey) and splices it out

If direct apply fails (parse error, filtered config, etc.), the engine falls back to LoadSingleConfig which fetches fresh data from the server.

On-Demand Engine Loading (Opting Out of Startup)

Section titled “On-Demand Engine Loading (Opting Out of Startup)”

Most engines register with @RegisterForStartup() and load during the parallel startup batch managed by StartupManager. For engines whose data is large and only useful in narrow paths, that’s wasted work — the dataset gets fetched, parsed, and cached even when nothing in the session will ever query it.

GeoDataEngine is the canonical on-demand example: country/state-province reference data plus pre-parsed polygon geometry for point-in-polygon resolution. The dataset is ~3,000+ rows with BoundaryGeoJSON blobs, and parsing the polygons takes ~3–4 s synchronously on the main thread. Most tenants never use map choropleth or geocoding.

The pattern:

  1. Drop @RegisterForStartup from the engine. It will not auto-load.
  2. Document the caller contract in the class JSDoc: callers must await GeoDataEngine.Instance.Config(...) before invoking sync lookup methods.
  3. Optionally short-circuit Config() based on metadata. GeoDataEngine.Config checks whether any EntityInfo.SupportsGeoCoding is true and returns early if not — making the call near-free for tenants where no entity opts into geocoding, even when callers invoke it repeatedly.
  4. Update each caller to await Config() once at its async entry point. BaseEngine.Load already dedups via _loadingSubject — concurrent callers share a single in-flight load, and post-load calls return immediately.
// In GeoDataEngine.Config:
const entities = (provider ?? Metadata.Provider)?.Entities;
if (entities && !entities.some(e => e.SupportsGeoCoding)) {
LogStatus('GeoDataEngine: no entities have SupportsGeoCoding=1 — skipping load');
return;
}
// ... otherwise call super.Load(configs, ...)
// At the caller — gate the sync API behind one await:
public async SyncIfChanged(entity: BaseEntity, contextUser: UserInfo): Promise<GeocodeResult | null> {
if (resolvedMappings.length === 0) return null;
await GeoDataEngine.Instance.Config(false, contextUser); // idempotent + deduped
// ... now safe to use GeoDataEngine.Instance.ResolveCountry() etc.
}

Sync lookup methods stay sync in this pattern — they’re called after the awaited Config() returns. The empty-state behavior (maps not populated, all lookups return undefined) is the correct shape when no entity in the tenant has opted into the engine’s domain — callers already handle the not-found case, so the empty engine is safe to leave in place.

When to use this pattern instead of startup loading:

  • Dataset is large and most sessions don’t need it
  • Loading has a measurable main-thread cost (parsing, indexing, geometry building)
  • A metadata flag (SupportsGeoCoding, SupportsAuditLog, etc.) cleanly indicates whether any caller will need it

When startup loading is still right:

  • Dataset is small and lookups happen everywhere (e.g. AIEngineBase, UserInfoEngine)
  • The first lookup happens in a hot path that can’t easily await an async load (e.g. a synchronous render loop with no setup hook)

Check the Registry Before You Query (MJ Convention)

Section titled “Check the Registry Before You Query (MJ Convention)”

The flip side of engines caching eagerly: other code should reuse those caches instead of re-querying. In any process that bootstraps via StartupManager (MJAPI, MJCLI commands, mj-sync), every @RegisterForStartup engine has already loaded its entities into memory before your code runs. A consumer that issues its own unfiltered RunView for one of those entities doubles the DB round trips and the RAM, and trips the REDUNDANT DATA LOADING warning from BaseEngineRegistry’s load tracking.

BaseEngineRegistry (in @memberjunction/core) provides the reverse lookup:

import { BaseEngineRegistry } from '@memberjunction/core';
// "Best cache or null" — the common one-liner
const rows = BaseEngineRegistry.Instance.TryGetCachedRecords<UserInfo>('Users', { unfilteredOnly: true });
if (rows) { /* serve from memory */ } else { /* RunView fallback */ }
// Full matches — when you need to vet the donor's config before trusting it
const matches = BaseEngineRegistry.Instance.FindCachedEntity('MJ: AI Prompts', { unfilteredOnly: true });

FindCachedEntity returns, per loaded engine that caches the entity: the engine instance, the full BaseEnginePropertyConfig that produced the cache, and a live reference to the cached array (unfiltered full-set caches sort first).

Vetting a donor. Treat a match as the authoritative full set only when all of these hold:

  1. unfilteredOnly: true — a Filter means a subset, useless as a full cache.
  2. No OrderBy on the config — ordered configs fail canUseImmediateMutation (see Immediate Mutation vs Debounced Refresh), so the donor responds to entity events with a full refresh that reassigns the array property. If your usage outlives a single tick, resolve the array per-access via donor engine + config.PropertyName rather than capturing the reference.
  3. ResultType is not 'simple' (and records[0] instanceof BaseEntity when rows exist) — required if you’ll call .Get() / .PrimaryKey / .Save() on the rows.
  4. Not yourself — guard match.engine === this so an engine’s own slot from a prior run can’t masquerade as a donor.

The returned array is the donor’s live array — read it, don’t mutate it. For unfiltered/unordered/entity_object configs the donor’s BaseEntity event subscription keeps the array current on save/delete automatically (see Real-Time Array Updates), so a live reference stays fresh for free. Writing into a donor’s array requires understanding both sides’ dedup semantics — SyncMetadataEngine in @memberjunction/metadata-sync is the correctly-engineered exception and the reference implementation: its delegateEntityIfCached() partitions a dynamic entity set into “delegate to a donor” vs. “self-load”, resolves donor arrays per-access, and PK-dedups its writes against the donor’s event handler.

A simpler read-only example is AIEngine.RefreshActions() in @memberjunction/aiengine: ActionEngineBase and ActionEngineServer are separate singletons that each cache the same unfiltered 'MJ: Actions' set, so on the server one of them is usually already loaded. RefreshActions does BaseEngineRegistry.Instance.TryGetCachedRecords<MJActionEntity>('MJ: Actions', { unfilteredOnly: true }) and reuses whichever sibling already holds the set, falling back to loading ActionEngineBase only on a miss — eliminating a redundant 6-entity load (and the duplicate-RunView telemetry warning) at agent/job startup.

Why this is a convention, not a one-off optimization: donors are discovered dynamically at runtime. Consumers get faster automatically as new engines ship — no version coupling, no hardcoded donor lists. When no engine caches the entity, the lookup returns empty and the consumer falls back to its own RunView/Load, so adoption is always graceful.

The same convention is summarized in the repo root CLAUDE.md under “Check the Registry Before You Query”.

LocalCacheManager Independent Event Handling

Section titled “LocalCacheManager Independent Event Handling”

LocalCacheManager subscribes to the same remote-invalidate MJGlobal events independently from BaseEngine. It handles its own RunView cache updates without coordination:

flowchart LR
    A[GraphQL Subscription<br/>remote-invalidate] -->|MJGlobal event| B[BaseEngine<br/>in-memory arrays]
    A -->|MJGlobal event| C[LocalCacheManager<br/>RunView caches]
  • Save with recordData: Looks up entity PK fields from Metadata, builds CompositeKey from the record data, and calls UpsertSingleEntity on all matching unfiltered fingerprints. Filtered fingerprints are invalidated.
  • Delete with primaryKeyValues: Parses the CompositeKey from JSON and calls RemoveSingleEntity on all matching fingerprints.
  • Missing data or error: Falls back to InvalidateRunViewResult for each fingerprint.

This separation means engines don’t need to worry about cache sync — LocalCacheManager is self-contained.


Smart Cache Validation (RunViewsWithCacheCheck)

Section titled “Smart Cache Validation (RunViewsWithCacheCheck)”

Important: Smart cache validation is used only by client-side providers (e.g., GraphQLDataProvider in the browser). Server-side providers (SQLServerDataProvider, PostgreSQLDataProvider) skip this entirely and trust the cache completely — see Server-Side vs Client-Side Cache Behavior above.

When CacheLocal is enabled on a client-side provider, MemberJunction uses a smart cache check protocol to minimize data transfer between client and server.

sequenceDiagram
    participant Client as Browser
    participant Server as MJAPI
    participant DB as Database

    Client->>Server: RunViewsWithCacheCheck<br/>{params, cacheStatus: {maxUpdatedAt, rowCount}}

    Server->>DB: SELECT MAX(__mj_UpdatedAt), COUNT(*)<br/>WHERE [same filter]

    alt maxUpdatedAt matches AND rowCount matches
        Server-->>Client: status: 'current'
        Note over Client: Use cached data.<br/>Zero data transfer.
    else rowCount changed significantly
        Server->>DB: SELECT * WHERE [same filter]
        Server-->>Client: status: 'stale', results: [full data]
        Note over Client: Replace cache entirely.
    else Only some rows changed (maxUpdatedAt differs, rowCount close)
        Server->>DB: SELECT * WHERE __mj_UpdatedAt > cachedMaxUpdatedAt
        Server-->>Client: status: 'differential'<br/>{updatedRows, deletedRecordIDs}
        Note over Client: Merge changes into cache.
    end
  1. current: Server’s MAX(__mj_UpdatedAt) matches client’s maxUpdatedAt AND COUNT(*) matches client’s rowCount → nothing changed
  2. differential: Timestamps differ but row count is close → fetch only rows updated after maxUpdatedAt, plus check for deletions
  3. stale: Row count changed significantly or no valid cache status → full refresh
Response TypeData TransferUse Case
currentZero (just status)Data hasn’t changed since last load
differentialMinimal (only changed rows)A few records were updated
staleFull datasetMany changes or first load

For engines that load metadata tables with hundreds of rows that rarely change, the current response dramatically reduces server load and network traffic.

Server-Side Pre-Check for RunViewsWithCacheCheck

Section titled “Server-Side Pre-Check for RunViewsWithCacheCheck”

When a RunViewsWithCacheCheck request arrives at the server, the server checks its own LocalCacheManager before hitting the database — even for the lightweight MAX/COUNT validation query. This is a four-phase pipeline:

flowchart TD
    A["Client sends RunViewsWithCacheCheck<br/>(N items, some with cacheStatus)"] --> B["Phase 1: Items WITH cacheStatus<br/>Check server LocalCacheManager"]

    B --> C{Server cache hit?}
    C -->|"Hit + maxUpdatedAt/rowCount match"| D["Return status: 'current'<br/>Zero DB queries"]
    C -->|"Hit but stale"| E["Serve from server cache<br/>(client will get fresh data)"]
    C -->|Miss| F["Phase 2: DB validation<br/>(only for server cache misses)"]

    F --> G["SELECT MAX, COUNT<br/>Compare with client cacheStatus"]

    A --> H["Phase 3: Items WITHOUT cacheStatus<br/>(empty client cache)"]
    H --> I{Server cache hit?}
    I -->|Hit| J["Serve from server cache<br/>Zero DB queries"]
    I -->|Miss| K["Phase 4: Full DB query<br/>Store result in server cache"]

This means:

  • Second client connecting with an empty cache gets all data from server cache — zero DB queries
  • Returning client with valid cache gets status: 'current' — zero DB queries and zero data transfer
  • Only genuinely new queries (never seen before) hit the database

Smart cache validation eliminates almost all data transfer on warm loads, but it still costs one round-trip per RunViews call. During app startup, dozens of engines fire in parallel — each making its own RunViewsWithCacheCheck request — which adds up to noticeable TTI on the warm path. Fast-start is a deterministic optimization that lets engines trust their local IndexedDB caches without per-view smart-cache-check round-trips, gated by a single pre-validation step before engines run.

The Two-Tier Cache for Client-Side Startup

Section titled “The Two-Tier Cache for Client-Side Startup”

A client-side provider holds two distinct caches in browser storage:

CacheWhatBackendPurpose
Framework metadataAllMetadata (entity definitions, fields, applications, etc.)IndexedDB (gzip-compressed)Powers Metadata.Provider.Entities, entity.Fields, etc. — needed before any engine can resolve a view
Engine view resultsPer-RunView row sets, keyed by fingerprintIndexedDB (uncompressed)What each engine’s CacheLocal: true config consumes

Both are loaded at the start of provider.Config(). The framework metadata cache is a single-blob round-trip; the engine view caches are loaded lazily by each engine’s RunViews call. Fast-start is the policy that decides whether to trust those engine view caches without a server round-trip during the initial load.

Fast-start is controlled by two flags on ProviderBase:

public static FastStartupMode: boolean = true; // master switch
private static _fastStartupConsumed = false; // closes the window after startup

While the window is open (FastStartupMode === true && !_fastStartupConsumed), PreRunViews short-circuits the smart-cache-check path: if all params have an entry in LocalCacheManager, the cached results are returned without contacting the server. If any param is missing, fast-start falls through to the normal smart-cache-check path for that batch.

The window is closed by ProviderBase.ConsumeFastStartupMode(), which StartupManager.Startup() calls after every registered engine has reported in. After that point, all subsequent RunViews calls go through normal smart-cache-check.

Pre-Validation: Making Fast-Start Deterministic

Section titled “Pre-Validation: Making Fast-Start Deterministic”

Trusting the cache without server validation is only safe if we know nothing has changed since this client last loaded. That’s what preValidateAndRefresh does — one batched timestamp round-trip before engines fire:

sequenceDiagram
    participant App as setupGraphQLClient
    participant Provider as GraphQLDataProvider
    participant Server as MJAPI
    participant SM as StartupManager
    participant Engines as Registered Engines

    App->>Provider: Config(config)
    Provider->>Provider: LoadLocalMetadataFromStorage<br/>(IndexedDB → decompress → parse)
    Provider-->>App: Metadata loaded from cache

    App->>Provider: preValidateAndRefresh()
    Provider->>Server: GetLatestMetadataUpdates<br/>(timestamp + rowCount per entity)
    Server-->>Provider: Latest timestamps

    alt Local timestamps current
        Provider-->>App: Fast-start engaged ⚡
    else Local timestamps stale
        Provider->>Server: GetAllMetadata (full refresh)
        Server-->>Provider: Fresh metadata
        Provider->>Provider: ConsumeFastStartupMode()<br/>(disable fast-start)
        Provider-->>App: Engines will smart-cache-check
    end

    App->>SM: Startup()
    SM->>Engines: Config() in parallel
    Note over Engines: If fast-start engaged: trust IndexedDB caches<br/>If disabled: smart-cache-check per RunViews
    SM->>Provider: ConsumeFastStartupMode() after all engines complete

Cost on the warm-current path: one batched timestamp fetch (~50–200 ms depending on RTT). Far less than the savings from skipping per-view smart-cache-checks across dozens of engines.

Cost on the warm-stale path: pre-validation triggers a full metadata refresh (GetAllMetadata) and disables fast-start, so engines fall through to smart-cache-check. This is slower than the cached-current path but eliminates any window in which stale data could be served to the UI.

On pre-validation failure (network error, server unreachable): fast-start is disabled defensively — engines fall through to smart-cache-check rather than trusting potentially-stale local caches against an unknown server state.

The framework metadata blob is large — 6–10 MB of JSON for a typical tenant. SaveLocalMetadataToStorage compresses it via the native CompressionStream('gzip') API before writing to IndexedDB; the compressed blob is base64-encoded for storage and decompressed via DecompressionStream('gzip') on read. Compression takes ~50–150 ms on a modern desktop, decompression ~30–80 ms.

Why compress? The savings come primarily from JSON.parse time on cold/warm load. Parsing a ~1 MB string is dramatically faster than parsing the raw 10 MB string — and JSON.parse is a synchronous main-thread operation, so this directly affects TTI. Secondary benefits include faster IndexedDB reads and better headroom on storage-constrained devices (iOS Safari, mobile).

A fallback path serializes uncompressed ('json') when CompressionStream is unavailable. The format is recorded in a separate IndexedDB key (localStorageFormatKey) so the load path can pick the right deserialization branch.

Naming note: The provider interface and storage keys use names like LocalStorageProvider, _localStorageProvider, and localStorageRootKey. The actual backend in browsers is IndexedDB, not browser localStorage — see BrowserIndexedDBStorageProvider in GraphQLDataProvider. The naming predates the IndexedDB switch and is kept for backwards compatibility.

Every metadata access reads from the in-memory _localMetadata object (held on the provider instance for the session lifetime), so decompress/parse only run once per cold load — not per access.

Per-Item Failure Tolerance in Metadata Deserialization

Section titled “Per-Item Failure Tolerance in Metadata Deserialization”

MetadataFromSimpleObject deserializes the cached metadata into typed EntityInfo / ApplicationInfo / etc. instances. A single corrupt or shape-incompatible row (from an old cache after a schema change, for example) used to throw and void the entire local cache. The deserializer now catches per-item errors, logs them, and continues — partial metadata is preferred over no metadata, since the next backgroundValidateAndRefresh will repair it.

export async function setupGraphQLClient(config: GraphQLProviderConfigData): Promise<GraphQLDataProvider> {
const provider = new GraphQLDataProvider();
SetProvider(provider);
await provider.Config(config); // loads IndexedDB metadata cache
await provider.preValidateAndRefresh(); // one timestamp round-trip;
// refreshes + disables fast-start if stale
MJGlobal.Instance.RaiseEvent({ event: MJEventType.LoggedIn, ... });
await StartupManager.Instance.Startup(); // engines fire in parallel — fast-start
// engaged or not based on pre-validation
return provider;
}

StartupManager.Startup calls ProviderBase.ConsumeFastStartupMode() itself once every engine has reported in, closing the window.

ScenarioBehaviorOutcome
Cold load (no IndexedDB cache)All engines hit cache missesFast-start can’t engage — normal smart-cache-check populates caches
Warm load, framework metadata currentPre-validation succeeds, fast-start engagedEngines trust caches, ~zero round-trips during startup
Warm load, framework metadata stalePre-validation refreshes + disables fast-startEngines smart-cache-check per view, picking up any stale entries
Pre-validation fails (network error)Fast-start disabled defensivelyEngines smart-cache-check; if those also fail, normal error handling kicks in

Server-Side Opportunistic Cache (All RunView/RunViews Calls)

Section titled “Server-Side Opportunistic Cache (All RunView/RunViews Calls)”

Beyond the explicit CacheLocal and RunViewsWithCacheCheck paths, the server opportunistically checks its LocalCacheManager for every RunView/RunViews call — even when CacheLocal is not set by the caller.

On the server (TrustLocalCacheCompletely = true), PreRunView and PreRunViews check the cache for a matching fingerprint regardless of the CacheLocal flag. If the data exists in cache (put there by engines, RunViewsWithCacheCheck, or auto-cache), it’s returned immediately with zero DB queries.

flowchart TD
    A["GraphQL RunViewsQuery arrives<br/>(no CacheLocal flag)"] --> BA{"BypassCache?"}
    BA -->|Yes| H["Execute DB query<br/>(skip all caching)"]
    BA -->|No| B["ProviderBase.PreRunView"]
    B --> C{"TrustLocalCacheCompletely<br/>AND cache initialized?"}
    C -->|No| D["Skip cache check<br/>(client-side behavior)"]
    C -->|Yes| E["Generate fingerprint<br/>Check LocalCacheManager"]
    E --> F{Cache hit?}
    F -->|Yes| G["Return cached data<br/>Zero DB queries ✅"]
    F -->|No| H
    H --> I{"shouldAutoCache?<br/>(small + unfiltered<br/>+ NOT BypassCache)"}
    I -->|Yes| J["Store in cache<br/>for future hits 📦"]
    I -->|No| K["Return without caching"]
OperationConditionBehavior
Cache Read (check before DB)TrustLocalCacheCompletely is trueAlways checks — if data is there, serve it
Cache Write (store after DB)CacheLocal is explicitly set, OR auto-cache criteria metSelective — only stores when intent is clear or data is safe to cache

This asymmetry is intentional:

  • Reading from cache is always safe — if data is there, it’s guaranteed fresh by BaseEntity event invalidation
  • Writing to cache must be selective to avoid memory bloat from large ad-hoc queries (e.g., 10K customer rows browsed in a grid)

When a RunView result meets all of the following criteria, it’s automatically stored in the server cache even without an explicit CacheLocal flag:

CriterionWhy
TrustLocalCacheCompletely is trueServer-side only
Result is successfulDon’t cache errors
Row count ≤ ServerAutoCacheMaxRows (default: 250)Small reference/lookup data
No ExtraFilterUnfiltered queries are safe for in-place upsert
No OrderByUnsorted queries don’t need sort-order maintenance
// Configurable threshold (on ProviderBase)
ProviderBase.ServerAutoCacheMaxRows = 250; // default

Why unfiltered + unsorted only?

When a BaseEntity is saved or deleted, LocalCacheManager must update all cached fingerprints for that entity. For unfiltered caches, it can safely do an in-place upsert or removal — the record always belongs in the result set. For filtered caches, it cannot evaluate arbitrary SQL predicates (which may include subqueries, functions, JOINs, etc.), so it must conservatively invalidate the entire cached result. Similarly, sorted caches would need SQL-compatible sort evaluation to maintain correct order.

flowchart TD
    A["BaseEntity.Save() fires event"] --> B["LocalCacheManager receives event"]
    B --> C["Find all cached fingerprints<br/>for this entity"]
    C --> D{isFilteredFingerprint?}
    D -->|"No (filter = '_' or empty)"| E["UpsertSingleEntity<br/>In-place update ✅"]
    D -->|"Yes (has ExtraFilter)"| F["InvalidateRunViewResult<br/>Blow away cache entry 🗑️"]
    F --> G["Next request repopulates<br/>from database"]

By limiting auto-cache to unfiltered+unsorted results, we guarantee that every auto-cached entry can be maintained in-place without ever serving stale data.

QueryRowsAuto-Cached?Why
RunView('Roles')15YesSmall, no filter, no sort
RunView('Entity Fields')200YesUnder threshold, no filter
RunView('Users', filter: "Status='Active'")50NoHas ExtraFilter
RunView('Customers')5,000NoExceeds threshold
RunView('AI Models', orderBy: 'Name')12NoHas OrderBy

The BypassCache parameter on RunViewParams provides a per-query escape hatch that skips all server-side caching — both the PreRunView cache check (read) and the post-query auto-cache storage (write). The query always hits the database and the result is never stored in cache.

// Always hits the database, even if cached results exist for this fingerprint
const result = await rv.RunView({
EntityName: 'Members',
ExtraFilter: 'State IS NOT NULL',
BypassCache: true,
IgnoreMaxRows: true
});

When to use BypassCache:

ScenarioWhy
Maintenance/audit actionsNeed to find records inserted via direct SQL that bypassed BaseEntity.Save() and its cache invalidation events
Scheduled geocodingMust detect records missing from RecordGeoCode that were bulk-imported outside the normal save pipeline
Data validation jobsNeed ground-truth DB state, not potentially stale cached data
One-time migration scriptsTemporary queries that shouldn’t pollute the cache

How it differs from CacheLocal:

ParameterControlsDefaultPurpose
CacheLocalOpt-IN to cachingfalse”I want this query’s results cached for faster subsequent calls”
BypassCacheOpt-OUT of all cachingfalse”I need true DB state regardless of what’s in cache”

Both can coexist — BypassCache: true takes precedence and skips all caching even if CacheLocal: true is also set.


In multi-server deployments, Redis pub/sub ensures all MJAPI instances have consistent cache state.

sequenceDiagram
    participant ExA as MJExplorer-A
    participant SrvA as MJAPI-A
    participant Redis as Redis
    participant SrvB as MJAPI-B
    participant ExB as MJExplorer-B

    ExA->>SrvA: entity.Save()
    SrvA->>SrvA: LocalCacheManager updates cache
    SrvA->>Redis: PUBLISH mj:__pubsub__<br/>{CacheKey, Action:'set', SourceServerId:'A'}

    Note over SrvA: Self-message filter:<br/>Server-A ignores its own pub/sub

    Redis->>SrvB: SUBSCRIBE receives message
    SrvB->>SrvB: Filters: SourceServerId != myUUID ✓
    SrvB->>SrvB: OnCacheChanged callback fires
    SrvB->>SrvB: LocalCacheManager.DispatchCacheChange()
    SrvB->>SrvB: Invalidates/updates cached data

Each MJAPI instance has a unique ProcessUUID (from MJGlobal.Instance.ProcessUUID). When publishing to Redis, the server includes its ProcessUUID as SourceServerId. When receiving pub/sub messages, the handler skips messages where SourceServerId matches the local ProcessUUID:

// In Redis subscriber callback
if (message.SourceServerId === MJGlobal.Instance.ProcessUUID) {
return; // Skip our own messages
}

The Redis provider exposes an OnCacheChanged callback that fires when remote changes are received:

// Wired in MJServer index.ts
redisProvider.OnCacheChanged = (event) => {
const entityName = event.CacheKey?.split('|')[0];
if (entityName) {
// Publish to GraphQL subscribers (for browser notification)
PubSubManager.Instance.Publish(CACHE_INVALIDATION_TOPIC, {
entityName,
action: event.Action || 'save',
sourceServerId: event.SourceServerId || 'unknown',
originSessionId: null, // Remote events have no session
timestamp: new Date(),
});
}
};

Server-to-Browser Synchronization (GraphQL Subscriptions)

Section titled “Server-to-Browser Synchronization (GraphQL Subscriptions)”

The final piece: pushing cache invalidation events from MJAPI servers to connected browser clients via GraphQL WebSocket subscriptions.

Server side (CacheInvalidationResolver.ts):

type Subscription {
cacheInvalidation: CacheInvalidationNotification!
}
type CacheInvalidationNotification {
EntityName: String!
PrimaryKeyValues: String # JSON array of {FieldName, Value} pairs (CompositeKey)
Action: String! # 'save' or 'delete'
SourceServerID: String! # ProcessUUID of originating server
OriginSessionID: String # Session of the user who made the change (nullable)
Timestamp: Timestamp!
RecordData: String # Full entity data as JSON (save events only)
}

This is a broadcast subscription — no filtering by session. Every connected browser receives every event. Filtering happens client-side.

RecordData: For save events, the server includes the full entity record as JSON (entity.GetAll()). This allows browsers to update their in-memory arrays and caches directly — no server round-trip needed. For delete events, RecordData is omitted (only the primary key values are needed to remove the record).

PathTriggerOriginSessionIDScenario
LocalResolverBase after save/deleteSet to userPayload.sessionIdSingle-server + multi-server
RemoteRedis OnCacheChanged callbacknullMulti-server only

The originating browser already knows about its own save via the local MJGlobal event. To prevent a redundant server-side re-fetch:

// In GraphQLDataProvider.SubscribeToCacheInvalidation()
next: (data) => {
const event = data?.cacheInvalidation;
if (!event) return;
// Skip events from our own session
if (event.OriginSessionID && event.OriginSessionID === this.sessionId) {
console.log(`Skipping self-originated cache invalidation for "${event.EntityName}"`);
return;
}
// Raise MJGlobal event for both BaseEngine and LocalCacheManager to handle
const baseEntityEvent: BaseEntityEvent = {
type: 'remote-invalidate',
entityName: event.EntityName,
baseEntity: null,
payload: {
primaryKeyValues: event.PrimaryKeyValues,
action: event.Action, // 'save' or 'delete'
sourceServerId: event.SourceServerID,
timestamp: event.Timestamp,
recordData: event.RecordData, // Full entity JSON for saves (undefined for deletes)
},
};
MJGlobal.Instance.RaiseEvent({
event: MJEventType.ComponentEvent,
eventCode: BaseEntity.BaseEventCode,
args: baseEntityEvent,
component: this,
});
}

The subscription is automatically established during MJExplorer initialization:

Auth → initializeGraphQL() → setupGraphQLClient()
→ SubscribeToCacheInvalidation()

The remote-invalidate event type was added to BaseEntityEvent to support cross-server invalidation:

type: 'new_record' | 'save' | 'delete' | 'load_complete' | 'transaction_ready'
| 'save_started' | 'delete_started' | 'load_started'
| 'remote-invalidate' // Cross-server cache invalidation
| 'other';
baseEntity: BaseEntity | null; // null for remote-invalidate (no local entity)
entityName?: string; // Entity name (used when baseEntity is null)
payload?: RemoteInvalidatePayload; // Included for remote-invalidate events

The RemoteInvalidatePayload interface:

export interface RemoteInvalidatePayload {
primaryKeyValues: string | null; // JSON array of {FieldName, Value} pairs
action: 'save' | 'delete'; // What happened to the entity
sourceServerId: string; // Which server originated the change
timestamp: string; // When the change occurred
recordData?: string; // Full entity JSON (save events only)
}

Angular components can subscribe to MJGlobal events to react in real-time when data changes on other browsers/servers:

@Component({ ... })
export class ModelManagementComponent implements OnDestroy {
private destroy$ = new Subject<void>();
ngOnInit() {
// Subscribe to cache invalidation events
MJGlobal.Instance.GetEventListener(false).pipe(
takeUntil(this.destroy$),
filter(e => e.event === MJEventType.ComponentEvent
&& e.eventCode === BaseEntity.BaseEventCode),
filter(e => {
const entityEvent = e.args as BaseEntityEvent;
return entityEvent.type === 'remote-invalidate'
&& entityEvent.entityName === 'MJ: AI Models';
}),
// Small delay to let BaseEngine finish its array mutation
delay(500),
).subscribe(() => {
// Re-read from engine's in-memory arrays (already updated)
this.Models = AIEngineBase.Instance.Models;
this.cdr.detectChanges();
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

This pattern gives instant UI updates with zero network calls — the engine’s arrays are already mutated by the time the component reads them.


FeatureNo RedisWith Redis
L1 cache (BaseEngine in-memory arrays)YesYes
L2 cache (IndexedDB/localStorage in browser)YesYes
L3 cache (server-side persistent cache)In-memory only (lost on restart)Persistent across restarts
Same-browser instant updates (local MJGlobal events)YesYes
Cross-browser updates (same server, GraphQL subscription)YesYes
Cross-server updates (multiple MJAPI instances)NoYes
Zero round-trip remote updates (RecordData in subscription)YesYes
Session deduplication (originating browser skips own events)YesYes

Key takeaway: Redis is only required for multi-server coordination. A single MJAPI instance gets real-time cross-browser push updates, session deduplication, zero-round-trip cache updates, and composite key support — all without Redis.

graph TD
    subgraph "Browser A (saves)"
        BA_Engine[BaseEngine] -->|Local MJGlobal| BA_Engine
    end

    subgraph "Browser B (other user)"
        BB_Engine[BaseEngine]
    end

    subgraph "MJAPI"
        Server[ResolverBase]
        Server -->|"CACHE_INVALIDATION + RecordData<br/>GraphQL WS"| BB_Engine
        Server -->|"CACHE_INVALIDATION<br/>WS + skip (same session)"| BA_Engine
    end
  • Browser A updates instantly via local MJGlobal events (no network call)
  • Browser B receives CACHE_INVALIDATION with RecordData via WebSocket
  • Browser B’s BaseEngine applies the record data directly to arrays — zero server round-trip
  • Browser B’s LocalCacheManager updates its RunView caches independently
  • No Redis required — works out of the box
  • Server uses InMemoryLocalStorageProvider (cache lost on restart)

Same as above, plus:

  • Redis provides persistent L3 cache that survives server restarts
  • Cache data shared across worker processes (if using cluster mode)
  • Foundation for future scale-out to multiple servers
  • To enable: set REDIS_URL in .env (e.g., REDIS_URL=redis://localhost:6379)
graph TD
    subgraph "Browser A → MJAPI-A"
        BA[Browser A] --> SA[MJAPI-A]
        BC[Browser C] --> SA
    end

    subgraph "Browser B → MJAPI-B"
        BB[Browser B] --> SB[MJAPI-B]
    end

    SA <-->|Redis Pub/Sub| Redis[(Redis)]
    SB <-->|Redis Pub/Sub| Redis

    SA -->|"GraphQL WS + RecordData"| BA
    SA -->|"GraphQL WS + RecordData"| BC
    SB -->|"GraphQL WS + RecordData"| BB
  • Browser A saves → MJAPI-A handles locally + publishes to Redis
  • MJAPI-B receives via Redis → publishes CACHE_INVALIDATION (with RecordData) to Browser B
  • Browser B applies changes directly — no round-trip back to MJAPI-B
  • MJAPI-A publishes CACHE_INVALIDATION to Browser C (same server, different user)
  • Browser A skips the notification (OriginSessionID match)
graph TD
    LB[Load Balancer] --> SA[MJAPI-A]
    LB --> SB[MJAPI-B]
    LB --> SC[MJAPI-C]

    SA <--> Redis[(Redis)]
    SB <--> Redis
    SC <--> Redis

    SA -->|WebSocket| Browsers_A[Connected Browsers]
    SB -->|WebSocket| Browsers_B[Connected Browsers]
    SC -->|WebSocket| Browsers_C[Connected Browsers]

All servers share cache state via Redis. All browsers get real-time updates via their server’s WebSocket. The system is horizontally scalable — add more MJAPI instances behind the load balancer as traffic grows. Record data flows through Redis pub/sub → GraphQL subscription → direct array mutation — no extra round-trips at any tier.

Important: WebSocket connections are sticky (a browser stays connected to the same MJAPI instance for the duration of its session). The load balancer should be configured for WebSocket support (e.g., HAProxy with option http-server-close).


PubSubManager is a server-side singleton that holds a reference to the type-graphql PubSubEngine, allowing any code (not just resolvers) to publish GraphQL subscription events.

The standard type-graphql pattern injects @PubSub() pubSub: PubSubEngine into resolver methods. But cache invalidation events originate from:

  • Redis pub/sub callbacks (outside resolver context)
  • MJGlobal event handlers (outside resolver context)
  • ResolverBase after save/delete (has access but uses PubSubManager for consistency)
import { PubSubManager, CACHE_INVALIDATION_TOPIC } from '@memberjunction/server';
// Publish from anywhere in server code
PubSubManager.Instance.Publish(CACHE_INVALIDATION_TOPIC, {
entityName: 'Users',
action: 'save',
sourceServerId: MJGlobal.Instance.ProcessUUID,
originSessionId: null, // null for remote events, sessionId for local
timestamp: new Date(),
});
// In MJServer index.ts
import { PubSub } from 'graphql-subscriptions';
const pubSub = new PubSub();
PubSubManager.Instance.SetPubSubEngine(pubSub);
// Pass to type-graphql schema builder
const schema = buildSchemaSync({
resolvers: [...],
pubSub,
validate: false,
});

LocalCacheManager provides comprehensive statistics for monitoring and debugging.

const stats = LocalCacheManager.Instance.GetStats();
// Returns:
{
totalEntries: number; // Total cached items
totalSizeBytes: number; // Total cache size
byType: {
runview: { count: number; sizeBytes: number };
runquery: { count: number; sizeBytes: number };
dataset: { count: number; sizeBytes: number };
};
oldestEntry: number; // Timestamp of oldest entry
newestEntry: number; // Timestamp of newest entry
hits: number; // Total cache hits
misses: number; // Total cache misses
}
const hitRate = LocalCacheManager.Instance.GetHitRate();
// Returns percentage (0-100) of cache hits vs total lookups
const entries = LocalCacheManager.Instance.GetAllEntries();
// Returns array of CacheEntryInfo for all cached items
// Useful for building cache management dashboards
// Check cache status for a specific RunView fingerprint
const status = LocalCacheManager.Instance.GetRunViewCacheStatus(fingerprint);
// Returns: { maxUpdatedAt: string, rowCount: number } | null

SettingDefaultDescription
enabledtrueMaster switch for caching
maxSizeBytes50 MBMaximum cache size before eviction
maxEntries1,000Maximum number of cached results
defaultTTLMs300,000 (5 min)Time-to-live for cached entries
evictionPolicy'lru'Eviction strategy: lru, lfu, or fifo
SettingDefaultDescription
ServerAutoCacheMaxRows250Max row count for auto-caching unfiltered RunView results (0 = disabled)
DedupLingerMs5,000 msHow long resolved RunViews results stay available for instant replay. Entries are write-invalidated: a BaseEntity save/delete/remote-invalidate for any entity a batch’s params touch drops its lingered AND in-flight entries, so an identical call after a write always re-executes instead of replaying pre-write rows. Params addressed only by ViewID/ViewName (no EntityName) are not tracked.
SettingDefaultDescription
FastStartupMode (static)trueMaster switch for the fast-start window. When false, engines always go through smart-cache-check on warm loads.
MinRefreshCheckIntervalMs (static)variesMinimum gap between successive CheckToSeeIfRefreshNeeded calls — prevents the pre-validation timestamp fetch from being fired repeatedly within a short window.

Set REDIS_URL environment variable on MJAPI:

Terminal window
REDIS_URL=redis://localhost:6379
# or with auth:
REDIS_URL=redis://user:password@redis-host:6379
SettingDefaultDescription
EntityEventDebounceTime1,500 msDebounce for filtered/sorted config refresh
CacheLocalTTL300,000 ms (5 min)Per-config TTL override
SettingDefaultDescription
BaseEnginePropertyConfig.ResultTypeundefinedPer-config override. undefined resolves to EngineDefaultResultType (engine-wide).
EngineDefaultResultType (getter)'entity_object'Engine-wide default. Override in subclasses returning 'simple' for read-only engines to skip BaseEntity construction.
SettingDefaultDescription
Keep-alive30 secondsPing interval
Retry attempts3WebSocket reconnection attempts
Client max age30 minutesWebSocket client recreation interval
Subscription idle timeout10 minutesCleanup after no activity

  1. Check if AutoRefresh is enabled on the BaseEngine config
  2. Check if entity name matches — names are case-insensitive but must match exactly
  3. Check debounce timing — debounced refreshes have a 1.5s delay
  4. Check if CacheLocal is enabled — without it, no LocalCacheManager involvement
  5. Check immediate mutation conditions — if Filter/OrderBy is set, uses debounced path
  1. Verify Redis connection: Check MJAPI logs for "Redis cache provider connected"
  2. Check DBSIZE: redis-cli DBSIZE should be > 0 after engines load
  3. Check storage provider migration: Look for "Migrated N/N entries to new storage provider" in logs
  4. Verify pub/sub subscribers: redis-cli PUBSUB NUMSUB mj:__pubsub__ should show subscriber count
  5. Check self-message filtering: Verify different MJAPI instances have different ProcessUUIDs
  1. Check subscription established: Browser console should show "Cache invalidation subscription active"
  2. Check WebSocket connection: Look for WebSocket errors in browser console
  3. Verify PubSubManager wired: Server logs should show PubSubEngine configured
  4. Check session dedup: If you’re testing from the same browser that made the change, events are intentionally skipped (log: "Skipping self-originated cache invalidation")

If DBSIZE = 0 after server startup, the storage provider hot-swap may have failed:

  1. Engines load before Redis connects → data goes to in-memory
  2. SetStorageProvider() should migrate entries → check for "Migrated" log
  3. If missing, verify SetStorageProvider is called after Redis connects in index.ts

”Converting circular structure to JSON” Errors During Startup

Section titled “”Converting circular structure to JSON” Errors During Startup”

This was a known bug (now fixed) caused by PostRunView/PostRunViews calling TransformSimpleObjectToEntityObject() before caching. BaseEntity objects contain RxJS Subject instances (_eventSubject) with circular subscriber references that break JSON.stringify.

Fix: Cache storage now happens before entity transformation. The cache stores plain JSON-serializable objects, and TransformSimpleObjectToEntityObject is called on cache read when ResultType === 'entity_object'.

If you see these errors, ensure you’re on the latest version of @memberjunction/core. The ordering in PostRunView and PostRunViews must be:

  1. Cache plain objects via LocalCacheManager.SetRunViewResult()
  2. Transform to entity objects via TransformSimpleObjectToEntityObject()
  3. Run post-hooks via RunPostRunViewHooks()
  1. Composite PK entities: Check that primary key format matches Field1|Value1||Field2|Value2
  2. maxUpdatedAt mismatch: Ensure __mj_UpdatedAt columns are present and populated
  3. rowCount drift: rowCount is always derived from results.length — if it seems wrong, the actual data array may be corrupted (clear cache)

Open browser DevTools:

// Check IndexedDB contents
const db = await indexedDB.open('MJ_Metadata', 3);
// Navigate to: Application > IndexedDB > MJ_Metadata
// Check cache stats from console
LocalCacheManager.Instance.GetStats()
LocalCacheManager.Instance.GetHitRate()
LocalCacheManager.Instance.GetAllEntries()

Datasets (GetDatasetByName and GetDatasetStatusByName) are a batch-loading mechanism used heavily at startup — the MJ_Metadata dataset alone loads ~22 entity types. Without caching, every client connection re-executes all of those SQL queries even though the data hasn’t changed.

The dataset caching layer in GenericDatabaseProvider integrates with LocalCacheManager to avoid redundant database queries. It uses the same fingerprint system as RunView, so cache entries are shared between both paths.

GetDatasetByName — Cache-Aware Resolution

Section titled “GetDatasetByName — Cache-Aware Resolution”

When a dataset is requested, each dataset item is resolved individually:

  1. Phase 1 — Cache Check: For each dataset item, generate a LocalCacheManager fingerprint using the item’s entity name and combined filter (WhereClause + ItemFilter). Check cache for a hit.
  2. Phase 2 — SQL for Misses Only: Build and execute SQL (SELECT columns FROM baseView WHERE filter) only for items that had cache misses. Items with cache hits skip SQL entirely.
  3. Phase 3 — Write-Through: After SQL execution, store results in LocalCacheManager via SetRunViewResult so future requests find them cached.
  4. Merge: Results are assembled in original item order regardless of hit/miss status.
First client connect (after server bootstrap):
Dataset items: 22 → Cache hits: 22, SQL queries: 0, elapsed: <1ms
Server cold start (no cache):
Dataset items: 22 → Cache hits: 0, SQL queries: 22 (batched), elapsed: ~400ms
Next request: Cache hits: 22, SQL queries: 0, elapsed: <1ms

GetDatasetStatusByName — Cache-Derived Status

Section titled “GetDatasetStatusByName — Cache-Derived Status”

Status checks (MAX(dateField) and COUNT(*) per item) are also cache-aware:

  1. Cache Hit: Derive LatestUpdateDate and TotalRowCount directly from the in-memory cached rows — sub-millisecond.
  2. Cache Miss: Fall back to the traditional SQL status query.

This eliminates the “status round-trip” that previously preceded every dataset fetch.

Dataset items generate fingerprints identical to RunView queries:

const fingerprint = cache.GenerateRunViewFingerprint(
{ EntityName: entityName, ExtraFilter: effectiveFilter } as RunViewParams,
this.InstanceConnectionString
);

This means:

  • RunView populates dataset cache: If a RunView call loads Entities with the same filter, a subsequent dataset request finds it cached.
  • Dataset populates RunView cache: After the dataset loads Entities, any RunView call with the same filter gets a cache hit.
  • Entity event invalidation: When an entity record changes, LocalCacheManager invalidates entries for that entity — both RunView and dataset cache entries are cleared together.
MetricBeforeAfter (warm cache)
DB queries per client connect~44 (22 data + 22 status)0
Dataset response time~400-500ms<1ms
DB load at 100 concurrent users4,400 queries0 queries

The server bootstrap (first boot) still hits the database to populate the cache, but every subsequent client connection — including the dozens that happen during peak usage — gets assembled entirely from memory.

Both methods emit LogStatusEx messages with elapsed time and hit/miss counts:

GetDatasetByName: MJ_Metadata - 22 items, 22 cache hits, 0 misses, elapsed: 0.3ms
GetDatasetStatusByName: MJ_Metadata - 22 items, 22 cache hits, 0 misses, elapsed: 0.1ms

These log entries use standard MJ logging and appear in the server console during startup and client connections.

Dataset caching is active when:

  • LocalCacheManager.Instance is available
  • TrustLocalCacheCompletely is true (server-side default)

On the client side (TrustLocalCacheCompletely = false), dataset requests pass through to the server without local cache checks.