# @memberjunction/core
The `@memberjunction/core` library is the foundational package of the MemberJunction ecosystem. It provides a comprehensive, tier-independent interface for metadata management, entity data access, view and query execution, transaction management, security, and more. All MemberJunction applications -- whether running on the server, in the browser, or via API -- depend on this package.
## Installation
```bash
npm install @memberjunction/core
```
## Architecture Overview
```mermaid
flowchart TB
subgraph Application["Application Layer"]
style Application fill:#2d6a9f,stroke:#1a4971,color:#fff
App["Your Application
(Angular, Node.js, etc.)"]
end
subgraph Core["@memberjunction/core"]
style Core fill:#2d8659,stroke:#1a5c3a,color:#fff
MD["Metadata"]
BE["BaseEntity"]
RV["RunView"]
RQ["RunQuery"]
RR["RunReport"]
TG["TransactionGroup"]
DS["Datasets"]
LC["LocalCacheManager"]
TM["TelemetryManager"]
LOG["Logging"]
end
subgraph Providers["Provider Layer"]
style Providers fill:#7c5295,stroke:#563a6b,color:#fff
SP["Server Provider
(SQLServerDataProvider)"]
CP["Client Provider
(GraphQLDataProvider)"]
end
subgraph Data["Data Layer"]
style Data fill:#b8762f,stroke:#8a5722,color:#fff
DB[("SQL Server
Database")]
API["GraphQL API"]
end
App --> MD
App --> BE
App --> RV
App --> RQ
MD --> SP
MD --> CP
BE --> SP
BE --> CP
RV --> SP
RV --> CP
SP --> DB
CP --> API
API --> DB
```
The package uses a **provider model** that allows the same application code to run transparently on different tiers. On the server, a `SQLServerDataProvider` communicates directly with the database. On the client, a `GraphQLDataProvider` routes requests through the GraphQL API. Your code does not need to know which provider is active.
## Key Features
- **Metadata-Driven Architecture** -- Complete access to MemberJunction metadata including entities, fields, relationships, and permissions
- **Entity Data Access** -- Type-safe base classes for loading, saving, and manipulating entity records with dirty tracking and validation
- **View Execution** -- Powerful view running capabilities for both stored and dynamic views with filtering, pagination, and aggregation
- **Query Execution** -- Secure parameterized query execution with Nunjucks templates and SQL injection protection
- **Transaction Management** -- Support for grouped database transactions with atomic commits
- **Provider Architecture** -- Flexible provider model supporting different execution environments (server, client, API)
- **Bulk Data Loading** -- Dataset system for efficient loading of related entity collections
- **Local Caching** -- Intelligent local cache manager with TTL, LRU eviction, and differential updates
- **Vector Embeddings** -- Built-in support for AI-powered text embeddings and similarity search
- **Enhanced Logging** -- Structured logging with metadata, categories, severity levels, and verbose control
- **Telemetry** -- Session-level event tracking for performance monitoring and pattern detection
- **BaseEngine Pattern** -- Abstract engine base class for building singleton services with automatic data loading
## Module Structure
```mermaid
flowchart LR
subgraph index["index.ts (Public API)"]
style index fill:#64748b,stroke:#475569,color:#fff
EX["Exports"]
end
subgraph generic["generic/"]
style generic fill:#2d6a9f,stroke:#1a4971,color:#fff
metadata["metadata.ts"]
baseEntity["baseEntity.ts"]
providerBase["providerBase.ts"]
entityInfo["entityInfo.ts"]
securityInfo["securityInfo.ts"]
interfaces["interfaces.ts"]
transactionGroup["transactionGroup.ts"]
baseEngine["baseEngine.ts"]
compositeKey["compositeKey.ts"]
logging["logging.ts"]
runQuery["runQuery.ts"]
runReport["runReport.ts"]
localCacheManager["localCacheManager.ts"]
telemetryManager["telemetryManager.ts"]
util["util.ts"]
queryCache["QueryCache.ts"]
databaseProvider["databaseProviderBase.ts"]
end
subgraph views["views/"]
style views fill:#2d8659,stroke:#1a5c3a,color:#fff
runView["runView.ts"]
viewInfo["viewInfo.ts"]
end
EX --> metadata
EX --> baseEntity
EX --> runView
EX --> providerBase
EX --> runQuery
EX --> baseEngine
```
| File | Purpose |
|------|---------|
| `metadata.ts` | Primary entry point for accessing MemberJunction metadata and creating entity objects |
| `baseEntity.ts` | Foundation class for all entity record manipulation with state tracking and events |
| `providerBase.ts` | Abstract base class all providers extend, contains caching and refresh logic |
| `entityInfo.ts` | Entity metadata classes: `EntityInfo`, `EntityFieldInfo`, `EntityRelationshipInfo`, etc. |
| `securityInfo.ts` | Security classes: `UserInfo`, `RoleInfo`, `AuthorizationInfo`, `AuditLogTypeInfo` |
| `interfaces.ts` | Core interfaces: `IMetadataProvider`, `IEntityDataProvider`, `IRunViewProvider`, etc. |
| `compositeKey.ts` | `CompositeKey` and `KeyValuePair` for multi-field primary key support |
| `transactionGroup.ts` | `TransactionGroupBase` for atomic multi-entity operations |
| `baseEngine.ts` | `BaseEngine` abstract singleton for building services with auto-loaded data |
| `runQuery.ts` | `RunQuery` class for secure parameterized query execution |
| `runReport.ts` | `RunReport` class for report generation |
| `logging.ts` | `LogStatus`, `LogError`, `LogStatusEx`, `LogErrorEx`, verbose controls |
| `localCacheManager.ts` | `LocalCacheManager` for client-side caching with TTL and LRU eviction |
| `telemetryManager.ts` | `TelemetryManager` for operation tracking and pattern detection |
| `queryCache.ts` / `QueryCacheConfig.ts` | LRU query result cache with TTL support |
| `databaseProviderBase.ts` | `DatabaseProviderBase` for server-side SQL execution and transactions |
| `util.ts` | Utility functions: `TypeScriptTypeFromSQLType`, `FormatValue`, `CodeNameFromString` |
| `runView.ts` | `RunView` class and `RunViewParams` for executing stored and dynamic views |
| `viewInfo.ts` | View metadata classes: `ViewInfo`, `ViewColumnInfo`, `ViewFilterInfo` |
---
## Core Components
### Metadata
The `Metadata` class is the primary entry point for accessing MemberJunction metadata and instantiating entity objects. It delegates to a provider set at application startup.
```typescript
import { Metadata } from '@memberjunction/core';
const md = new Metadata();
// Refresh cached metadata
await md.Refresh();
// Access metadata collections
const entities = md.Entities; // EntityInfo[]
const applications = md.Applications; // ApplicationInfo[]
const currentUser = md.CurrentUser; // UserInfo
const roles = md.Roles; // RoleInfo[]
const queries = md.Queries; // QueryInfo[]
```
#### Metadata Properties
| Property | Type | Description |
|----------|------|-------------|
| `Applications` | `ApplicationInfo[]` | All applications in the system |
| `Entities` | `EntityInfo[]` | All entity definitions with fields, relationships, permissions |
| `CurrentUser` | `UserInfo` | Current authenticated user (client-side only) |
| `Roles` | `RoleInfo[]` | System roles |
| `AuditLogTypes` | `AuditLogTypeInfo[]` | Available audit log types |
| `Authorizations` | `AuthorizationInfo[]` | Authorization definitions |
| `Libraries` | `LibraryInfo[]` | Registered libraries |
| `Queries` | `QueryInfo[]` | Query definitions |
| `QueryFields` | `QueryFieldInfo[]` | Query field metadata |
| `QueryCategories` | `QueryCategoryInfo[]` | Query categorization |
| `QueryPermissions` | `QueryPermissionInfo[]` | Query-level permissions |
| `VisibleExplorerNavigationItems` | `ExplorerNavigationItem[]` | Navigation items visible to the current user |
| `AllExplorerNavigationItems` | `ExplorerNavigationItem[]` | All navigation items (including hidden) |
| `ProviderType` | `'Database' \| 'Network'` | Whether the active provider connects directly to DB or via network |
| `LocalStorageProvider` | `ILocalStorageProvider` | Persistent local storage (IndexedDB, file, memory) |
#### Helper Methods
```typescript
// Look up entities by name or ID
const entityId = md.EntityIDFromName('Users');
const entityName = md.EntityNameFromID('12345');
const entityInfo = md.EntityByName('users'); // case-insensitive
const entity = md.EntityByID('12345');
// Record operations
const name = await md.GetEntityRecordName('Users', compositeKey);
const names = await md.GetEntityRecordNames(infoArray);
const isFavorite = await md.GetRecordFavoriteStatus(userId, 'Orders', key);
await md.SetRecordFavoriteStatus(userId, 'Orders', key, true);
// Dependencies and duplicates
const deps = await md.GetRecordDependencies('Orders', primaryKey);
const entityDeps = await md.GetEntityDependencies('Orders');
const dupes = await md.GetRecordDuplicates(duplicateRequest);
// Record merging
const mergeResult = await md.MergeRecords(mergeRequest);
// Record change history (built-in version control)
const changes = await md.GetRecordChanges('Users', primaryKey);
// Transactions
const txGroup = await md.CreateTransactionGroup();
```
### GetEntityObject()
`GetEntityObject()` is the correct way to create entity instances. It uses the MemberJunction class factory to ensure the proper subclass is instantiated and supports two overloads.
```mermaid
flowchart LR
subgraph Creation["Entity Creation Flow"]
style Creation fill:#2d6a9f,stroke:#1a4971,color:#fff
GEO["GetEntityObject()"] --> CF["ClassFactory
Lookup"]
CF --> SC["Subclass
Instantiation"]
SC --> NR["NewRecord()
(auto-called)"]
NR --> READY["Entity Ready"]
end
subgraph Loading["Entity Loading Flow"]
style Loading fill:#2d8659,stroke:#1a5c3a,color:#fff
GEO2["GetEntityObject()
with CompositeKey"] --> CF2["ClassFactory
Lookup"]
CF2 --> SC2["Subclass
Instantiation"]
SC2 --> LD["Load()
from Database"]
LD --> READY2["Entity Ready
(with data)"]
end
```
#### Creating New Records
```typescript
// NewRecord() is called automatically
const customer = await md.GetEntityObject('Customers');
customer.Name = 'Acme Corp';
await customer.Save();
// Server-side with context user
const order = await md.GetEntityObject('Orders', contextUser);
```
#### Loading Existing Records
```typescript
import { CompositeKey } from '@memberjunction/core';
// Load by ID (most common)
const user = await md.GetEntityObject('Users', CompositeKey.FromID(userId));
// Load by named field
const userByEmail = await md.GetEntityObject('Users',
CompositeKey.FromKeyValuePair('Email', 'user@example.com'));
// Load with composite primary key
const orderItem = await md.GetEntityObject('OrderItems',
CompositeKey.FromKeyValuePairs([
{ FieldName: 'OrderID', Value: orderId },
{ FieldName: 'ProductID', Value: productId }
]));
// Server-side with context user
const order = await md.GetEntityObject('Orders',
CompositeKey.FromID(orderId), contextUser);
```
### BaseEntity
The `BaseEntity` class is the foundation for all entity record manipulation. All entity classes generated by CodeGen extend it.
#### Field Access
```typescript
// Type-safe property access (via generated getters/setters)
const name = user.FirstName;
user.FirstName = 'Jane';
// Dynamic field access
const value = user.Get('FirstName');
user.Set('FirstName', 'Jane');
// Field metadata
const field = user.Fields.find(f => f.Name === 'Email');
console.log(field.Dirty); // Has the value changed?
console.log(field.IsUnique); // Unique constraint?
console.log(field.IsPrimaryKey); // Primary key?
console.log(field.ReadOnly); // Read-only field?
```
#### Deprecated and Disabled Fields (Active-Status Enforcement)
Every entity field has a `Status` of `Active` (the default), `Deprecated`, or `Disabled`. The column stays physically present in the table and the `EntityField` instance is always created — status only governs whether *code* is allowed to use the field:
- **`Deprecated`** — still functional, but emits a batched console **warning** when accessed, nudging callers off it before removal.
- **`Disabled`** — **throws** on access; the field is off-limits even though the metadata and physical column remain.
**Where enforcement happens (and where it deliberately does not).** The status check lives at the field-access boundary that real code flows through — `BaseEntity.Get()`, `BaseEntity.Set()`, and `BaseEntity.SetMany()` — which is exactly what the generated strongly-typed accessors call:
```typescript
// Generated accessor → BaseEntity.Get/Set → status enforced here
const s = agentRun.AgentState; // Deprecated → warns; Disabled → throws
agentRun.Set('AgentState', value); // same enforcement via the dynamic API
```
It is **not** enforced on the low-level `EntityField.Value` accessor. Framework-internal machinery — dirty checking, validation, serialization (`GetAll`), record-change capture, and load-time hydration — reads `EntityField.Value` directly and is therefore exempt by construction. This is what keeps merely **loading or saving** a record that *contains* a deprecated column from false-warning on every operation: only genuine, code-initiated field access counts as "use."
`SetMany()` distinguishes the two via its `ignoreActiveStatusAssertions` parameter — the load/hydration paths pass `true` (populating from the database is not user use), while ordinary user-initiated `SetMany()` calls enforce status.
**Fast path.** Enforcement is gated on `EntityInfo.HasInactiveFields`, a value memoized once per entity definition. Entities whose fields are all `Active` (the overwhelming majority) pay only a single cached boolean check in `Get`/`Set`/`SetMany` — no per-field work and zero overhead in hot read/write loops.
> Note: `EntityField.ActiveStatusAssertions` is retained as a **deprecated no-op** for backward compatibility. There is nothing to toggle at the field level anymore, since `EntityField.Value` no longer asserts.
#### Save and Delete
```typescript
import { EntitySaveOptions } from '@memberjunction/core';
// Simple save
const success = await entity.Save();
// Save with options
const options = new EntitySaveOptions();
options.IgnoreDirtyState = true; // Force save even if no changes detected
options.SkipEntityAIActions = true; // Skip AI-related actions
options.SkipEntityActions = true; // Skip entity actions
await entity.Save(options);
// Delete
await entity.Delete();
```
#### GetAll() for Spread Operator
BaseEntity uses getter/setter properties, so the spread operator will not capture field values. Use `GetAll()` instead.
```typescript
// WRONG -- spread ignores getter properties
const data = { ...entity };
// CORRECT -- GetAll() returns a plain object with all field values
const data = { ...entity.GetAll(), customField: 'value' };
```
#### State Tracking and Events
BaseEntity provides comprehensive state tracking and lifecycle events.
```typescript
import { BaseEntityEvent } from '@memberjunction/core';
// Check operation states
if (entity.IsSaving) { /* Save in progress */ }
if (entity.IsDeleting) { /* Delete in progress */ }
if (entity.IsLoading) { /* Load in progress */ }
if (entity.IsBusy) { /* Any operation in progress */ }
// Subscribe to lifecycle events
const subscription = entity.RegisterEventHandler((event: BaseEntityEvent) => {
switch (event.type) {
case 'save_started':
console.log(`Save started (${event.saveSubType})`); // 'create' or 'update'
break;
case 'save':
console.log('Save completed');
break;
case 'delete_started':
console.log('Delete started');
break;
case 'delete':
console.log('Delete completed, old values:', event.payload?.OldValues);
break;
case 'load_started':
console.log('Load started for key:', event.payload?.CompositeKey);
break;
case 'load_complete':
console.log('Load completed');
break;
case 'new_record':
console.log('NewRecord() called');
break;
}
});
// Unsubscribe when done
subscription.unsubscribe();
```
#### Awaiting In-Progress Operations
```typescript
// Wait for an in-progress save to complete before proceeding
await entity.EnsureSaveComplete();
await entity.EnsureDeleteComplete();
await entity.EnsureLoadComplete();
```
#### Save Debouncing
Multiple rapid calls to `Save()` or `Delete()` are automatically debounced -- the second call receives the same result as the first.
```typescript
const promise1 = entity.Save();
const promise2 = entity.Save(); // Returns same promise, no duplicate save
const [result1, result2] = await Promise.all([promise1, promise2]);
// result1 === result2
```
#### Global Event Subscription
Monitor all entity operations across the application.
```typescript
import { MJGlobal, MJEventType, BaseEntity, BaseEntityEvent } from '@memberjunction/core';
MJGlobal.Instance.GetEventListener(true).subscribe((event) => {
if (event.event === MJEventType.ComponentEvent &&
event.eventCode === BaseEntity.BaseEventCode) {
const entityEvent = event.args as BaseEntityEvent;
console.log(`[${entityEvent.baseEntity.EntityInfo.Name}] ${entityEvent.type}`);
}
});
```
#### Validation
```typescript
const result = entity.Validate();
if (!result.Success) {
for (const error of result.Errors) {
console.error(`${error.Source}: ${error.Message}`);
}
}
```
### CompositeKey
The `CompositeKey` class provides flexible primary key representation supporting both single and multi-field primary keys.
```typescript
import { CompositeKey, KeyValuePair } from '@memberjunction/core';
// Single ID field
const key = CompositeKey.FromID('abc-123');
// Named single field
const key2 = CompositeKey.FromKeyValuePair('Email', 'user@example.com');
// Composite key
const key3 = CompositeKey.FromKeyValuePairs([
{ FieldName: 'OrderID', Value: orderId },
{ FieldName: 'ProductID', Value: productId }
]);
// Key operations
const value = key.GetValueByFieldName('ID');
const str = key.ToString(); // "ID=abc-123"
const concat = key.ToConcatenatedString(); // "abc-123"
const valid = key.Validate(); // { IsValid: boolean, ErrorMessage: string }
```
---
### RunView
The `RunView` class provides powerful view execution capabilities for both stored and dynamic queries.
```mermaid
flowchart LR
subgraph Params["RunViewParams"]
style Params fill:#2d6a9f,stroke:#1a4971,color:#fff
SV["Stored View
(ViewID/ViewName)"]
DV["Dynamic View
(EntityName + Filter)"]
end
subgraph RunView["RunView"]
style RunView fill:#2d8659,stroke:#1a5c3a,color:#fff
RV["RunView()"]
RVS["RunViews()"]
end
subgraph Result["RunViewResult"]
style Result fill:#b8762f,stroke:#8a5722,color:#fff
S["Success"]
R["Results[]"]
TC["TotalRowCount"]
AG["AggregateResults"]
end
SV --> RV
DV --> RV
DV --> RVS
RV --> S
RV --> R
RVS --> S
RV --> AG
```
#### Basic Usage
```typescript
import { RunView, RunViewParams } from '@memberjunction/core';
const rv = new RunView();
// Run a stored view by name
const result = await rv.RunView({
ViewName: 'Active Users',
ExtraFilter: "CreatedDate > '2024-01-01'"
});
// Run a dynamic view with entity objects returned
const typedResult = await rv.RunView({
EntityName: 'Users',
ExtraFilter: 'IsActive = 1',
OrderBy: 'LastName ASC, FirstName ASC',
Fields: ['ID', 'FirstName', 'LastName', 'Email'],
ResultType: 'entity_object'
});
// Access results
if (typedResult.Success) {
const users = typedResult.Results; // UserEntity[]
console.log(`Found ${users.length} users`);
}
```
#### Batch Multiple Views
Use `RunViews` (plural) to execute multiple independent queries in a single operation.
```typescript
const [users, roles, permissions] = await rv.RunViews([
{
EntityName: 'Users',
ExtraFilter: 'IsActive = 1',
ResultType: 'entity_object'
},
{
EntityName: 'Roles',
OrderBy: 'Name',
ResultType: 'entity_object'
},
{
EntityName: 'Entity Permissions',
ResultType: 'simple'
}
]);
```
#### Aggregates
Request aggregate calculations that run in parallel with the main query, unaffected by pagination.
```typescript
const result = await rv.RunView({
EntityName: 'Orders',
ExtraFilter: "Status = 'Completed'",
MaxRows: 50,
Aggregates: [
{ expression: 'SUM(TotalAmount)', alias: 'TotalRevenue' },
{ expression: 'COUNT(*)', alias: 'OrderCount' },
{ expression: 'AVG(TotalAmount)', alias: 'AverageOrder' }
]
});
// Aggregate results are in result.AggregateResults[]
```
#### Keyset (Seek) Pagination — `AfterKey`
For background jobs and bulk processing that iterate through *all* records of a large entity, use `AfterKey` instead of `StartRow`. Keyset pagination stays **O(log N) per page** regardless of depth — `StartRow`/OFFSET pagination becomes progressively slower as the offset grows.
```typescript
import { CompositeKey } from '@memberjunction/core';
let lastSeenKey: CompositeKey | undefined; // undefined => first page
while (true) {
const result = await rv.RunView({
EntityName: 'Tax Returns',
ExtraFilter: 'AddressLine1 IS NOT NULL',
AfterKey: lastSeenKey,
MaxRows: 500,
ResultType: 'entity_object'
}, contextUser);
if (!result.Success || result.Results.length === 0) break;
for (const r of result.Results) { /* process */ }
if (result.Results.length < 500) break; // partial page = end of data
const last = result.Results[result.Results.length - 1];
lastSeenKey = CompositeKey.FromID(last.ID);
}
```
**Constraints** (throw `AfterKeyNotSupportedError` on violation):
- Entity must have a **single-column primary key** on a comparable type.
- `OrderBy`, if set, must reference only the PK column (any `ASC`/`DESC` direction).
- Cannot be combined with non-zero `StartRow`.
Keyset queries automatically bypass the server cache (read + write) — each call uses a different seek key, so caching them is pure overhead.
UI grid pagination (a few hundred pages of a few hundred rows) should stay on `StartRow` — keyset isn't necessary there. See **[KEYSET_PAGINATION_GUIDE.md](../../guides/KEYSET_PAGINATION_GUIDE.md)** for the full pattern, validation rules, and reference implementations.
```typescript
// Defensive: catch the framework's typed error if you want to fall back to OFFSET
import { AfterKeyNotSupportedError } from '@memberjunction/core';
try {
await rv.RunView({ EntityName: 'SomeEntity', AfterKey: key, MaxRows: 500 }, user);
} catch (e) {
if (e instanceof AfterKeyNotSupportedError && e.Reason === 'CompositePK') {
// entity has composite PK — fall back to StartRow-based iteration
} else {
throw e;
}
}
```
Helper: `IsKeysetPaginationOrderableType(sqlTypeName)` — returns true if a column type is acceptable as a keyset PK (essentially all standard SQL types; defensively rejects exotics like `xml`/`sql_variant`/`varbinary`).
#### ResultType and Fields Optimization
```typescript
// entity_object -- full BaseEntity objects for mutation (Fields is ignored)
const mutableResult = await rv.RunView({
EntityName: 'Users',
ResultType: 'entity_object'
});
// simple -- plain JavaScript objects for read-only use (use Fields for performance)
const readOnlyResult = await rv.RunView<{ ID: string; Name: string }>({
EntityName: 'Users',
Fields: ['ID', 'Name'],
ResultType: 'simple'
});
// count_only -- returns only TotalRowCount, no rows
const countResult = await rv.RunView({
EntityName: 'Users',
ExtraFilter: 'IsActive = 1',
ResultType: 'count_only'
});
```
#### RunViewParams Reference
| Parameter | Type | Description |
|-----------|------|-------------|
| `ViewID` | `string` | ID of stored view to run |
| `ViewName` | `string` | Name of stored view to run |
| `ViewEntity` | `BaseEntity` | Pre-loaded view entity (for performance) |
| `EntityName` | `string` | Entity name for dynamic views |
| `ExtraFilter` | `string` | Additional SQL WHERE clause |
| `OrderBy` | `string` | SQL ORDER BY clause |
| `Fields` | `string[]` | Field names to return (simple mode only) |
| `UserSearchString` | `string` | User search term |
| `MaxRows` | `number` | Maximum rows to return |
| `StartRow` | `number` | Row offset (OFFSET-based pagination). Use for UI grids. For deep iteration over large tables, prefer `AfterKey`. |
| `AfterKey` | `CompositeKey` | Keyset (seek) pagination cursor — O(log N) per page regardless of depth. Requires single-column PK. Throws `AfterKeyNotSupportedError` on incompatible entities. See [KEYSET_PAGINATION_GUIDE.md](../../guides/KEYSET_PAGINATION_GUIDE.md). |
| `ResultType` | `'simple' \| 'entity_object' \| 'count_only'` | Result format |
| `IgnoreMaxRows` | `boolean` | Bypass entity MaxRows setting |
| `SaveViewResults` | `boolean` | Store run results for future exclusion |
| `ExcludeUserViewRunID` | `string` | Exclude records from a specific prior run |
| `ExcludeDataFromAllPriorViewRuns` | `boolean` | Exclude all previously returned records |
| `ForceAuditLog` | `boolean` | Force audit log entry |
| `CacheLocal` | `boolean` | Use LocalCacheManager for caching |
| `CacheLocalTTL` | `number` | Cache TTL in milliseconds |
| `BypassCache` | `boolean` | Skip all server-side caching (read and write). Use for maintenance queries that need true DB state after direct SQL inserts. |
| `Aggregates` | `AggregateExpression[]` | Aggregate expressions to compute |
---
### RunQuery
The `RunQuery` class provides secure execution of parameterized stored queries with Nunjucks templates and SQL injection protection.
```typescript
import { RunQuery, RunQueryParams } from '@memberjunction/core';
const rq = new RunQuery();
// Execute by Query ID
const result = await rq.RunQuery({
QueryID: '12345',
Parameters: {
StartDate: '2024-01-01',
EndDate: '2024-12-31',
Status: 'Active'
}
});
// Execute by Query Name and Category Path
const namedResult = await rq.RunQuery({
QueryName: 'Monthly Sales Report',
CategoryPath: '/Sales/',
Parameters: { Month: 12, Year: 2024 }
});
// Execute ad-hoc SQL (SELECT/WITH only — validated and run on read-only connection)
const adhocResult = await rq.RunQuery({
SQL: 'SELECT TOP 100 Name, Status FROM __mj.vwUsers WHERE IsActive = 1'
});
if (result.Success) {
console.log(`Rows: ${result.RowCount}, Time: ${result.ExecutionTime}ms`);
} else {
console.error('Query failed:', result.ErrorMessage);
}
```
#### SQL Security Filters
Parameterized queries use Nunjucks templates with built-in SQL injection protection filters:
| Filter | Purpose | Example |
|--------|---------|---------|
| `sqlString` | Escapes strings, wraps in quotes | `{{ name \| sqlString }}` produces `'O''Brien'` |
| `sqlNumber` | Validates numeric values | `{{ amount \| sqlNumber }}` produces `1000.5` |
| `sqlDate` | Formats dates as ISO 8601 | `{{ date \| sqlDate }}` produces `'2024-01-15T00:00:00.000Z'` |
| `sqlBoolean` | Converts to SQL bit | `{{ flag \| sqlBoolean }}` produces `1` |
| `sqlIdentifier` | Brackets identifiers | `{{ table \| sqlIdentifier }}` produces `[UserAccounts]` |
| `sqlIn` | Formats arrays for IN clauses | `{{ list \| sqlIn }}` produces `('A', 'B', 'C')` |
| `sqlLikeContains` | Wraps value with `%` for LIKE contains | `{{ term \| sqlLikeContains }}` produces `'%Conference%'` |
| `sqlLikeBegins` | Appends `%` for LIKE begins-with | `{{ term \| sqlLikeBegins }}` produces `'Conference%'` |
| `sqlLikeEnds` | Prepends `%` for LIKE ends-with | `{{ term \| sqlLikeEnds }}` produces `'%Conference'` |
| `sqlNoKeywordsExpression` | Blocks dangerous SQL keywords | Allows `Revenue DESC`, blocks `DROP TABLE` |
---
### RunReport
Execute reports by ID.
```typescript
import { RunReport, RunReportParams } from '@memberjunction/core';
const rr = new RunReport();
const result = await rr.RunReport({ ReportID: '12345' });
```
---
### TransactionGroup
Group multiple entity operations into an atomic transaction.
```typescript
import { Metadata } from '@memberjunction/core';
const md = new Metadata();
const txGroup = await md.CreateTransactionGroup();
// Add entities to the transaction
await txGroup.AddTransaction(entity1);
await txGroup.AddTransaction(entity2);
// Submit all operations as a single transaction
const results = await txGroup.Submit();
```
Each `TransactionResult` in the returned array contains a `Success` flag. If any operation fails, all are rolled back.
---
### Datasets
Datasets enable efficient bulk loading of related entity collections in a single operation, reducing database round trips.
```mermaid
flowchart TB
subgraph Dataset["Dataset System"]
style Dataset fill:#2d6a9f,stroke:#1a4971,color:#fff
DEF["Dataset Definition
(name, description)"]
ITEMS["Dataset Items
(entity, filter, code)"]
end
subgraph Loading["Loading Strategies"]
style Loading fill:#2d8659,stroke:#1a5c3a,color:#fff
FRESH["GetDatasetByName()
(always fresh)"]
CACHED["GetAndCacheDatasetByName()
(uses cache if valid)"]
CHECK["IsDatasetCacheUpToDate()
(check freshness)"]
CLEAR["ClearDatasetCache()
(invalidate)"]
end
subgraph Storage["Cache Storage"]
style Storage fill:#b8762f,stroke:#8a5722,color:#fff
IDB["IndexedDB
(Browser)"]
FS["File System
(Node.js)"]
MEM["Memory
(Fallback)"]
end
DEF --> ITEMS
ITEMS --> FRESH
ITEMS --> CACHED
CACHED --> IDB
CACHED --> FS
CACHED --> MEM
```
```typescript
import { DatasetItemFilterType } from '@memberjunction/core';
const md = new Metadata();
// Load dataset with caching
const dataset = await md.GetAndCacheDatasetByName('ProductCatalog');
// Load with item-specific filters
const filters: DatasetItemFilterType[] = [
{ ItemCode: 'Products', Filter: 'IsActive = 1' },
{ ItemCode: 'Categories', Filter: 'ParentID IS NULL' }
];
const filteredDataset = await md.GetAndCacheDatasetByName('ProductCatalog', filters);
if (filteredDataset.Success) {
for (const item of filteredDataset.Results) {
console.log(`Loaded ${item.Results.length} records for ${item.EntityName}`);
}
}
// Check if cache is up-to-date
const isUpToDate = await md.IsDatasetCacheUpToDate('ProductCatalog');
// Clear cache
await md.ClearDatasetCache('ProductCatalog');
```
---
### BaseEngine
The `BaseEngine` abstract class is a singleton pattern for building engine/service classes that auto-load and auto-refresh data from entities or datasets.
```typescript
import { BaseEngine, BaseEnginePropertyConfig } from '@memberjunction/core';
export class MyEngine extends BaseEngine {
public static get Instance(): MyEngine {
return super.getInstance();
}
private _myData: SomeEntity[] = [];
public get MyData(): SomeEntity[] {
return this.GetConfigData('_myData');
}
public async Config(forceRefresh?: boolean, contextUser?: UserInfo): Promise {
const params: Partial[] = [
{
PropertyName: '_myData',
EntityName: 'Some Entity',
Filter: 'IsActive = 1',
OrderBy: 'Name ASC',
AutoRefresh: true // Auto-refresh on entity save/delete events
}
];
return await this.Load(params, undefined, forceRefresh, contextUser);
}
}
// Usage
await MyEngine.Instance.Config(false, contextUser);
const data = MyEngine.Instance.MyData;
```
Key features:
- Singleton per class via `BaseSingleton`
- Declarative data loading via `BaseEnginePropertyConfig`
- Automatic refresh when entities are saved or deleted (debounced)
- Local caching support via `CacheLocal` and `CacheLocalTTL` options
- Supports both entity and dataset loading
#### Permission-Constrained Loading
When a user lacks read permissions on entities an engine loads, the engine enters a **permission-constrained** state instead of failing with errors or retrying endlessly. This is an all-or-nothing check — if any entity config is denied, all configs for that engine are skipped.
The `GetConfigData(propertyName)` method is the canonical way for engine getters to expose loaded data. It checks the data map for permission denial and throws a `PermissionConstrainedError` if the config was skipped, preventing consumers from silently operating on empty arrays.
```typescript
// Consumer that wants graceful degradation (optional feature)
if (!AIEngineBase.Instance.IsPermissionConstrained) {
const models = AIEngineBase.Instance.Models;
// ... render AI features
} else {
// ... hide AI features, show notice
}
// Consumer that requires the data (hard error if missing)
const queries = QueryEngine.Instance.Queries; // throws PermissionConstrainedError if denied
```
| State | `Loaded` | `IsPermissionConstrained` | Behavior |
|---|---|---|---|
| Not loaded | `false` | `false` | `EnsureLoaded()` retries normally |
| Loaded normally | `true` | `false` | Normal operation |
| Permission-constrained | `true` | `true` | `GetConfigData()` throws `PermissionConstrainedError`, no retry, no entity event handling |
---
### BaseEngineRegistry — cross-engine cache reverse lookup
Every `BaseEngine` registers itself with the process-wide `BaseEngineRegistry` on
load, so the registry always knows **which loaded engines cache which entities**.
You can use that to ask, from anywhere, *"is this entity already fully in memory?
if so, hand me the array — and don't go to the database."*
This is the introspection behind the Admin → System Diagnostics "loaded engines"
view, plus two reverse-lookup helpers:
```typescript
import { BaseEngineRegistry, UserInfo } from '@memberjunction/core';
// All loaded engines that cache 'Users', unfiltered (full-set) caches first.
// Each match carries the engine, its config, and a LIVE pointer to the array.
const matches = BaseEngineRegistry.Instance.FindCachedEntity('Users');
// matches[0] => { engineClassName, engine, config, records: UserInfo[], unfiltered }
// Or the one-liner: the best (unfiltered-preferred) cached array, or null.
const users = BaseEngineRegistry.Instance.TryGetCachedRecords('Users', { unfilteredOnly: true });
if (users) {
// Small/static entity already in memory — filter/sort locally, zero DB calls.
const hits = users.filter(u => u.Name.toLowerCase().includes(q));
} else {
// Not cached as a full set → fall back to a normal RunView against the DB.
}
```
`FindCachedEntity(entityName, { unfilteredOnly? })`:
- Considers **only loaded** engines (a registered-but-unloaded engine has no data).
- Matches an engine config when `Type === 'entity'` and `EntityName` matches (case-insensitive, trimmed).
- Orders **unfiltered caches first** — a config with no `Filter` holds the *complete*
entity set and is authoritative (safe for "show all" / in-memory search); filtered
caches (a subset) come after. `unfilteredOnly: true` omits the filtered ones.
- Returns the engine's **live array** (not a copy) — read it, don't mutate it. When the
config's `ResultType` is `'simple'`, rows are plain objects, not `BaseEntity` instances.
- Returns **all** matches when several engines cache the same entity, so the caller can
pick (by `engineClassName`, by inspecting `config`, etc.).
`TryGetCachedRecords(entityName, { unfilteredOnly? })` is the convenience wrapper —
the best match's array, or `null`.
**Why it's useful:** UI and service code that needs to look up records for a
small/static entity (FK pickers, dropdowns, validation) can serve the lookup from
an already-loaded engine cache in a single line — no extra DB round-trip, no
per-keystroke query — and transparently fall back to `RunView` when the entity
isn't cached as a full set.
---
### RegisterForStartup
The `@RegisterForStartup` decorator registers singleton engine classes (or any class implementing `IStartupSink`) with the `StartupManager` to automatically run configuration/setup during application boot.
```typescript
import { RegisterForStartup, IStartupSink, IMetadataProvider, UserInfo } from '@memberjunction/core';
@RegisterForStartup({
priority: 10, // Lower numbers run first
severity: 'fatal', // 'fatal' (aborts startup), 'error', 'warn', 'silent'
description: 'My custom startup engine'
})
export class MyStartupEngine implements IStartupSink {
public static get Instance(): MyStartupEngine {
return super.getInstance();
}
public async HandleStartup(contextUser?: UserInfo, provider?: IMetadataProvider): Promise {
// Run configuration and initial load
await this.Config(false, contextUser, provider);
}
}
```
#### Deferred Startup & Delay
For non-critical background services (like local AI model loading or vector pre-warming), you can set `deferred: true` to execute asynchronously without blocking the main application boot sequence.
You can also specify `deferredDelay` (in milliseconds) to wait a set duration after synchronous boot finishes before the startup manager triggers the task, preventing resource spikes during boot:
```typescript
@RegisterForStartup({
deferred: true,
deferredDelay: 15000, // Delay background loading by 15 seconds
description: 'Background AI Engine pre-warming'
})
export class AIEngine implements IStartupSink {
// ...
}
```
---
### LocalCacheManager
The `LocalCacheManager` provides intelligent client-side caching for RunView and RunQuery results with TTL, LRU eviction, and differential updates.
```typescript
import { LocalCacheManager } from '@memberjunction/core';
const cache = LocalCacheManager.Instance;
// Initialize with a storage provider
cache.Init(localStorageProvider);
// Cache statistics
const stats = cache.GetStats();
console.log(`Entries: ${stats.totalEntries}, Hits: ${stats.hits}, Misses: ${stats.misses}`);
// Clear all cached data
await cache.ClearAll();
```
To use caching with RunView, set `CacheLocal: true` in your `RunViewParams`:
```typescript
const result = await rv.RunView({
EntityName: 'Products',
ExtraFilter: 'IsActive = 1',
CacheLocal: true,
CacheLocalTTL: 300000 // 5 minutes
});
```
To bypass all caching for a specific query (e.g., maintenance actions that need to see
records inserted via direct SQL that bypassed `BaseEntity.Save()`), set `BypassCache: true`:
```typescript
// Always hits the database — skips both cache reads and cache writes
const result = await rv.RunView({
EntityName: 'Members',
ExtraFilter: 'State IS NOT NULL',
BypassCache: true,
IgnoreMaxRows: true
});
```
#### Cross-Server Cache Invalidation
When multiple MJAPI server instances share a Redis-backed `ILocalStorageProvider`, cache invalidation propagates automatically across all instances. The system uses two complementary mechanisms:
**1. BaseEngine path (engine-managed data):**
When `BaseEntity.Save()` fires, `BaseEngine` catches the MJGlobal event, updates its in-memory arrays, and calls `syncLocalCacheForConfig()` → `LocalCacheManager.UpsertSingleEntity()` → Redis `SetItem()` → pub/sub notification. Other servers receive the notification via `OnExternalCacheChange()` and refresh their engine data.
**2. LocalCacheManager path (all cached data):**
`LocalCacheManager` independently subscribes to MJGlobal `BaseEntityEvent` events. When any entity is saved or deleted, it finds all cached RunView fingerprints for that entity via a reverse index and either updates them in-place (unfiltered queries) or invalidates them (filtered queries). This ensures that **all** cached data — not just engine-managed data — stays consistent across servers.
```
MJAPI-A: BaseEntity.Save()
→ MJGlobal event
→ LocalCacheManager.HandleBaseEntityEvent()
→ Find all cached fingerprints for this entity
→ UpsertSingleEntity() or InvalidateRunViewResult()
→ Redis SetItem() → PUBLISH on mj:__pubsub__
→ MJAPI-B receives → DispatchCacheChange()
→ BaseEngine.OnExternalCacheChange() refreshes arrays
```
**Registering for change notifications:**
```typescript
// Engines and components can register callbacks for specific cache fingerprints
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params);
const unsubscribe = LocalCacheManager.Instance.RegisterChangeCallback(
fingerprint,
(event: CacheChangedEvent) => {
console.log(`Cache updated by server ${event.SourceServerId}`);
// Refresh local data...
}
);
// Cleanup when no longer needed
unsubscribe();
```
**Requirements for cross-server invalidation:**
- Redis-backed `ILocalStorageProvider` (`@memberjunction/redis-provider`)
- `enablePubSub: true` in Redis provider config
- `StartListening()` called after provider creation
- `OnCacheChanged` wired to `LocalCacheManager.DispatchCacheChange()`
#### Storage Provider Implementations
`LocalCacheManager` and `ProviderBase` delegate persistence to an `ILocalStorageProvider`. MemberJunction ships with several implementations:
| Provider | Package | Environment | Persistence | Storage Format |
|----------|---------|-------------|-------------|----------------|
| `InMemoryLocalStorageProvider` | `@memberjunction/core` | Server (Node.js) | None — data lost on restart | Native references (no serialization) |
| `BrowserLocalStorageProvider` | `@memberjunction/graphql-dataprovider` | Browser | `localStorage` | JSON-serialized internally |
| `BrowserIndexedDBStorageProvider` | `@memberjunction/graphql-dataprovider` | Browser | IndexedDB | **Native objects via structured clone** |
| `RedisLocalStorageProvider` | [`@memberjunction/redis-provider`](../RedisProvider/) | Server (Node.js) | Redis — shared across instances, survives restarts | JSON-serialized internally |
For production server deployments, the Redis provider is recommended. See the [`@memberjunction/redis-provider` README](../RedisProvider/) for setup instructions.
#### Generic-typed interface
`ILocalStorageProvider` is generic — `SetItem(key, value, category?)` and `GetItem(key, category?)` thread the value's type through the call:
```typescript
interface UserCacheEntry { userId: string; roles: string[]; }
await provider.SetItem('user:1', { userId: 'u-1', roles: ['admin'] }, 'Users');
const user = await provider.GetItem('user:1', 'Users');
// ^^^^^ typed as UserCacheEntry | null — no .parse(), no casting
```
Each implementation handles serialization for its medium internally:
- **IndexedDB** stores objects natively via the structured clone algorithm — `Date`, `Map`, `Set`, typed arrays, and nested objects are preserved as-is on retrieval. **No JSON.parse on read** — significantly faster for cache-heavy workloads.
- **localStorage** and **Redis** JSON-encode/decode internally because their underlying media are string-only. `Date` instances become ISO strings on round-trip; `Map`/`Set` become plain objects.
- **In-memory** stores object references directly — same identity returned on read.
Class instances (with prototype methods) lose their prototype on retrieval across all providers; store the underlying data shape (e.g. via `entity.GetAll()` for `BaseEntity`).
#### Batched reads via `GetItems`
For workflows that need many cached entries at once — most notably the smart-cache-check warm-load path that reads ~85 fingerprints per coalesced engine batch — the interface exposes a batched read:
```typescript
GetItems(keys: string[], category?: string): Promise