MemberJunction GraphQL Data Provider
A comprehensive GraphQL client for MemberJunction that provides a complete data access layer for connecting applications to MemberJunction APIs.
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
npm install @memberjunction/graphql-dataprovider
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
Usage
Setting up the GraphQL Client
import { setupGraphQLClient, GraphQLProviderConfigData } from '@memberjunction/graphql-dataprovider';
const config = new GraphQLProviderConfigData(
'your-jwt-token',
'https://api.example.com/graphql',
'wss://api.example.com/graphql',
async () => {
const newToken = await refreshAuthToken();
return newToken;
},
'__mj',
['schema1', 'schema2'],
['excluded_schema'],
'mj-api-key'
);
const dataProvider = await setupGraphQLClient(config);
const dataProvider = new GraphQLDataProvider();
await dataProvider.Config(config);
Working with Entities
import { GraphQLDataProvider } from '@memberjunction/graphql-dataprovider';
const dataProvider = new GraphQLDataProvider({
graphQLEndpoint: 'https://api.example.com/graphql',
});
async function getUserById(userId: number) {
const result = await dataProvider.loadEntity('User', userId);
return result.success ? result.entity : null;
}
async function createUser(userData: any) {
const entityData = {
ID: 0,
FirstName: userData.firstName,
LastName: userData.lastName,
Email: userData.email,
};
const options = {
IgnoreDirtyFields: false,
SkipValidation: false
};
const result = await dataProvider.SaveEntity(
entityData,
'User',
options
);
return result;
}
async function updateUser(userId: number, updatedData: any) {
const entity = await dataProvider.GetEntityObject(
'User',
{ ID: userId }
);
if (entity) {
Object.assign(entity.GetData(), updatedData);
const result = await dataProvider.SaveEntity(
entity.GetData(),
'User'
);
return result;
}
return { Success: false, Message: 'User not found' };
}
async function deleteUser(userId: number) {
const options = {
IgnoreWarnings: false
};
const result = await dataProvider.DeleteEntity(
'User',
{ ID: userId },
options
);
return result;
}
Executing Views and Reports
import { GraphQLDataProvider } from '@memberjunction/graphql-dataprovider';
import { RunViewParams } from '@memberjunction/core';
const dataProvider = new GraphQLDataProvider();
async function getActiveUsers() {
const params: RunViewParams = {
EntityName: 'Users',
ExtraFilter: "Status = 'Active'",
OrderBy: 'LastName, FirstName',
Fields: ['ID', 'FirstName', 'LastName', 'Email'],
IgnoreMaxRows: false,
MaxRows: 50,
ResultType: 'entity_object',
ForceAuditLog: true,
AuditLogDescription: 'Loading active users for report'
};
const result = await dataProvider.RunView(params);
return result.Success ? result.Results : [];
}
async 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;
}
async function iterateAllTaxReturns() {
let lastSeenKey: CompositeKey | undefined;
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) { }
if (result.Results.length < 500) break;
lastSeenKey = CompositeKey.FromID(result.Results[result.Results.length - 1].ID);
}
}
async function getSalesReport(reportId: string) {
const params = {
ReportID: reportId
};
const result = await dataProvider.RunReport(params);
return result.Success ? result.Results : [];
}
async function runCustomQuery(queryId: string, parameters: any) {
const params = {
QueryID: queryId,
Parameters: parameters
};
const result = await dataProvider.RunQuery(params);
return result;
}
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
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:
| 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
import { GraphQLDataProvider } from '@memberjunction/graphql-dataprovider';
import { TransactionGroupBase } from '@memberjunction/core';
const dataProvider = new GraphQLDataProvider({
graphQLEndpoint: 'https://api.example.com/graphql',
});
class OrderTransactionGroup extends TransactionGroupBase {
constructor() {
super('CreateOrderWithItems');
}
}
async function createOrderWithItems(orderData: any, items: any[]) {
const transaction = await dataProvider.CreateTransactionGroup();
const orderEntity = await dataProvider.GetEntityObject('Order');
orderEntity.NewRecord();
orderEntity.Set('CustomerID', orderData.customerId);
orderEntity.Set('OrderDate', new Date());
orderEntity.Set('Status', 'New');
const orderItem = transaction.AddTransaction(orderEntity, 'create');
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);
const orderTransaction = transaction.AddTransaction(itemEntity, 'create');
transaction.AddVariable(
'orderID',
'ID',
'FieldValue',
orderItem.BaseEntity,
orderTransaction.BaseEntity,
'OrderID'
);
}
const results = await transaction.Submit();
return results;
}
Executing Actions
import { GraphQLActionClient } from '@memberjunction/graphql-dataprovider';
import { ActionParam } from '@memberjunction/actions-base';
const actionClient = new GraphQLActionClient(dataProvider);
async 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
);
if (result.Success) {
console.log('Action result:', result.ResultCode);
}
return result;
}
async function runEntityAction() {
const params = {
EntityAction: entityAction,
InvocationType: { Name: 'SingleRecord' },
EntityObject: userEntity,
ContextUser: currentUser
};
const result = await actionClient.RunEntityAction(params);
return result;
}
Field Mapping
import { FieldMapper } from '@memberjunction/graphql-dataprovider';
const mapper = new FieldMapper();
const mappedData = mapper.MapFields({
__mj_CreatedAt: '2024-01-01',
Name: 'John Doe'
});
const mappedField = mapper.MapFieldName('__mj_CreatedAt');
const originalField = mapper.ReverseMapFieldName('_mj__CreatedAt');
WebSocket Subscriptions
import { GraphQLDataProvider } from '@memberjunction/graphql-dataprovider';
import { Observable } from 'rxjs';
const dataProvider = new GraphQLDataProvider();
function subscribeToRecordChanges() {
const observable: Observable<RecordChange[]> =
await dataProvider.GetRecordChanges(
'User',
{ ID: 123 },
['update', 'delete'],
true
);
const subscription = observable.subscribe(changes => {
console.log('Record changes:', changes);
});
subscription.unsubscribe();
}
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;
async 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;
}
async 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',
responseFormat: 'json'
});
if (result.success) {
console.log('Response:', result.result);
console.log('Model Used:', result.modelName);
if (result.resultObject) {
console.log('Parsed JSON:', result.resultObject);
}
}
return result;
}
async function generateEmbeddings() {
const single = await aiClient.EmbedText({
textToEmbed: 'This is a sample text',
modelSize: 'small'
});
console.log('Embedding:', single.embeddings);
console.log('Dimensions:', single.vectorDimensions);
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);
console.log('Model:', batch.modelName);
return batch;
}
async 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
import { GraphQLSystemUserClient } from '@memberjunction/graphql-dataprovider';
const systemClient = new GraphQLSystemUserClient(
'https://api.example.com/graphql',
'',
'session-id',
'mj-api-key'
);
const queries = [
'SELECT * FROM Users WHERE Active = 1',
'SELECT COUNT(*) as Total FROM Orders'
];
const result = await systemClient.GetData(
queries,
'access-token'
);
if (result.Success) {
console.log('Query results:', result.Results);
}
async function systemAIOperations() {
const promptResult = await systemClient.RunAIPrompt({
promptId: 'system-prompt-123',
skipValidation: true,
data: { systemContext: 'internal data' }
});
const simpleResult = await systemClient.ExecuteSimplePrompt({
systemPrompt: 'Analyze system performance',
modelPower: 'highest'
});
const embeddings = await systemClient.EmbedText({
textToEmbed: ['System log entry 1', 'System log entry 2'],
modelSize: 'medium'
});
return { promptResult, simpleResult, embeddings };
}
async function systemAgentOperation() {
const result = await systemClient.RunAIAgent({
agentId: 'system-agent-123',
messages: [
{ role: 'system', content: 'Process batch data' }
],
sessionId: 'system-session-456'
});
return result;
}
const 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
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
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
const aiPrompt = await md.GetEntityObject<AIPromptEntity>('AI Prompts');
aiPrompt.NewRecord();
aiPrompt.Name = 'Customer Analysis Prompt';
aiPrompt.Description = 'Analyzes customer behavior patterns';
aiPrompt.AIModelID = 'gpt-4-model-id';
aiPrompt.PromptRole = 'User';
await aiPrompt.Save();
Transaction Management
Important aspects of transaction handling with IS-A relationships:
- No Client-Side Transactions:
BaseEntity.ProviderTransaction remains 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
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
const prompt = await md.GetEntityObject<AIPromptEntity>('AI Prompts', contextUser);
await prompt.Load('prompt-id');
console.log(prompt.Name);
console.log(prompt.PromptRole);
Related Documentation
For comprehensive information about IS-A relationships in MemberJunction:
Key Classes and Types
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 |
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
Core Methods
Entity Operations
GetEntityObject(entityName: string, compositeKey?: CompositeKey) - Get an entity object instance
SaveEntity(entityData: any, entityName: string, options?: EntitySaveOptions) - Save entity data
DeleteEntity(entityName: string, compositeKey: CompositeKey, options?: EntityDeleteOptions) - Delete an entity
GetRecordChanges(entityName: string, compositeKey: CompositeKey, operations: string[], includeInitial: boolean) - Subscribe to entity changes
View and Query Operations
RunView(params: RunViewParams) - Execute a single view
RunViews(params: RunViewParams[]) - Execute multiple views in parallel
RunReport(params: RunReportParams) - Execute a report
RunQuery(params: RunQueryParams) - Execute a custom query
Transaction Operations
CreateTransactionGroup() - Create a new transaction group
ExecuteTransaction(transaction: TransactionGroupBase) - Execute a transaction group
Duplicate Detection
GetRecordDuplicates(request: PotentialDuplicateRequest) - Find potential duplicate records
MergeRecords(request: RecordMergeRequest, contextUser?: UserInfo, options?: EntityMergeOptions) - Merge duplicate records with optional transaction scope support
Metadata Operations
GetEntityRecordName(entityName: string, compositeKey: CompositeKey) - Get display name for a record
GetEntityRecordNames(info: EntityRecordNameInput[]) - Get display names for multiple records
GetEntityDependencies(entityName: string, compositeKey: CompositeKey) - Get record dependencies
AI Operations (via GraphQLAIClient)
RunAIPrompt(params: RunAIPromptParams) - Execute a stored AI prompt with parameters
RunAIAgent(params: RunAIAgentParams) - Run an AI agent for conversational interactions
ExecuteSimplePrompt(params: ExecuteSimplePromptParams) - Execute ad-hoc prompts without stored configuration
EmbedText(params: EmbedTextParams) - Generate text embeddings using local models
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
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)
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)
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
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
@memberjunction/core - Core MemberJunction functionality
@memberjunction/core-entities - Entity definitions
@memberjunction/actions-base - Action system base classes
@memberjunction/global - Global utilities
graphql - GraphQL language and execution
graphql-request - Minimal GraphQL client
graphql-ws - GraphQL WebSocket client for subscriptions
@tempfix/idb - IndexedDB wrapper for offline storage
rxjs - Reactive extensions for subscriptions
uuid - UUID generation for session IDs
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
Business Source License 1.1 — see LICENSE for details.