Skip to main content

Model

Model reads and writes return Model<T> instances.

Model<T> is a mutable client-side wrapper around a document snapshot plus the ModelService<T> that loaded it.

Why Model<T> Exists​

It solves three common client problems:

  • mutate a loaded document locally without immediately sending a request
  • track which top-level fields changed (modified state is auto-reconciled against the snapshot, so reversing a change cleans the field)
  • persist the changes back to the same service through save()

Basic Usage​

const read = await userService.read('user-id-1');

if (read.success) {
read.data.name = 'new-name';
read.data.role = 'owner';

if (read.data.isDirty()) {
await read.data.save();
}
}

You can also construct a model directly when you want a client-side draft before calling the server:

import { Model } from '@web-ts-toolkit/access-router-client';

const draft = new Model(
{
name: 'draft-user',
role: 'author',
public: true,
},
userService,
);

await draft.save();

Property Access​

Ordinary nonreserved top-level assignment writes document data even if the key was absent or omitted from a projection. It now tracks/persists like set or assign, rather than creating the former untracked wrapper shadow. Direct reads, helper-after-direct writes, serialization, save and reset agree.

read.data.name;
read.data.role = 'owner';

For paths and collision-safe access, use get(...) and set(...).

Both new Model and Model.create preserve instanceof Model and constructor identity. For typed direct fields use Model.create or service responses (Model & ModelData); the constructor remains typed as Model. A statically selected projection still exposes only its selected fields; use set for an omitted field.

read.data.get('statusHistory.0.label');
read.data.set('statusHistory.0.label', 'approved');

Dirty Tracking​

Model<T> tracks modified top-level paths and reconciles every write against the loaded or last successfully saved snapshot.

The snippets in this section all assume a successful read — i.e. they live inside the if (read.success) { ... } narrowing shown in Basic Usage so read.data is the non-null Model<T> & ModelData<T>. The narrower guard is omitted from each one-liner for brevity.

Available helpers:

  • isDirty()
  • isDirty(path)
  • markModified(path)
  • assign(partial)
  • set(path, value)

Behavior notes:

  • unchanged writes do not mark a field dirty
  • nested path tracking is normalized to the top-level field
  • direct mutation of deeply nested objects is not automatically tracked unless it passes through set(...) or markModified(...) (see the Nested-edit contract below)
  • reverting a top-level field to its snapshot baseline clears the dirty flag, so A -> B -> A (or set('x', snap) -> set('x', diff) -> set('x', snap)) leaves the field clean

Revert-clean semantics​

When you call set(...), assign(...), or assign a top-level field via the property setter, the model compares the new value to the snapshot using deep equality. If they match, the path is removed from the dirty set. This applies whether the write produced a different value or simply re-wrote the same value:

const user = await userService.read('user-id-1');
const baseline = user.data.role;

user.data.role = 'maintainer';
user.data.isDirty('role'); // true

user.data.role = baseline;
user.data.isDirty('role'); // false — reverted to the snapshot

The same rule applies to nested writes through set('path.to.field', value) because nested writes normalize to their top-level field. Dot, bracket-index (items[0].label), and quoted-key (a['b-c']) forms share one parsed path and the same dirty root across set, markModified, and isDirty. Reverting a nested value back to its baseline cleans the entire top-level field.

const original = user.data.statusHistory[0].label;
user.data.set('statusHistory.0.label', 'pending');
user.data.isDirty('statusHistory'); // true

user.data.set('statusHistory.0.label', original);
user.data.isDirty('statusHistory'); // false — deep-equals the snapshot

markModified(...) is the explicit escape hatch: it forces a path into the dirty set even when the effective value still equals the snapshot, so you can re-send a field to the server (for example, to retrigger server-side defaults or to re-apply a value that another client may have reverted). markModified(...) is never auto-reconciled.

This top-level normalization is intentional. The client ultimately sends modified top-level fields back to the server, not path-by-path Mongo update operators.

Nested-edit contract​

Direct mutation of nested objects and arrays is not tracked by the model:

user.data.statusHistory[0].label = 'approved';

user.data.isDirty('statusHistory');
// false — direct nested writes bypass the dirty tracker

user.data.statusHistory.push({ label: 'extra', flag: 'red' });
user.data.isDirty('statusHistory');
// false — array methods (push, splice, etc.) bypass the dirty tracker too

This behavior is intentional, not a silent unsupported feature. The model deliberately avoids a recursive Proxy over nested values because recursive proxies introduce unstable identity, break common equality checks, and obscure which top-level fields will be persisted.

To track a nested edit, use one of:

  • set('path.to.field', value) — applies the write and reconciles the top-level field against the snapshot
  • markModified('topLevelField') after a direct mutation — flags the top-level field dirty without reconciling, so the next save() includes it
user.data.set('statusHistory.0.label', 'approved');
user.data.isDirty('statusHistory'); // true

// or, equivalently, mutate directly and opt in to tracking:
user.data.statusHistory[0].label = 'approved';
user.data.markModified('statusHistory');
user.data.isDirty('statusHistory'); // true

If you forget to opt in, save() will not include the field and the server will not see the change — the change is "lost" on save, not silently applied. Tests assert this so the contract cannot silently regress.

Example:

read.data.statusHistory[0].label = 'approved';

read.data.isDirty('statusHistory');
// false

read.data.set('statusHistory.0.label', 'approved');

read.data.isDirty('statusHistory');
// true

save()​

save() persists only the tracked modified top-level fields.

Behavior:

  • if _id exists, save() calls service.update(...)
  • an ID-based read retains its persistence identity even when the projection excludes _id, so save() still updates the original route
  • if _id and captured persistence identity are both missing on a draft, save() calls service.create(...)
  • if the wrapper represents an existing document but has no recoverable identity, save() throws MissingPersistenceIdentityError before network activity instead of silently creating a duplicate
  • on success, the model snapshot is replaced with the latest persisted state and the dirty set is cleared, except for top-level fields you concurrently re-edited while the request was in flight (those fields retain their newer local value and stay dirty so the next save() re-sends them)
  • on failure, the model remains dirty so you can correct and retry

save() returns the normalized service response shape, not just the raw document. That means you can inspect success, status, message, raw, and data just like other client calls.

Import MissingPersistenceIdentityError from the package root when a projected filter/list read may intentionally omit _id and you need to handle that case.

Example:

const draft = await userService.new();

if (draft.success) {
draft.data.assign({
name: 'draft-user',
role: 'author',
public: true,
});

const saved = await draft.data.save();
void saved;
}

reset()​

reset() restores the last loaded or successfully saved snapshot.

const user = await userService.read('user-id-1');

if (user.success) {
user.data.role = 'owner';
user.data.reset();
}

After reset():

  • current mutable data is restored from the snapshot
  • deleted keys are restored if they existed in the snapshot
  • extra keys added after load are removed
  • dirty tracking is cleared

For an added field, reset restores absence in toObject() and undefined through both direct reads and get, replacing the former null from a removed field's forwarder. Forwarder slots may remain enumerable: serialize document data with toObject/toJSON, not wrapper enumeration. Reverting an absent field to undefined reconciles clean. Reset is local, and JSON still omits undefined values; this adds no server unset/delete protocol. Unsaved draft reset restores the draft baseline and keeps its initial fields dirty for creation.

assign(...), toObject(), and toJSON()​

assign(...) mutates the live model in place:

user.data.assign({ role: 'admin', public: true });

toObject() and toJSON() return deep-cloned plain data.

That is useful when:

  • you need to serialize safely
  • you want to compare snapshots
  • you do not want accidental mutation to change the live model state

toJSON() makes JSON.stringify(model) behave like JSON.stringify(model.toObject()).

Field Name Collisions​

Some document keys can collide with model methods such as save, set, or reset.

The model avoids defining direct properties for keys that already exist on the instance or its prototype.

That means this is safe:

const doc = await weirdService.read('1');

typeof doc.data.save;
// 'function'

doc.data.get('save');
doc.data.set('save', 'field-value');

Wrapper methods, internal state names (including _snapshot and _saveQueue) and inherited prototype members are reserved on direct access. Direct writes throw TypeError before mutation; document values remain available via get, set, assign, or toObject subject to the key/path rules below. then is explicitly helper-only: direct reads are undefined even for callable document data, and direct writes throw. ModelData excludes public Model members, then, and inherited Object-member names. Model methods are owner-bound and fluent calls return the same wrapper.

Safe-write restrictions and migration​

Direct and assign keys must be nonempty literal strings: no ., [ or ], no __proto__, constructor, or prototype, and no numeric spelling changed by the path normalizer (such as 01). Direct symbol writes and enumerable symbol keys in assign throw TypeError. assign validates every key before applying values, so invalid later keys cannot partially apply earlier fields. This is key validation, not transactional execution of arbitrary getters or proxies. Use set/markModified for nested dot/bracket paths; their existing forbidden-segment rules still apply.

Replace wrapper defineProperty, delete, setPrototypeOf, preventExtensions, freeze/seal, and legacy getter/setter definition helpers with supported data-edit helpers. Those structural operations now throw TypeError before changing wrapper structure, document data or dirty state.

Overlapping Saves​

Multiple save() calls on the same model instance are serialized in call order. A queued save snapshots dirty paths only after the previous save has finished reconciling, so overlapping callers do not submit the same stale dirty set concurrently.

Edits made while a save is in flight remain local and dirty when they were not submitted by that save, or when they changed the same path to a newer value. The next queued or manual save() sends those remaining dirty paths.

This also applies to newly added fields. Reset during an in-flight save retains the persisted baseline once that save succeeds, even if the local key was removed. Unsubmitted concurrent fields retain their prior absent baseline. The original and returned save models have detached snapshots/dirty sets and independent save queues.

Practical Guidance​

Use direct property syntax for simple top-level fields.

Use set(...) when:

  • the path is nested
  • the field name collides with a method
  • you want dirty tracking to reflect the change immediately and reconcile against the snapshot

Use markModified(...) when you mutate nested data outside set(...) and still want save() to include the top-level field. markModified(...) also serves as the explicit opt-in to send a top-level field even when its effective value matches the snapshot.

Recommended editing pattern:

  1. read the model
  2. use direct property writes for simple top-level fields
  3. use set(...) for nested fields
  4. check isDirty() before saving if you want to skip no-op writes
  5. call reset() when the user cancels local edits