MasterRecord

Change Tracking & Context Lifetime

The context is a unit of work. Scope it per request, never share it.

MasterRecord’s change tracking follows the Unit of Work + Identity Map model of Entity Framework Core: a query tracks its results, mutating a tracked entity marks it dirty, and saveChanges() flushes the dirty set in one transaction. Everything on this page maps 1:1 to a DbContext — if you know EF Core, you already know the rules.

The one rule
A context is a unit of work. Create one per request(or per job run) and dispose it. Never export a module-level singleton context and share it across concurrent requests — EF Core’s docs say a DbContext“is not thread-safe” and “is designed to be used for a single unit-of-work”; the same is true here.

How tracking works#

unit-of-work.js
const user = await db.User.where((u) => u.id == $$, id).single(); // tracked (state 'track')
user.blocked = true;                                                   // dirty  (state 'modified')
await db.saveChanges();                                                // one UPDATE, then clean again
  • A query tracks its results by default; loaded entities enter the context’s identity map.
  • Every setter bumps a per-entity mutation version and adds the entity to the dirty index (EF’s StateManager), so a flush is O(changes), not O(total tracked); an empty save is O(1).
  • After a successful flush, written entities are reset to clean and released; deletes are detached; anything re-mutated mid-flush stays pending and ships with the next save.
  • On failure the change set is left tracked and dirty and the call rejects — a dirty entity is always written or the call throws; it never resolves true with the write missing.

Entity states & entry()#

Each tracked entity has a state, exactly like EF’s EntityState. Inspect and control it through db.entry(entity) (EF’s context.Entry(e)):

states.js
const e = db.User.new();          // state: 'insert'   — INSERT on saveChanges()
const u = await db.User.find(1);  // state: 'track'    — loaded, unchanged
u.name = 'Orko';                  // state: 'modified' — UPDATE on saveChanges()
db.User.remove(u);                // state: 'delete'   — DELETE on saveChanges()
db.detach(u);                     // state: 'detached' — no longer part of any save

// EF's context.Entry(entity)
const entry = db.entry(u);
entry.state;                 // 'track' | 'modified' | 'insert' | 'delete' | 'detached' (settable)
entry.originalValues;        // as loaded / last saved
entry.currentValues;
entry.isModified('name');    // pending change on one field (or the whole entity)
await entry.reload();        // database wins: refresh values + originals
await entry.getDatabaseValues(); // live row, entity untouched (null if gone)
entry.detach();

db.hasChanges();             // ChangeTracker.HasChanges()
db.entries('User');          // ChangeTracker.Entries<User>()
Note
Setting entry.state = 'modified'with no dirty fields marks every writable column — EF’s Update(). Computed columns and navigation objects are never written.

What saveChanges() commits#

saveChanges() builds a change set of only the dirty entities (insert / modified / delete) and commits just that, in one transaction. It never snapshots, processes, or untracks the rest of the tracked list, and it releases only the rows it actually wrote.

commit-semantics.js
// Two units of work on one connection: each save commits ONLY its own dirty set.
const a = await db.User.find(1);
a.plan = 'pro';
await db.saveChanges();      // writes a; leaves every other tracked entity alone

// An empty save is a true no-op — nothing is snapshotted, nothing is untracked.
await db.saveChanges();      // O(1)

The lost-write bugs this design closed (1.5.9 → 1.5.12)#

Earlier releases swept the wholetracked list on every save. On a shared/singleton context that meant one request’s save could untrack another request’s freshly loaded row, whose own save then found nothing to write while still resolving true (1.5.9, 1.5.10); a committed delete could stay latched and replay forever (1.5.11); and queried entities were given a random identity that, in a large long-lived tracked set, collided by the birthday paradox and was silently dropped from tracking (1.5.12). All are fixed — saves commit only their change set, identities are collision-free and sequential, and the identity map is the single source of truth. But every one of those bugs only manifested on a context shared across requests. Scoping the context is the EF model, and it removes the entire class.

Scoping a context (the memory bound)#

Like EF Core, MasterRecord holds tracked entities with strong references for the life of the context, so memory is bounded by context lifetime. Two supported patterns:

Register AppContext with master.addScoped('db', AppContext)— the equivalent of ASP.NET’s services.AddDbContext<T>(). Every controller and minimal-API handler gets a fresh instance as this.db, built lazily on first use and disposed (close()) when the response closes. This is what master new scaffolds.

server.js + usersController.js
// backend/server.js — register the context per request (ASP.NET: services.AddDbContext<AppContext>())
import master from 'mastercontroller';
import AppContext from './app/models/AppContext.js';

master.addScoped('db', AppContext);

// app/controllers/usersController.js — a fresh AppContext per request, disposed when the response closes
export default class UsersController {
  constructor(requestObject) { this.requestObject = requestObject; }

  async block() {
    const user = await this.db.User.find(this.params.id);
    user.blocked = true;
    await this.db.saveChanges();
    this.ok({ id: user.id, blocked: user.blocked });
  }
}

Outside a request — background jobs, hosted services, scripts — build a scope per run with master.useScope() (ASP.NET’s IServiceScopeFactory.CreateScope()). The scaffold ships a tiny withDb() helper for exactly this:

db.js + job.js
// app/models/db.js — database access OUTSIDE a request (jobs, scripts, hosted services)
import master from 'mastercontroller';

export function withDb(fn) {
  return master.useScope((scope) => fn(scope.db));   // scope disposed (context closed) afterwards
}

// a job
import { withDb } from './app/models/db.js';
await withDb(async (db) => {
  const expired = await db.Session.where((s) => s.expires_at < $$, Date.now()).toList();
  for (const s of expired) db.Session.remove(s);
  await db.saveChanges();
});

Without MasterController the pattern is the same: one context per unit of work, closed at the end.

handler.js
// Without MasterController: one context per unit of work, closed at the end
async function handler(req, res) {
  const db = new AppContext();          // cheap: reuses the warm pooled connection
  try {
    const user = await db.User.find(req.params.id);
    user.last_seen = new Date().toISOString();
    await db.saveChanges();
  } finally {
    await db.close();                   // Dispose(): releases the tracker + returns the connection
  }
}
new AppContext() is cheap
Constructing a context does not open a connection. Connections are pooled per database (ADO.NET-style): close() returns the connection to the pool kept open and idle, and the next context reuses it warm. Idle connections are reaped after MR_POOL_IDLE_MS (default 60000 ms; 0 closes at refcount zero).

2. Context pooling (EF’s AddDbContextPool)#

If you want to amortise setup across many contexts, a ContextPool keeps a bounded set of instances warm and lends an exclusive, reset() instance per request — each request still gets its own instance; nothing is shared.

pool.js
import ContextPool from 'masterrecord/ContextPool';   // or masterrecord.ContextPool

const pool = new ContextPool(AppContext, { maxSize: 64 });

// per request — acquire → run → reset → release, even on error
await pool.use(async (db) => {
  const user = await db.User.asTracking().where((u) => u.id == $$, id).single();
  user.blocked = true;
  await db.saveChanges();
});

await pool.drain();   // at shutdown

Controlling what gets tracked#

Tracking is the default. For read-only work opt out so nothing is retained — EF’s AsNoTracking() / AsTracking() / QueryTrackingBehavior:

APIEF CoreEffect
db.Model.asNoTracking()…AsNoTracking()This query’s results are not tracked; mutations on them are not saved.
db.Model.asTracking()…AsTracking()Track this query even when the context defaults to no-tracking.
db.setQueryTrackingBehavior('no-track')QueryTrackingBehavior.NoTrackingEvery query is no-tracking by default; opt back in with asTracking().
db.setQueryTrackingBehavior('track')(default)Queries track their results.
tracking.js
// Read-only list: retain nothing (EF AsNoTracking)
const users = await db.User.asNoTracking().toList();

// Context-wide default (EF QueryTrackingBehavior.NoTracking) + opt back in per query
db.setQueryTrackingBehavior('no-track');
const u = await db.User.asTracking().where((x) => x.id == $$, id).single();
u.name = 'Teela';
await db.saveChanges();            // tracked because of asTracking()

// Mutating a no-tracking entity does NOT enqueue a write (it is detached)
const ghost = await db.User.asNoTracking().where((x) => x.id == $$, id).single();
ghost.name = 'nope';
await db.saveChanges();            // nothing written
Note
pluck(), toObjectList(), aggregates and groupBy().aggregate() are projections — they never track anything.

DetectChanges for owned types#

Owned / complex values (db.owned(Class), EF’s OwnsOne(...).ToJson()) live in one JSON column. Nested mutations never hit a column setter, so saveChanges()compares the serialized value with the loaded one — EF’s DetectChanges on complex properties — and writes only when it differs.

owned.js
class Address { constructor() { this.street = null; this.city = null; } }

export default class Customer {
  id(db)      { db.integer().primary().auto(); }
  address(db) { db.owned(Address); }      // JSON column, hydrated into Address on read
  prefs(db)   { db.owned().nullable(); }  // plain object / array
}

const c = await db.Customer.find(1);
c.address.city = 'Paris';          // no column setter fires...
c.prefs.tags.push('vip');
await db.saveChanges();            // ...but DetectChanges compares the serialized value and writes the diff

attach, update, remove, dispose#

tracker-api.js
// Entity loaded elsewhere (another context / service) — EF's context.Update()
const task = await taskService.getTask(taskId);
task.status = 'completed';
db.attach(task);                         // attach + mark modified
await db.saveChanges();

db.attach(task, { status: 'completed' }); // only these fields are written
db.attachAll([task1, task2]);

// Update by primary key without loading first
await db.update('Task', { id: taskId }, { status: 'completed' });
await db.saveChanges();

// Context-level add/remove (EF DbContext.Add / Remove)
db.add(entity);    db.remove(entity);
db.addRange(list); db.removeRange(list);

// Stop tracking
db.detach(entity);        // Entry(e).State = Detached (drops its pending change)
db.clearChangeTracker();  // ChangeTracker.Clear() — drops ALL pending changes
db.reset();               // detach all + clear query cache, connection kept (pool return)
await db.close();         // Dispose(): tracker released, connection returned to the pool warm
clearChangeTracker() is not a per-request mitigation
It drops pending changes, so on a shared context it can destroy another in-flight request’s writes. Scope the context, or use asNoTracking() for reads, instead.

Mapping to Entity Framework Core#

MasterRecordEF Core
context = unit of work, master.addScoped('db', AppContext)DbContext registered Scoped via AddDbContext
master.useScope() / withDb()IServiceScopeFactory.CreateScope()
ContextPool / pool.use()AddDbContextPool
asNoTracking() / asTracking()AsNoTracking() / AsTracking()
setQueryTrackingBehavior('no-track')QueryTrackingBehavior.NoTracking
entry(e), hasChanges(), entries()Entry(e), ChangeTracker.HasChanges(), Entries()
find(pk)Find() — identity map first, no query if tracked
attach(e) / add / removeUpdate(e) / Add / Remove
clearChangeTracker()ChangeTracker.Clear()
close()Dispose()
on('savingChanges' | 'savedChanges' | 'saveChangesFailed')SavingChanges / SavedChanges / SaveChangesFailed events