Best Practices
MasterRecord is flexible, but a few conventions keep your code fast, correct, and easy to reason about.
1. Scope the context per request — never a singleton#
A context is a unit of work, exactly like an EF Core DbContext. Register it per request (master.addScoped('db', AppContext) → this.db in controllers), build a scope per job run (master.useScope() / withDb()), or new AppContext() + close() around each unit of work. A module-level context shared across concurrent requests mixes change sets, grows memory without bound and was the root of the lost-write class of bugs hardened in 1.5.9–1.5.12.
master.addScoped('db', AppContext); // ✅ fresh context per request, disposed on response close
await master.useScope((s) => s.db.User.toList()); // ✅ jobs / scripts
// export default new AppContext(); ❌ process-wide singleton shared by every requestSee Change Tracking & Context Lifetime.
2. Always await writes#
Every engine is asynchronous. Await every save() and saveChanges() so errors surface and ordering is guaranteed.
await user.save(); // ✅
await db.saveChanges(); // ✅
user.save(); // ❌ unhandled promise, lost errors3. asNoTracking() for reads#
Queries track their results by default. For listings and read-only endpoints use asNoTracking() (EF AsNoTracking()) — nothing is retained, and a read-heavy path never grows the tracked set. Project with pluck() / select() / toObjectList() when you only need columns.
const feed = await db.Post.asNoTracking().orderByDescending((p) => p.id).take(50).toList();Hot loops: when you correlate two result sets, build a one-pass Map keyed by the join column instead of a nested loop — an O(n²) scan over two tables of a few thousand rows is millions of property reads. Since 1.23.0 an entity property read costs about what a plain-object read costs (~20 ns; it was ~300 ns before, because every row carried its own prototype), but the algorithmic fix still wins by orders of magnitude.
const tenants = await db.Tenant.asNoTracking().toList();
const usage = await db.Usage.asNoTracking().toList();
const byTenant = new Map(); // one pass over usage
for (const u of usage) (byTenant.get(u.tenantId) ?? byTenant.set(u.tenantId, []).get(u.tenantId)).push(u);
const report = tenants.map((t) => ({ id: t.id, name: t.name, rows: (byTenant.get(t.id) ?? []).length }));4. One transaction per multi-step write#
saveChanges() is atomic on its own. When a unit spans several saves or raw SQL, wrap it in db.transaction(async (tx) => { … }); use executeUpdate/executeDelete for set-based writes instead of load-modify-save loops. Add rowVersion() to rows that several users edit and handle ConcurrencyError. See Transactions & Concurrency.
5. Define fields as methods#
Columns are builder methods, not constructor properties. Object-literal “fields” are silently ignored by the schema builder.
name(db) { db.string().notNullable(); } // ✅
// this.name = { type: 'string' }; ❌ ignored6. Name the context file after the class#
The migration CLI resolves contexts by file name. Keep AppContext in app/models/AppContext.js so master db can find it.
7. Use relative SQLite paths#
connection: 'db/' resolves under your project root and auto-creates the file. A leading slash (/db/) is treated as a filesystem-absolute path.
8. Parameterize — never concatenate#
Bind values with $$. The query builder parameterizes them, eliminating injection. The sql tag for executeUpdate refuses interpolations for the same reason.
db.User.where((u) => u.email == $$, email); // ✅ safe & parameterized9. Batch wisely#
Creating many rows? Save them through the same context and call saveChanges() once — MasterRecord builds an optimized batch insert that still runs your .set() transformers and relationships. Each save flushes only its dirty set, so a big tracked context is not a slow one.
10. Index your foreign keys and filters#
FK columns are indexed automatically. Add .index() to columns you filter or sort on frequently, and consider full-text indexes for searchable text.
11. Review migrations before applying#
masterrecord script AppContext prints the SQL update-database would run; watch for // POSSIBLE RENAME advisories (a rename is diffed as drop + add, as in EF) and swap in renameColumn before applying. Use update-database-all in deploy scripts — it exits non-zero when any context fails.
.new() create · .save() persist one · saveChanges() persist all · .where().toList() read many · .single()/.find() read one · .asNoTracking() read without tracking · .remove()/.delete() remove · db.transaction() span saves · close() dispose.