MemberJunction GraphQL Data Provider
A comprehensive GraphQL client for MemberJunction that provides a complete data access layer for connecting applications to MemberJunction APIs.
Overview
Section titled “Overview”The @memberjunction/graphql-dataprovider package is a full-featured GraphQL client implementation for MemberJunction applications. It provides a standardized way to interact with MemberJunction’s GraphQL API, handling queries, mutations, subscriptions, and complex operations like transaction groups and entity relationships.
This data provider is designed for both frontend and backend applications that need to communicate with a MemberJunction API server, offering a consistent interface regardless of the underlying database technology.
Installation
Section titled “Installation”npm install @memberjunction/graphql-dataproviderKey Features
Section titled “Key Features”- Complete Entity Operations: CRUD operations for all MemberJunction entities
- View and Report Execution: Run database views and reports with parameters
- Query Execution: Execute custom queries with full parameter support
- Transaction Support: Execute complex operations as atomic transactions
- Action Execution: Execute entity actions and general actions through GraphQL
- WebSocket Subscriptions: Real-time data updates via GraphQL subscriptions
- Offline Caching: IndexedDB-based caching for offline functionality
- Type Safety: Full TypeScript support with generated types
- Authentication Integration: Works with MemberJunction’s authentication system
- Field Mapping: Automatic mapping between client and server field names
- Session Management: Persistent session IDs with automatic storage
- System User Client: Specialized client for server-to-server communication
- Duplicate Detection: Built-in support for finding and merging duplicate records
- AI Operations: Execute AI prompts and generate embeddings through GraphQL
- Simple Prompts: Run ad-hoc AI prompts without stored configurations
Setting up the GraphQL Client
Section titled “Setting up the GraphQL Client”import { setupGraphQLClient, GraphQLProviderConfigData } from '@memberjunction/graphql-dataprovider';
// Create configurationconst config = new GraphQLProviderConfigData( 'your-jwt-token', 'https://api.example.com/graphql', 'wss://api.example.com/graphql', async () => { // Refresh token function - called when JWT expires const newToken = await refreshAuthToken(); return newToken; }, '__mj', // Optional: MJ Core schema name (defaults to '__mj') ['schema1', 'schema2'], // Optional: Include only these schemas ['excluded_schema'], // Optional: Exclude these schemas 'mj-api-key' // Optional: For server-to-server communication);
// Setup the client (returns configured instance)const dataProvider = await setupGraphQLClient(config);
// Or create and configure manuallyconst dataProvider = new GraphQLDataProvider();await dataProvider.Config(config);Connect-only boot for embeds
Section titled “Connect-only boot for embeds”setupGraphQLClient performs the full boot: it connects, downloads the entire entity metadata graph, loads the current user and starts the registered startup engines. An anonymous embed (for example a widget that only needs to run a realtime voice session) needs none of that, and the metadata download dominates the cost. For that case use ConnectGraphQLClient, which connects and registers the provider globally (Metadata.Provider is set) and stops there.
import { ConnectGraphQLClient, GraphQLProviderConfigData } from '@memberjunction/graphql-dataprovider';
const config = new GraphQLProviderConfigData(token, 'https://api.example.com/graphql', 'wss://api.example.com/graphql', refreshToken);const provider = await ConnectGraphQLClient(config);ConnectGraphQLClient | setupGraphQLClient | |
|---|---|---|
Client, session id, token, ConfigData | Yes | Yes |
Metadata.Provider set | Yes | Yes |
ExecuteGQL, subscriptions, the realtime runtime | Works | Works |
Entity metadata, RunView, GetEntityObject, engines, CurrentUser | Not available | Available |
ConnectGraphQLClient raises no LoggedIn event, so nothing that waits for a login (engine pre-warming, for example) starts.
Calling ConnectGraphQLClient on a provider that is already fully booted is a no-op that returns it; it never drops loaded metadata or recreates the client. Calling it again on a connected but unbooted provider replaces ConfigData and keeps the session id. If the URL, token or API keys differ from the ones the current client was built with, the client is rebuilt so requests carry the new identity; with the same credentials the existing client is reused. For routine token expiry, keep using RefreshTokenFunction / OnAuthenticationError.
You can finish the boot later on the same instance by calling setupGraphQLClient(config); it then performs the full metadata load, current-user fetch and startup engines. This is also how an anonymous connection becomes a signed-in one: pass the login’s config, and the client is rebuilt with the new token before the metadata download. An already-open subscription socket keeps the identity it connected with until it reconnects.
setupGraphQLClient now rejects when the boot loaded no entity metadata (before the current user is loaded or any startup engine runs), instead of resolving with an empty provider. If you only want a connection, call ConnectGraphQLClient.
Working with Entities
Section titled “Working with Entities”import { GraphQLDataProvider } from '@memberjunction/graphql-dataprovider';
const dataProvider = new GraphQLDataProvider({ graphQLEndpoint: 'https://api.example.com/graphql',});
// Load an entityasync function getUserById(userId: number) { const result = await dataProvider.loadEntity('User', userId); return result.success ? result.entity : null;}
// Create a new entityasync function createUser(userData: any) { const entityData = { ID: 0, // 0 indicates a new entity FirstName: userData.firstName, LastName: userData.lastName, Email: userData.email, // other fields... };
const options = { IgnoreDirtyFields: false, // Save all fields SkipValidation: false // Run validation before save };
const result = await dataProvider.SaveEntity( entityData, 'User', options ); return result;}
// Update an existing entityasync function updateUser(userId: number, updatedData: any) { // Load the entity const entity = await dataProvider.GetEntityObject( 'User', { ID: userId } );
if (entity) { // Update fields Object.assign(entity.GetData(), updatedData);
// Save changes const result = await dataProvider.SaveEntity( entity.GetData(), 'User' ); return result; }
return { Success: false, Message: 'User not found' };}
// Delete an entityasync function deleteUser(userId: number) { const options = { IgnoreWarnings: false // Show warnings if any };
const result = await dataProvider.DeleteEntity( 'User', { ID: userId }, options ); return result;}Delta-Only Save() Wire Protocol on Update Mutations
Section titled “Delta-Only Save() Wire Protocol on Update Mutations”When saving an existing entity (entity.IsSaved === true) over GraphQLDataProvider.Save(), the provider transmits only the record’s primary key(s) and dirty writable fields (f.Dirty === true, including dirty parent-chain fields in an IS-A hierarchy):
vars.input: Contains only primary keys and dirty writable fields. Clean fields, read-only fields (__mj_CreatedAt,__mj_UpdatedAt), virtual fields, andNotLoadedfields are omitted from the mutation input.vars.input.OldValues___: ContainsKey/Valuepairs only for primary keys and dirty writable fields (recording their pre-mutationOldValuefor server-side concurrency conflict detection).- Server-Side Partial-Delta Handling (
ResolverBase): WhenResolverBase.UpdateRecordreceives a partial-deltaOldValues___,MustLoadTruthFromDatabaseloads the authoritative row from the database (InnerLoad) beforeTestAndSetClientOldValuesToDBValuescompares the dirty fields’ old values against the database and appliesentityObject.SetMany(clientNewValues)— preserving all untouched columns in the database. - Full Response Selection: The GraphQL mutation response selection set still requests all readable fields so
BaseEntity.finalizeSave()refreshes server-generated timestamps and computed/virtual columns without marking clean fieldsNotLoaded. - Forcing a Full Save (
IgnoreDirtyState: true): Passingoptions.IgnoreDirtyState = trueinEntitySaveOptionsbypasses the dirty-only filter and sends all loaded writable fields in bothvars.inputandvars.input.OldValues___. CreateMutations Unchanged: Creating a new record (!entity.IsSaved, or an IS-A promotion create) continues to send all loaded writable fields so client-initializedNewRecord()defaults and requiredCreate*Inputfields are transmitted.
Executing Views and Reports
Section titled “Executing Views and Reports”import { GraphQLDataProvider } from '@memberjunction/graphql-dataprovider';import { RunViewParams } from '@memberjunction/core';
const dataProvider = new GraphQLDataProvider();
// Execute a viewasync function getActiveUsers() { const params: RunViewParams = { EntityName: 'Users', ExtraFilter: "Status = 'Active'", OrderBy: 'LastName, FirstName', Fields: ['ID', 'FirstName', 'LastName', 'Email'], // Optional: specific fields IgnoreMaxRows: false, MaxRows: 50, ResultType: 'entity_object', // or 'simple' for raw data ForceAuditLog: true, AuditLogDescription: 'Loading active users for report' };
const result = await dataProvider.RunView(params); return result.Success ? result.Results : [];}
// Execute multiple views in parallelasync function getMultipleDatasets() { const viewParams: RunViewParams[] = [ { EntityName: 'Users', ExtraFilter: "Status = 'Active'" }, { EntityName: 'Orders', ExtraFilter: "OrderDate >= '2024-01-01'" } ];
const results = await dataProvider.RunViews(viewParams); return results;}
// Iterate through a large entity using keyset (seek) pagination// O(log N) per page, regardless of depth — ideal for background jobs and bulk processingasync function iterateAllTaxReturns() { let lastSeenKey: CompositeKey | undefined; // undefined => first page while (true) { const result = await dataProvider.RunView({ EntityName: 'Tax Returns', ExtraFilter: 'AddressLine1 IS NOT NULL', AfterKey: lastSeenKey, MaxRows: 500, ResultType: 'entity_object', }); if (!result.Success || result.Results.length === 0) break; for (const r of result.Results) { /* process */ } if (result.Results.length < 500) break; lastSeenKey = CompositeKey.FromID(result.Results[result.Results.length - 1].ID); }}// AfterKey is forwarded through the GraphQL wire via CompositeKeyInputType.// See guides/KEYSET_PAGINATION_GUIDE.md for the full pattern, constraints,// and reference implementations.
// Execute a reportasync function getSalesReport(reportId: string) { const params = { ReportID: reportId };
const result = await dataProvider.RunReport(params); return result.Success ? result.Results : [];}
// Execute a saved queryasync function runCustomQuery(queryId: string, parameters: any) { const params = { QueryID: queryId, Parameters: parameters };
const result = await dataProvider.RunQuery(params); return result;}
// Execute an ad-hoc SQL query (SELECT/WITH only, validated server-side)async function runAdhocQuery(sql: string) { const result = await dataProvider.RunQuery({ SQL: sql }); return result;}Atomicity on the client — what this provider can and cannot do
Section titled “Atomicity on the client — what this provider can and cannot do”GraphQLDataProvider reports SupportsEntityTransactions === false and does not implement
BeginEntityTransaction(). There is no local transaction to open, and a server transaction cannot be
held open across round trips. Two supported ways to get atomicity from the browser:
| Need | Use |
|---|---|
| Several unrelated records in one atomic round trip | Transaction Group (below) — batched into a single ExecuteTransactionGroup mutation that runs in one server-side SQL transaction |
| A parent and its children saved together | Entity graph — declare a RelatedRecordCollection on a shared client+server subclass and call entity.Save(). BaseEntity detects that this provider cannot transact and routes the whole unit of work to the server via the MJ.SaveEntityGraph remote operation, which rebuilds the records as their server-side subclasses and executes the cascade there, inside a real transaction. |
Do not reach for a Transaction Group to save a parent and its children: saves are deferred, so
the parent’s primary key is unavailable afterwards, there is no read-your-writes, and Save()
returns true before anything has persisted. See
Transactions, Batching & Entity Graphs.
Using Transaction Groups
Section titled “Using Transaction Groups”import { GraphQLDataProvider } from '@memberjunction/graphql-dataprovider';import { TransactionGroupBase } from '@memberjunction/core';
const dataProvider = new GraphQLDataProvider({ graphQLEndpoint: 'https://api.example.com/graphql',});
// Define a transaction groupclass OrderTransactionGroup extends TransactionGroupBase { constructor() { super('CreateOrderWithItems'); }}
// Use the transaction groupasync function createOrderWithItems(orderData: any, items: any[]) { // Create transaction group const transaction = await dataProvider.CreateTransactionGroup();
// Create order entity const orderEntity = await dataProvider.GetEntityObject('Order'); orderEntity.NewRecord(); orderEntity.Set('CustomerID', orderData.customerId); orderEntity.Set('OrderDate', new Date()); orderEntity.Set('Status', 'New');
// Add to transaction const orderItem = transaction.AddTransaction(orderEntity, 'create');
// Add order items with references for (const item of items) { const itemEntity = await dataProvider.GetEntityObject('OrderItem'); itemEntity.NewRecord(); itemEntity.Set('ProductID', item.productId); itemEntity.Set('Quantity', item.quantity); itemEntity.Set('Price', item.price);
// Reference the order using a variable const orderTransaction = transaction.AddTransaction(itemEntity, 'create'); transaction.AddVariable( 'orderID', 'ID', 'FieldValue', orderItem.BaseEntity, orderTransaction.BaseEntity, 'OrderID' ); }
// Execute transaction const results = await transaction.Submit(); return results;}Executing Actions
Section titled “Executing Actions”import { GraphQLActionClient } from '@memberjunction/graphql-dataprovider';import { ActionParam } from '@memberjunction/actions-base';
const actionClient = new GraphQLActionClient(dataProvider);
// Execute a standalone actionasync function runAction(actionId: string) { const params: ActionParam[] = [ { Name: 'parameter1', Value: 'value1', Type: 'Input' }, { Name: 'parameter2', Value: 123, Type: 'Input' } ];
const result = await actionClient.RunAction( actionId, params, false // skipActionLog );
if (result.Success) { console.log('Action result:', result.ResultCode); } return result;}
// Execute an entity actionasync function runEntityAction() { const params = { EntityAction: entityAction, // EntityActionEntity instance InvocationType: { Name: 'SingleRecord' }, EntityObject: userEntity, // BaseEntity instance ContextUser: currentUser // UserInfo instance };
const result = await actionClient.RunEntityAction(params); return result;}Field Mapping
Section titled “Field Mapping”import { FieldMapper } from '@memberjunction/graphql-dataprovider';
// The GraphQL provider automatically handles field mapping for system fields// __mj_CreatedAt <-> _mj__CreatedAt// __mj_UpdatedAt <-> _mj__UpdatedAt// __mj_DeletedAt <-> _mj__DeletedAt
// You can also use the FieldMapper directlyconst mapper = new FieldMapper();
// Map fields in an objectconst mappedData = mapper.MapFields({ __mj_CreatedAt: '2024-01-01', Name: 'John Doe'});// Result: { _mj__CreatedAt: '2024-01-01', Name: 'John Doe' }
// Map individual field namesconst mappedField = mapper.MapFieldName('__mj_CreatedAt');// Result: '_mj__CreatedAt'
// Reverse mappingconst originalField = mapper.ReverseMapFieldName('_mj__CreatedAt');// Result: '__mj_CreatedAt'WebSocket Subscriptions
Section titled “WebSocket Subscriptions”import { GraphQLDataProvider } from '@memberjunction/graphql-dataprovider';import { Observable } from 'rxjs';
const dataProvider = new GraphQLDataProvider();
// Subscribe to record changesfunction subscribeToRecordChanges() { const observable: Observable<RecordChange[]> = await dataProvider.GetRecordChanges( 'User', { ID: 123 }, ['update', 'delete'], // Watch for these operations true // Return initial values );
const subscription = observable.subscribe(changes => { console.log('Record changes:', changes); // Handle changes });
// Later, unsubscribe subscription.unsubscribe();}AI Operations
Section titled “AI Operations”The GraphQL provider includes a comprehensive AI client for executing prompts and generating embeddings.
import { GraphQLDataProvider } from '@memberjunction/graphql-dataprovider';
const dataProvider = new GraphQLDataProvider();const aiClient = dataProvider.AI; // Access the AI client
// Execute a stored AI promptasync function runAIPrompt() { const result = await aiClient.RunAIPrompt({ promptId: 'prompt-123', data: { context: 'user specific data' }, temperature: 0.7, topP: 0.9, responseFormat: 'json' });
if (result.success) { console.log('AI Response:', result.output); console.log('Parsed Result:', result.parsedResult); console.log('Tokens Used:', result.tokensUsed); } return result;}
// Execute a simple prompt without stored configurationasync function runSimplePrompt() { const result = await aiClient.ExecuteSimplePrompt({ systemPrompt: 'You are a helpful data analyst', messages: [ { message: 'What are the key trends?', role: 'user' }, { message: 'Based on the data...', role: 'assistant' }, { message: 'Can you elaborate?', role: 'user' } ], preferredModels: ['gpt-4', 'claude-3'], modelPower: 'medium', // 'lowest', 'medium', or 'highest' responseFormat: 'json' });
if (result.success) { console.log('Response:', result.result); console.log('Model Used:', result.modelName);
// If response contains JSON if (result.resultObject) { console.log('Parsed JSON:', result.resultObject); } } return result;}
// Generate text embeddingsasync function generateEmbeddings() { // Single text embedding const single = await aiClient.EmbedText({ textToEmbed: 'This is a sample text', modelSize: 'small' // 'small' or 'medium' });
console.log('Embedding:', single.embeddings); // number[] console.log('Dimensions:', single.vectorDimensions);
// Multiple text embeddings (batch) const batch = await aiClient.EmbedText({ textToEmbed: [ 'First text to embed', 'Second text to embed', 'Third text to embed' ], modelSize: 'medium' });
console.log('Embeddings:', batch.embeddings); // number[][] console.log('Model:', batch.modelName);
return batch;}
// Run an AI agent for conversational interactionsasync function runAIAgent() { const result = await aiClient.RunAIAgent({ agentId: 'agent-456', messages: [ { role: 'user', content: 'Hello, I need help with data analysis' }, { role: 'assistant', content: 'I can help you analyze your data' }, { role: 'user', content: 'What patterns do you see?' } ], sessionId: 'session-789', data: { contextData: 'relevant information' } });
if (result.success) { console.log('Agent Response:', result.payload); console.log('Execution Time:', result.executionTimeMs, 'ms'); } return result;}System User Client
Section titled “System User Client”import { GraphQLSystemUserClient } from '@memberjunction/graphql-dataprovider';
// Create system user client for server-to-server communicationconst systemClient = new GraphQLSystemUserClient( 'https://api.example.com/graphql', '', // No JWT token needed 'session-id', 'mj-api-key' // Shared secret key);
// Execute queries as system userconst queries = [ 'SELECT * FROM Users WHERE Active = 1', 'SELECT COUNT(*) as Total FROM Orders'];
const result = await systemClient.GetData( queries, 'access-token' // Short-lived access token);
if (result.Success) { console.log('Query results:', result.Results);}
// AI Operations with system privilegesasync function systemAIOperations() { // Run AI prompt as system user const promptResult = await systemClient.RunAIPrompt({ promptId: 'system-prompt-123', skipValidation: true, data: { systemContext: 'internal data' } });
// Execute simple prompt as system user const simpleResult = await systemClient.ExecuteSimplePrompt({ systemPrompt: 'Analyze system performance', modelPower: 'highest' });
// Generate embeddings as system user const embeddings = await systemClient.EmbedText({ textToEmbed: ['System log entry 1', 'System log entry 2'], modelSize: 'medium' });
return { promptResult, simpleResult, embeddings };}
// Run AI agent as system userasync function systemAgentOperation() { const result = await systemClient.RunAIAgent({ agentId: 'system-agent-123', messages: [ { role: 'system', content: 'Process batch data' } ], sessionId: 'system-session-456' });
return result;}
// Sync roles and usersconst syncResult = await systemClient.SyncRolesAndUsers({ Roles: [ { ID: '1', Name: 'Admin', Description: 'Administrator role' } ], Users: [ { ID: '1', Name: 'john.doe', Email: 'john@example.com', Type: 'User', FirstName: 'John', LastName: 'Doe', Roles: [{ ID: '1', Name: 'Admin', Description: 'Administrator role' }] } ]});IS-A Type Relationship Support
Section titled “IS-A Type Relationship Support”The GraphQL data provider provides transparent handling of IS-A type relationships (entity inheritance) on the client side. When working with IS-A child entities, the provider seamlessly manages the complexity of parent-child field coordination.
How IS-A Relationships Work
Section titled “How IS-A Relationships Work”For IS-A child entities:
- GraphQL Input Types: Include ALL fields (both parent and child fields) in a single mutation
- Client Simplicity: The client sends one mutation with all fields combined
- Server Responsibility: The GraphQL resolver on the server side handles:
- Splitting fields between parent and child entities
- Orchestrating parent saves before child saves
- Managing transaction boundaries
- Rolling back changes if any operation fails
Client-Side Behavior
Section titled “Client-Side Behavior”// Example: Saving an AIPrompt (child of Template)const aiPrompt = await md.GetEntityObject<AIPromptEntity>('AI Prompts');aiPrompt.NewRecord();
// Set both parent fields (from Template)aiPrompt.Name = 'Customer Analysis Prompt';aiPrompt.Description = 'Analyzes customer behavior patterns';
// Set child fields (from AIPrompt)aiPrompt.AIModelID = 'gpt-4-model-id';aiPrompt.PromptRole = 'User';
// Save - client sends all fields in one mutationawait aiPrompt.Save();// The server handles splitting and saving parent first, then childTransaction Management
Section titled “Transaction Management”Important aspects of transaction handling with IS-A relationships:
- No Client-Side Transactions:
BaseEntity.ProviderTransactionremains null on the client side - Server-Side Orchestration: The GraphQL resolver creates and manages transactions internally
- Automatic Rollback: If either parent or child save fails, all changes are rolled back
- Transparent to Client: The client code doesn’t need to manage these complexities
Load Operations
Section titled “Load Operations”When loading IS-A child entities:
- The GraphQL query returns a merged view with both parent and child fields
- All fields appear as properties on the entity object
- No special handling required in client code
- Field access is seamless regardless of whether it’s a parent or child field
// Loading returns merged parent + child viewconst prompt = await md.GetEntityObject<AIPromptEntity>('AI Prompts', contextUser);await prompt.Load('prompt-id');
// Access parent fieldsconsole.log(prompt.Name); // From Template
// Access child fieldsconsole.log(prompt.PromptRole); // From AIPrompt
// Both appear as regular properties with no distinctionRelated Documentation
Section titled “Related Documentation”For comprehensive information about IS-A relationships in MemberJunction:
- IS-A Relationships Architecture - Server-side implementation details, entity structure, and transaction management
Realtime session client (GraphQLRealtimeSessionClient)
Section titled “Realtime session client (GraphQLRealtimeSessionClient)”Typed transport for the realtime session surface of MJServer: mid-session identity verification and the
session event subscription. It is what the browser realtime runtime and the embeddable widget call; it holds no
logic (see guides/TRANSPORT_LAYER_ARCHITECTURE_GUIDE.md). The server side is documented in
packages/MJServer/src/realtimeSessions/README.md; the event types live in @memberjunction/ai-core-plus.
import { GraphQLDataProvider, GraphQLRealtimeSessionClient } from '@memberjunction/graphql-dataprovider';
const client = new GraphQLRealtimeSessionClient(provider as GraphQLDataProvider);
// 1. Listen first, so an event fired right after the request cannot be missed.const sub = client.SubscribeToSessionEventsOfType(agentSessionId, 'identity.verified').subscribe((event) => { show(`Verified as ${event.Payload.VerifiedEmail}`); // typed payload});
// 2. Ask for an emailed link + code.const sent = await client.RequestVerification({ AgentSessionID: agentSessionId, Name: 'Pat', Email: 'pat@acme.com' });if (!sent.Success) show(sent.Message); // sent.ErrorCode is e.g. 'consumer_domain' | 'rate_limited'
// 3. The person types the code (or opens the link on any device).const done = await client.SubmitVerificationCode({ AgentSessionID: agentSessionId, Code: typed });
// 4. After a reconnect, events are NOT replayed: read the durable state.const status = await client.GetVerificationStatus(agentSessionId);| Method | GraphQL | Notes |
|---|---|---|
RequestVerification({AgentSessionID, Name, Email}) | mutation RequestRealtimeSessionVerification | Policy and limits are enforced server-side. |
SubmitVerificationCode({AgentSessionID, Code}) | mutation SubmitRealtimeSessionVerificationCode | Single use; wrong codes are counted. |
GetVerificationStatus(agentSessionId) | query RealtimeSessionVerificationStatus | The recovery path for a missed event. |
SubscribeToSessionEvents(agentSessionId) | subscription RealtimeSessionEvents | Reuses GraphQLDataProvider.Subscribe (the same WebSocket client as the other subscriptions). |
SubscribeToSessionEventsOfType(agentSessionId, type) | same | Narrowed to one event type with a typed payload. |
Contracts worth knowing:
- The three request/response methods never throw. A transport fault comes back as
Success: falsewithErrorCode: 'transport_error'(a client-only code). UseIsKnownRealtimeSessionVerificationErrorCodeto narrowErrorCode; the server may add codes, so the field is typedstring. - The email address and the typed code are never logged.
- Events are validated.
PayloadJsonis parsed withParseRealtimeSessionEvent; malformed events are dropped and logged, and a type this client does not know arrives as anUnknownRealtimeSessionEvent(ignore it) instead of breaking an older client. - Authorization is the session’s own principal. A subscription for someone else’s session fails as an error on the observable (“Realtime session not found.”); the socket and other subscriptions are unaffected.
- Reconnects. When the provider recycles an expired-token socket the observable completes. Re-subscribe, then call
GetVerificationStatusonce. - Multi-provider. Construct it with the provider you mean (in Angular,
this.ProviderToUse), never a hidden global.
Key Classes and Types
Section titled “Key Classes and Types”| Class/Type | Description |
|---|---|
GraphQLDataProvider | Main class implementing IEntityDataProvider, IMetadataProvider, IRunViewProvider, IRunReportProvider, and IRunQueryProvider interfaces |
GraphQLProviderConfigData | Configuration class for setting up the GraphQL provider with authentication and connection details |
GraphQLActionClient | Client for executing actions and entity actions through GraphQL |
GraphQLAIClient | Client for AI operations including prompts, agents, and embeddings |
GraphQLRealtimeSessionClient | Typed client for realtime session identity verification and the RealtimeSessionEvents subscription |
GraphQLSystemUserClient | Specialized client for server-to-server communication using API keys |
GraphQLTransactionGroup | Manages complex multi-entity transactions with variable support |
FieldMapper | Handles automatic field name mapping between client and server |
setupGraphQLClient | Helper function to quickly setup and configure the GraphQL client |
API Documentation
Section titled “API Documentation”Core Methods
Section titled “Core Methods”Entity Operations
Section titled “Entity Operations”GetEntityObject(entityName: string, compositeKey?: CompositeKey)- Get an entity object instanceSaveEntity(entityData: any, entityName: string, options?: EntitySaveOptions)- Save entity dataDeleteEntity(entityName: string, compositeKey: CompositeKey, options?: EntityDeleteOptions)- Delete an entityGetRecordChanges(entityName: string, compositeKey: CompositeKey, operations: string[], includeInitial: boolean)- Subscribe to entity changes
View and Query Operations
Section titled “View and Query Operations”RunView(params: RunViewParams)- Execute a single viewRunViews(params: RunViewParams[])- Execute multiple views in parallelRunReport(params: RunReportParams)- Execute a reportRunQuery(params: RunQueryParams)- Execute a custom query
Transaction Operations
Section titled “Transaction Operations”CreateTransactionGroup()- Create a new transaction groupExecuteTransaction(transaction: TransactionGroupBase)- Execute a transaction group
Duplicate Detection
Section titled “Duplicate Detection”GetRecordDuplicates(request: PotentialDuplicateRequest)- Find potential duplicate recordsMergeRecords(request: RecordMergeRequest, contextUser?: UserInfo, options?: EntityMergeOptions)- Merge duplicate records with optional transaction scope support
Metadata Operations
Section titled “Metadata Operations”GetEntityRecordName(entityName: string, compositeKey: CompositeKey)- Get display name for a recordGetEntityRecordNames(info: EntityRecordNameInput[])- Get display names for multiple recordsGetEntityDependencies(entityName: string, compositeKey: CompositeKey)- Get record dependencies
AI Operations (via GraphQLAIClient)
Section titled “AI Operations (via GraphQLAIClient)”RunAIPrompt(params: RunAIPromptParams)- Execute a stored AI prompt with parametersRunAIAgent(params: RunAIAgentParams)- Run an AI agent for conversational interactionsExecuteSimplePrompt(params: ExecuteSimplePromptParams)- Execute ad-hoc prompts without stored configurationEmbedText(params: EmbedTextParams)- Generate text embeddings using local models
Caching & Real-Time Sync
Section titled “Caching & Real-Time Sync”This package provides browser-side storage providers for LocalCacheManager and subscribes to CACHE_INVALIDATION GraphQL events to keep browser data fresh when other users or servers modify entities.
Storage providers
Section titled “Storage providers”| Provider | Backing store | Key features |
|---|---|---|
BrowserIndexedDBStorageProvider | IndexedDB | Native object storage via structured clone (no JSON parse/stringify on the hot path), single-transaction batched reads via GetItems, per-category object stores, version-bumped wipe on minor releases |
BrowserLocalStorageProvider | localStorage | Generic-typed SetItem<T> / GetItem<T> / GetItems<T> with internal JSON serialization, key-prefix-based category isolation |
Both implement the generic-typed ILocalStorageProvider interface defined in @memberjunction/core — see that package’s README for interface details.
IndexedDB schema versioning (auto-derived from package version)
Section titled “IndexedDB schema versioning (auto-derived from package version)”BrowserIndexedDBStorageProvider ties its IDB DB_VERSION to this package’s package.json version (major * 1000 + minor):
- 5.30.x → DB version
5030 - 5.31.x → DB version
5031 - 6.0.x → DB version
6000
Patch releases keep the same DB version, so frequent patch deploys don’t force users into cold-cache loads. Each minor release triggers a one-time onupgradeneeded that wipes all object stores; caches repopulate on first use.
The version is generated at build time by scripts/generate-version.mjs (runs as a prebuild/pretest step) into src/version.generated.ts. To force an extra wipe within the same minor (rare — emergency hotfix scenario), bump the MANUAL_CACHE_REVISION constant at the top of storage-providers.ts.
Batched IDB reads (single-transaction)
Section titled “Batched IDB reads (single-transaction)”BrowserIndexedDBStorageProvider.GetItems<T>(keys, category?) reads N keys inside a single read transaction. Per-call IDB transaction overhead is real (~3–10ms each in most browsers) and serialized on the same object store, so Promise.all([...N GetItem calls]) actually pays N × overhead even though it looks parallel.
For the warm-load smart-cache-check flow this matters a lot — the client reads cached fingerprint metadata for every view in the coalesced engine bundle, then reads the actual cached row data for entries the server marks as ‘current’. Both passes used to do per-key reads; both now use GetItems to amortize the overhead into one transaction. For 85 keys this is the difference between ~425ms of pure IDB bookkeeping and ~10ms.
Warm-load orchestration
Section titled “Warm-load orchestration”setupGraphQLClient orchestrates a deterministic warm-load path: after provider.Config(...) loads the cached AllMetadata blob from IndexedDB (gzip-compressed, three keys read in a single batched call), it calls provider.preValidateAndRefresh() to confirm the cache is current via a single batched timestamp round-trip. If current, engines trust their local caches and route per-view requests through RunViewsWithCacheCheck — a fingerprint-only GraphQL call that returns either a “current” marker (use local cache) or fresh data for stale entries.
For the full architecture — differential updates, Redis cross-server sync, session-based deduplication, and deployment topologies — see the Caching & Pub/Sub Guide.
Dependencies
Section titled “Dependencies”@memberjunction/core- Core MemberJunction functionality@memberjunction/core-entities- Entity definitions@memberjunction/actions-base- Action system base classes@memberjunction/global- Global utilitiesgraphql- GraphQL language and executiongraphql-request- Minimal GraphQL clientgraphql-ws- GraphQL WebSocket client for subscriptions@tempfix/idb- IndexedDB wrapper for offline storagerxjs- Reactive extensions for subscriptionsuuid- UUID generation for session IDs
Requirements
Section titled “Requirements”- Node.js 16+
- Modern browser with WebSocket support (for subscriptions)
- MemberJunction GraphQL API endpoint
- Valid JWT token or MJ API key for authentication
License
Section titled “License”Business Source License 1.1 — see LICENSE for details.