Transactions, Batching & Entity Graphs
Deep dive on entity graphs specifically — metadata declaration, flow diagrams for the local save and the network round trip, cache-backed sources and load modes: Related-Record Collections (1:N, FK on the related row) and Embedded Records (1:1, FK on the owner).
MemberJunction has three different mechanisms that all sound like “do several things at once,” and picking the wrong one produces bugs that do not announce themselves — torn writes, saves that report success without persisting, children that duplicate on every edit.
This guide names the three, says exactly what each is for, and gives you a decision tree.
The three mechanisms at a glance
Section titled “The three mechanisms at a glance”| Provider transactions | Transaction Groups | Entity graphs | |
|---|---|---|---|
| What it is | An ambient SQL transaction on a provider | A batch of arbitrary records shipped in one round trip | A parent plus its companion-contributed children, saved as one unit |
| Where you can use it | Server only | Client or server | Client or server |
| Records involved | Whatever you save inside it | Any, unrelated | One root plus its declared companions |
| Execution | Sequential, read-your-writes | Deferred until Submit() | Sequential, read-your-writes |
| Can you read what you just wrote? | ✅ Yes | ❌ No | ✅ Yes |
| Is the PK available after saving the parent? | ✅ Yes | ❌ No | ✅ Yes |
Does Save() return after persisting? | ✅ Yes | ❌ No — returns true early | ✅ Yes |
| Atomic | ✅ | ✅ | ✅ server-side; client routes to server |
| API | provider.BeginEntityTransaction() | md.CreateTransactionGroup() | entity.Save() |
1. Provider transactions
Section titled “1. Provider transactions”Use when: server-side code writes several records that must land together, and you are writing the orchestration yourself.
import { RunInEntityTransaction } from '@memberjunction/core';
await RunInEntityTransaction(this.ProviderToUse, async () => { await header.Save(); for (const line of lines) { line.HeaderID = header.ID; // read-your-writes: header.ID exists here await line.Save(); }});BeginEntityTransaction() returns an {@link EntityTransactionScope} that is settle-once — the
first Commit() or Rollback() wins, so the try/catch shape above is safe even when the work
already unwound its own scope. RunInEntityTransaction() wraps that for you and is preferred.
Join semantics — the important part
Section titled “Join semantics — the important part”The provider arbitrates. If a transaction is already in flight, BeginEntityTransaction() joins
it (a dialect savepoint — SQL Server SAVE TRANSACTION, PostgreSQL SAVEPOINT) rather than starting
a second physical transaction; only the outermost commit commits for real. Participants never ask
whether someone else already opened a transaction — code that needs to be inside one just calls
BeginEntityTransaction() (or RunInEntityTransaction()) and joins automatically. To see whether a
transaction is open on an instance at all, read TransactionDepth (public on DatabaseProviderBase),
not IsInTransaction — SQL Server deliberately leaves IsInTransaction false so RunMaybeSerial can
fan out. TransactionDepth is instance-wide: on a shared provider TransactionDepth > 0 does not
mean your statements run in that transaction (see Who is in the transaction).
After a server abort, call ResetTransactionState() rather than poking private fields.
That is not a nicety, it is a correctness requirement. Before 6.2 MemberJunction had two transaction mechanisms that were blind to each other:
GenericDatabaseProvider.BeginTransaction()— depth-counted, re-entrant, dialect savepoints. A server abort of the ambient TX is not recoverable (DoomedTransactionError); the outerCommitfails andSave()returns false. While doomed, every statement without an explicitconnectionSourcethrows. Concurrent nested scopes on one provider instance are unsupported — do not let a nested unit outlive its outer scope (Promise.allover throwing units on one provider starts a fresh physical TX); useallSettledor serialize.BeginISATransaction()— four lines that opened a brand-newsql.Transactionon the pool with no depth awareness at all.
An entity that hit both paths — an IS-A entity saved inside an application cascade, or a composite whose child is an IS-A leaf — wrote into two independent physical transactions on the same pool. Rolling one back left the other committed. No error was raised.
The BeginISATransaction / CommitISATransaction / RollbackISATransaction trio was removed in
6.2, along with BaseEntity.ProviderTransaction and PropagateTransactionToParents(). IS-A now uses
the same BeginEntityTransaction() everything else does, which is why the two can no longer
disagree. If you called any of them, switch to BeginEntityTransaction() — or better,
RunInEntityTransaction().
Concurrency note. The transaction handle lives on the provider instance; membership in it is per async scope (see “Who is in the transaction” below). MJServer builds per-request providers, so an ambient transaction is effectively request-scoped. Long-lived CLI tools must not run parallel Saves on one provider instance.
mj sync pushis atomic by default: every save runs on the host provider inside the push transaction, one JSON-root graph at a time, so nothing interleaves. An entity directory that opts into isolated transactions (push.isolatedTransactions, or--isolated-transactions) runs its graphs in parallel onDatabaseProviderBase.CreateIndependentInstance(), which forks a provider that shares the connection pool and metadata cache but has its own transaction stack (SQL Server and PostgreSQL). Those saves commit as they go and are not rolled back with the push.ReleaseIndependentInstance()must not close the pool.
Who is in the transaction (scope affinity)
Section titled “Who is in the transaction (scope affinity)”A transaction belongs to the async scope that began it — the code that awaited
BeginTransaction() / BeginEntityTransaction() / RunInEntityTransaction() and everything that
code starts (awaited calls, Promise.all fan-out, timers and fire-and-forget work launched from
inside it). Only that scope’s statements run on the transaction. Any other caller on the same
provider instance — another request using the global provider, a background timer that started
earlier — runs its statements on the pool: they are never rolled back with the transaction, never
queued behind its COMMIT, and never blocked by it being doomed (#4786). That caller is not wholly
unaffected — a BeginTransaction() it issues joins the open transaction, or throws
DoomedTransactionError while that transaction is doomed, and the edge cases below can pull code
into a transaction it did not begin.
Consequences:
- Begin where you write. The scope is set when the begin is called. Call it from the function
that performs the writes (or use
RunInEntityTransaction, which does). A helper thatawaits something and then begins does not make its caller part of the transaction:// ❌ the caller's writes after `await open()` are NOT in the transactionasync function open() { await loadConfig(); return provider.BeginEntityTransaction(); } - Work that outlives the transaction runs on the pool. Something launched inside the scope that
runs after the commit/rollback (a timer, an un-awaited promise) is no longer part of any
transaction — it autocommits, as before, and never joins a later transaction someone else opens.
Use
RunAfterCommit()for work that must wait for the commit. RunAfterCommit()from outside the scope runs now, detached, on the pool. A task registered by a caller that is not in the open transaction (or with a token whose transaction already committed) is not tied to that transaction: it starts immediately, fire-and-forget, outside every transaction on the instance, so it is never rolled back with — nor awaited by — someone else’s unit of work. If such a task begins its own transaction while another is open on the instance, it joins that one as a savepoint (logged, see the next bullet); a task that needs a transaction of its own belongs onCreateIndependentInstance().- An unrelated scope that begins while another transaction is open still joins it as a
savepoint (the behaviour before #4786, kept because waiting could deadlock), and the provider logs
it once per transaction. The joiner stays in that transaction after its own savepoint is released:
its later statements keep running on it — and roll back with it — until the owner ends it.
Independent units of work belong on
CreateIndependentInstance()or a per-request provider. - A begin that runs synchronously inside un-awaited work or an event listener makes the code that
launched it part of that transaction. Async context flows down into a call and back up out of
its synchronous prefix, so
void saveAll()whose first step begins a transaction, or anEventEmitterlistener that begins one, makes the launcher (the emitter) a member. On Node builds withoutAsyncContextFrame(Node < 24, or--no-async-context-frame), a begin inside asetIntervalcallback also carries over to that interval’s later ticks. Await the unit of work, or run independent units onCreateIndependentInstance()/ a per-request provider. ExecuteSQLOptions.ignoreAmbientTransactionstill lets code inside the scope run a read on the pool (#4514).
Client-side
Section titled “Client-side”GraphQLDataProvider reports SupportsEntityTransactions === false and has no
BeginEntityTransaction. There is no local transaction to open, and there is no way to hold a
server transaction open across round trips. If you need atomicity from the browser, do not
orchestrate — route the unit of work to the server (see §3, or write a BaseRemotableOperation).
2. Transaction Groups
Section titled “2. Transaction Groups”Use when: you want to ship several unrelated record writes to the server in one round trip and have them land atomically.
const tg = await md.CreateTransactionGroup();recordA.TransactionGroup = tg;recordB.TransactionGroup = tg;await recordA.Save(); // does NOT persist yetawait recordB.Save(); // does NOT persist yetconst ok = await tg.Submit(); // one round trip, one server-side SQL transactionA transaction group is an arbitrary batch facility. Its value is network efficiency plus
atomicity across records that have nothing structurally to do with one another. TransactionVariable
adds simple value forwarding between items (Define a field on one, Use it on another), which
covers straightforward FK threading.
Why a transaction group is NOT a composite-save engine
Section titled “Why a transaction group is NOT a composite-save engine”This is the trap. Under a transaction group Save() defers — the provider registers an
instruction and the entity returns true immediately, finalising later off
TransactionNotifications$. Four consequences make it structurally unsuitable for a parent/children
save:
- No primary key after the parent’s save.
line.OrderID = header.IDreads blank.TransactionVariablepapers over the simple case, but only forwards a field value into a field — not into arbitrary code that needs the value. - No read-your-writes. Anything that reads back what it just wrote (recompute totals from persisted lines, book from the saved rows) sees nothing, because nothing has been written.
Save()returnstruebefore persistence. Everyif (!saved) throwin your cascade becomes a lie, and errors surface asynchronously via a subscription instead of at the call site.- No dependency graph. Ordering is array position with a flat
Define/Usenamespace. Two levels of nesting (payment → line → allocation) cannot be expressed.
Use a transaction group for what it is good at. Reach for an entity graph for parent/children.
3. Entity graphs (composites)
Section titled “3. Entity graphs (composites)”Use when: a record and its children should load, validate and persist as one unit — order and its lines, journal entry and its lines, payment and its allocations.
Declare the collection on a shared (client + server) subclass:
@RegisterClass(BaseEntity, 'MJ_BizApps_Accounting: Journal Entries')export class JournalEntryEntity extends mjBizAppsAccountingJournalEntryEntity { public readonly Lines = this.DeclareRelatedRecords<JournalEntryLineEntity>({ Name: 'Lines', RelatedEntity: 'MJ_BizApps_Accounting: Journal Entry Lines', RelatedEntityJoinField: 'JournalEntryID', OrderBy: 'LineNumber ASC', Load: 'explicit', // 'explicit' | 'immediate' | 'lazy' | 'never' OnRemove: 'delete', // 'delete' | 'orphan' | 'refuse' Sequence: { Field: 'LineNumber', From: 1 }, });
public override Validate(): ValidationResult { const result = super.Validate(); // fans out to companions assertBalanced(this.Lines.Items, result); // sees the WHOLE graph, before any write return result; }}When the FK lives on this record (Deal.OrderID) the join inverts and so does the save
order: the peer persists first, the owner stamps the FK, then the owner persists. That is an
embedded record, not a collection. Declare it on the FK field
(EntityField.EmbeddedRecord) and CodeGen emits deal.OrderID_Object /
deal.OrderID_EnsureObject(). Same graph executor, same MJ.SaveEntityGraph wire path,
recursive companion payload so the order’s lines ride along. See
Embedded Records.
const deal = await md.GetEntityObject<DealEntity>('Deals');// OrderID is nullable — Ensure() provisions the peer (required FKs exist after NewRecord).const order = deal.OrderID_EnsureObject();order.OrderDate = new Date('2002-01-01');await deal.Save(); // Order first, stamp Deal.OrderID, save DealThen use it. The API is the same on both tiers:
const je = await md.GetEntityObject<JournalEntryEntity>('MJ_BizApps_Accounting: Journal Entries');je.NewRecord();(await je.Lines.Create()).DebitAmount = 100;(await je.Lines.Create()).CreditAmount = 100;await je.Save(); // header + both lines, atomicallyWhere it executes
Section titled “Where it executes”BaseEntity.Save() builds an EntitySavePlan. If it has more than one node:
- Provider supports entity transactions (server) → execute locally in one transaction.
- It does not (browser) → serialise the graph and hand the whole unit of work to the server via
the
MJ.SaveEntityGraphremote operation, which rebuilds the records as their server-side registered subclasses and runs the same local executor there.
There is exactly one cascade implementation. The remote path relocates it; it never reimplements it.
Platform guarantees
Section titled “Platform guarantees”Every node is persisted by calling that record’s own Save() / Delete() — never direct SQL. So
Record Changes, entity actions, field validation, subclass Save overrides, PreSave data hooks,
save_started / save / delete events and cache invalidation all fire per node, exactly as for a
standalone save.
The root additionally raises graph_save_started and graph_save, so a UI can refresh once per unit
of work rather than once per line.
If a node fails, the graph rolls back, and every record in it (IS-A parents included) goes back in
memory to how it was before Save(), so the same call can be retried. A delete works the same way,
whether it’s a graph or an IS-A chain: each record it deletes is reset with NewRecord() only once
the unit of work commits, so a rollback leaves every record saved, under the same key.
Loading children
Section titled “Loading children”| Mode | Behaviour |
|---|---|
'explicit' (default) | Nothing loads until await entity.Lines.Load() |
'immediate' | Populated by Load() — never by LoadFromData() |
'lazy' | Populated on first read of Items — requires Source: 'cache' and read-only, and throws on a cache miss rather than returning an empty array |
'never' | Write-only staging buffer |
For result sets, use one batched query rather than eager loading per row:
const result = await rv.RunView<JournalEntryEntity>({ EntityName: 'MJ_BizApps_Accounting: Journal Entries', ExtraFilter: `PeriodID = '${periodId}'`, ResultType: 'entity_object', IncludeRelatedRecords: ['Lines'], // 1 query for ALL entries' lines});Why
immediateexcludesLoadFromData().LoadFromData()is the per-row materialisation path forRunView(ResultType:'entity_object'). Loading children there turns one view into N+1 queries. This is a real defect that shipped: aLoadFromDataoverride callingLoadLines()meant listing 500 journal entries issued 500 line queries plus 500 dimension queries.IncludeRelatedRecordscosts1 + Kregardless of row count.
Declaring in metadata instead of code
Section titled “Declaring in metadata instead of code”The option shape deliberately mirrors EntityRelationship, so the same declaration can be driven
from metadata rather than hand-written:
| Option | Metadata source |
|---|---|
RelatedEntity | EntityRelationship.RelatedEntity (column) |
RelatedEntityJoinField | EntityRelationship.RelatedEntityJoinField (column) |
Name, Load, OnRemove, OrderBy, Sequence, ClearAfterSave | EntityRelationship.RelatedRecordCollection (JSONType IRelatedRecordCollectionConfig) |
The two column-backed values are not duplicated inside the JSON — one source of truth each.
NULL means “this relationship is not a declared collection”, which is the default for every
existing row.
The column and its type definition ship in 6.2; CodeGen emission of DeclareRelatedRecords(...)
from these rows is a follow-up, so hand-written declarations remain the only working path for now.
When adding an option, keep RelatedRecordCollectionOptions and
metadata/entities/JSONType-interfaces/IRelatedRecordCollectionConfig.ts in step.
Decision tree
Section titled “Decision tree”Do the records form a parent + its children?├── YES → Entity graph. Declare a RelatedRecordCollection; call entity.Save().└── NO ├── Are you on the server, orchestrating writes yourself? │ └── YES → Provider transaction: RunInEntityTransaction(provider, work) └── Are you on the client, batching unrelated writes into one round trip? └── YES → Transaction group: CreateTransactionGroup() + Submit() └── ...but if you need read-your-writes or the parent's new PK, you need a server-side unit of work instead — write a BaseRemotableOperation and call it from both tiers.Anti-patterns
Section titled “Anti-patterns”❌ Using a transaction group to save a parent and its children.
The parent’s PK is unavailable, nothing can be read back, and Save() lies about success. Use an
entity graph.
❌ Casting ProviderToUse to DatabaseProviderBase in code that might run client-side.
// ❌ Server-only by construction — this is what made every composite class server-onlyconst db = this.ProviderToUse as unknown as DatabaseProviderBase;await db.BeginTransaction();
// ✅ Capability-checked, works on both tiersawait RunInEntityTransaction(this.ProviderToUse, async () => { /* ... */ });❌ Declaring a child collection only on the server subclass.
The browser then cannot see it, stage children, or validate them — which is exactly the limitation
this feature removes. Declare on a shared subclass; put server-only behaviour in a class that
extends it. ClassFactory priority auto-increments by load order, so the server subclass wins
server-side with no configuration.
❌ Calling BeginISATransaction().
Deprecated since 6.2. It opens a second physical transaction blind to any already in flight. Use
BeginEntityTransaction().
❌ Setting Load: 'immediate' on a collection whose parent is commonly listed in grids.
Use 'explicit' plus IncludeRelatedRecords on the specific views that need children.
❌ Inventing a “header-only” save that skips companions.
The graph executor’s recursion guard is private on BaseEntity. Application
code that previously passed a public IsGraphNodeSave flag dropped every
companion, including owner-held embeds. Use SkipRelatedCollections: true —
embeds still persist, collections do not.
Related
Section titled “Related”packages/MJCore/src/generic/entityTransactionScope.ts— scope contract and the torn-write historypackages/MJCore/src/generic/entityCompanion.ts— the companion abstractionpackages/MJCore/src/generic/relatedRecordCollection.ts— the typed collectionpackages/MJCore/src/generic/entitySavePlan.ts— plan and executorpackages/MJCore/src/generic/saveEntityGraphOperation.ts— the remote operation- Remote Operations Guide — the typed RPC substrate composites use
- BaseEntity Server-Side Patterns
- Data access rules