zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Schema and ORM

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 changes

In 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 value
❗ Important

Fetched 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:

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 ORMzmdb
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 entitiesdb.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'),
  }));
}
💡 Tip

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: