Change Tracking & Context Lifetime
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.
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#
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
truewith 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)):
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>()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.
// 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:
1. Request-scoped context with MasterController (recommended)#
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.
// 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:
// 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.
// 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
}
}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.
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 shutdownControlling what gets tracked#
Tracking is the default. For read-only work opt out so nothing is retained — EF’s AsNoTracking() / AsTracking() / QueryTrackingBehavior:
| API | EF Core | Effect |
|---|---|---|
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.NoTracking | Every query is no-tracking by default; opt back in with asTracking(). |
db.setQueryTrackingBehavior('track') | (default) | Queries track their results. |
// 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 writtenpluck(), 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.
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 diffattach, update, remove, dispose#
// 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 warmasNoTracking() for reads, instead.Mapping to Entity Framework Core#
| MasterRecord | EF 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 / remove | Update(e) / Add / Remove |
clearChangeTracker() | ChangeTracker.Clear() |
close() | Dispose() |
on('savingChanges' | 'savedChanges' | 'saveChangesFailed') | SavingChanges / SavedChanges / SaveChangesFailed events |