Why Fetched Rows Are InertSupported
Fetched rows in zmdb are plain objects with no change tracking, no proxies, and no identity map. Mutating them has zero effect on the database. Reads, writes, and relation loading happen through explicit repository calls.
The Mutation Fallacy#
If you're coming from MikroORM, TypeORM, or similar, you may be used to this pattern:
// MikroORM-style
const user = await em.findOne(User, 1);
user.email = 'new@example.com';
await em.flush(); // persist changesIn zmdb, this doesn't work:
const user = await users.findById(1);
user.email = 'new@example.com'; // ❌ Does NOT persist
// The database still has the old email
const check = await users.findById(1);
console.log(check.email); // original valueFetched rows are inert. The only way to persist changes is through explicit create, update, or delete methods on the repository.
Why Inert?#
zmdb deliberately excludes:
- Proxies — no
Proxywrapping fetched rows - Dirty checking — no comparison of original vs current state
- Identity map — no shared references across queries
- Unit of work — no implicit flush
This avoids proxy dispatch and change-tracking scans. Repositories still build queries and assemble results, including relation batches and populated copies. Those operations have runtime cost.
The Correct Pattern#
Translate your "load-mutate-flush" workflow into explicit updates:
| Traditional ORM | zmdb |
|---|---|
em.findOne(User, 1) | await users.findById(1) |
user.email = 'x' | const patch = { email: 'x' } |
await em.flush() | await users.update(1, patch) |
| Multiple changes across entities | db.transaction(async tx => { ... }) |
// Find
const user = await users.findById(1);
// Prepare patch
const patch = { email: 'new@example.com', role: 'admin' };
// Persist explicitly
await users.update(1, patch);Explicit population#
You can load relations after fetching a row without making that row a live object:
const row = await users.findById(1);
if (row !== undefined) {
const populated = await users.populate(row, ['posts.comments']);
// row stays unchanged; populated carries the requested relations.
}populate() also accepts readonly arrays of existing rows. It returns new populated copies and fetches only the requested relations, without reloading roots. Reading a property never triggers SQL. See loading strategies for target-schema registration and request-scoped batching for concurrent calls.
Post-Select Hook#
Use postSelect to enrich or filter rows on the way out:
protected postSelect(rows: readonly Record<string, unknown>[]): readonly Record<string, unknown>[] {
return rows.map(r => ({
...r,
// Add computed field
isNew: r.createdAt instanceof Date && r.createdAt > new Date('2024-01-01'),
}));
}postSelect is the escape hatch for row enrichment. Use it for computed fields, masking, or adding metadata — but it doesn't enable auto-persisting.
Performance Impact#
The inert row design avoids automatic entity bookkeeping:
- No proxy dispatch — property access reads ordinary objects
- No change tracking — no array of dirty entities to scan
- No identity map — repository reads do not consult a shared entity registry
- Explicit loading — requested relation paths and dialect batch limits determine the relation queries
Cross-links#
- CRUD — explicit create/update/delete
- Repository — full repository API
- Transactions — grouping multiple writes