npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@katn30/trakr

v5.1.0

Published

TypeScript state-management library implementing the Unit of Work pattern, with object tracking for undo/redo and validation support.

Readme

trakr

TypeScript state-management library implementing the Unit of Work pattern, with object tracking and undo/redo.

Built on the TC39 decorator standard (Stage 3). Requires TypeScript 5+ with experimentalDecorators not set.

Installation

npm install @katn30/trakr
// tsconfig.json — no experimentalDecorators needed
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"]
  }
}

Quick Start

import {
  Tracker,
  TrackedObject,
  TrackedCollection,
  Tracked,
  AutoId,
} from '@katn30/trakr';

const tracker = new Tracker();

class InvoiceModel extends TrackedObject {
  @AutoId
  id: number = 0;

  @Tracked()
  accessor status: string = '';

  @Tracked((self, value) => value < 0 ? 'Total must be positive' : undefined)
  accessor total: number = 0;

  readonly lines: TrackedCollection<string>;

  constructor(tracker: Tracker) {
    super(tracker);
    this.lines = new TrackedCollection(tracker);
  }
}

const invoices = new TrackedCollection<InvoiceModel>(tracker);
const invoice = tracker.construct(() => new InvoiceModel(tracker));
invoices.push(invoice);        // trakrState: Insert, trakrId: 1

invoice.status = 'draft';     // recorded
invoice.total = 100;          // recorded
invoice.lines.push('item-1'); // recorded

tracker.isDirty;   // true
tracker.canUndo;   // true

tracker.undo();    // reverts lines.push
tracker.undo();    // reverts total
tracker.undo();    // reverts status
tracker.undo();    // reverts push — state back to Unchanged

tracker.isDirty;   // false

Concepts

Undo/redo strategy

The two common patterns for implementing undo/redo are:

  • Command — every change stores a redoAction and an undoAction closure pair. Undoing calls the inverse function; redoing calls the original. No state is copied.
  • Memento — the entire state (or a relevant slice) is snapshotted before each change and restored on undo. Simpler to implement because no inverse logic is required, but carries memory and copying overhead on every change.

trakr uses the Command pattern because, once correctly implemented, it is strictly more efficient: no memory overhead, no copying, and undo granularity is exactly as fine or coarse as designed.

How undo steps are created

Every tracked write — a @Tracked() property assignment or a TrackedCollection mutation — becomes its own undo step unless it is automatically composed into an existing one (see Automatic composing below).

invoice.status = 'void'          → undo step A
invoice.lines.clear()            → undo step B   (independent)

Automatic composing

Multiple tracked writes can automatically land in the same undo step in three cases. No extra API is needed.

Case 1a — @Tracked setter body

When a property's setter is decorated with @Tracked, the setter body runs as part of the tracked write. Any @Tracked property writes or TrackedCollection mutations made synchronously inside that setter body are automatically composed into the same undo step.

class NameModel extends TrackedObject {
  private _firstName: string = '';
  private _lastName: string = '';

  get firstName(): string { return this._firstName; }
  @Tracked() set firstName(value: string) { this._firstName = value; }

  get lastName(): string { return this._lastName; }
  @Tracked() set lastName(value: string) { this._lastName = value; }

  get fullName(): string { return `${this._firstName} ${this._lastName}`.trim(); }
  @Tracked() set fullName(value: string) {
    const [first = '', last = ''] = value.split(' ');
    this.firstName = first;  // composed — same undo step as fullName
    this.lastName  = last;   // composed — same undo step as fullName
  }
}

model.fullName = 'John Doe';

tracker.undo(); // reverts firstName AND lastName together — one step

The same applies when the setter mutates a TrackedCollection:

class TagModel extends TrackedObject {
  private _tag: string = '';
  readonly tags: TrackedCollection<string>;

  constructor(tracker: Tracker) {
    super(tracker);
    this.tags = new TrackedCollection(tracker);
  }

  get tag(): string { return this._tag; }
  @Tracked() set tag(value: string) {
    this._tag = value;
    if (value) this.tags.push(value); // composed — same undo step as tag
  }
}

model.tag = 'active';

tracker.undo(); // reverts tag AND removes 'active' from tags — one step

Case 1b — @Tracked with onChange callback

When side-effect logic needs to be kept separate from the setter body — or when using accessor fields where there is no setter body — pass an onChange callback as the second argument to @Tracked(). It receives (self, newValue, oldValue) and runs inside the tracked operation, so any @Tracked property writes or TrackedCollection mutations made inside it are automatically composed into the same undo step. The callback does not fire during undo or redo — the stored actions handle replay.

class TagModel extends TrackedObject {
  readonly tags: TrackedCollection<string>;

  @Tracked(
    undefined,
    (self: TagModel, newValue, oldValue) => {
      if (oldValue) self.tags.remove(oldValue);
      if (newValue) self.tags.push(newValue);  // composed — same undo step as tag
    },
  )
  accessor tag: string = '';

  constructor(tracker: Tracker) {
    super(tracker);
    this.tags = new TrackedCollection(tracker);
  }
}

model.tag = 'active';

tracker.undo(); // reverts tag AND removes 'active' from tags — one step

Case 2 — TrackedCollection event callbacks

When a TrackedCollection is mutated, both its changed and trackedChanged events fire synchronously inside the tracked operation. Any @Tracked property write made inside either subscriber is automatically composed into the same undo step as the collection mutation.

Use changed when you also want the callback to run during undo and redo. Use trackedChanged when you only want the callback to run on direct user mutations — it will not fire during undo or redo, and writes inside it are still composed on the initial write.

// Using changed — fires on initial write, undo, and redo
class OrderModel extends TrackedObject {
  @Tracked() accessor itemCount: number = 0;

  readonly items: TrackedCollection<string>;

  constructor(tracker: Tracker) {
    super(tracker);
    this.items = new TrackedCollection(tracker);
    this.items.changed.subscribe(() => {
      this.itemCount = this.items.length; // composed into the same undo step
    });
  }
}

order.items.push('x');  // itemCount becomes 1
tracker.undo();         // items back to [], itemCount back to 0

// Using trackedChanged — fires only on initial write, writes still composed
class LoggedCollection extends TrackedObject {
  @Tracked() accessor lastAdded: string = '';

  readonly items: TrackedCollection<string>;

  constructor(tracker: Tracker) {
    super(tracker);
    this.items = new TrackedCollection(tracker);
    this.items.trackedChanged.subscribe((e) => {
      if (e.added.length > 0) this.lastAdded = e.added[e.added.length - 1]; // composed
    });
  }
}

The same applies to TrackedObject.trackedChanged. A subscriber that writes to another @Tracked property is composed into the same undo step:

class TitleModel extends TrackedObject {
  @Tracked() accessor summary: string = '';

  private _title: string = '';
  get title(): string { return this._title; }
  @Tracked() set title(value: string) { this._title = value; }

  constructor(tracker: Tracker) {
    super(tracker);
    this.trackedChanged.subscribe(({ property, newValue }) => {
      if (property === 'title') {
        this.summary = `Summary: ${newValue}`; // composed into the same undo step
      }
    });
  }
}

model.title = 'Hello';
tracker.undo(); // reverts title AND summary together

Coalescing consecutive writes

Rapid consecutive writes to the same string or number property on the same model can be merged into a single undo step. Coalescing is opt-in per property via the coalesceWithin option on @Tracked(). Pass the maximum gap in milliseconds between two writes that should still be considered part of the same edit:

@Tracked(undefined, undefined, { coalesceWithin: 3000 })
accessor status: string = '';
invoice.status = 'd';
invoice.status = 'dr';
invoice.status = 'dra';
invoice.status = 'draft';

tracker.undo(); // reverts all four at once → status = ''

Properties without coalesceWithin — and all Date, boolean, and object properties — are never coalesced; every write produces its own undo step.

Sessions

startSession() returns a TrackerSession that groups all writes made during the session into a single undo step. Call session.end() to commit or session.rollback() to revert:

const session = tracker.startSession();

model.firstName = 'Alice';
model.lastName  = 'Smith';
model.email     = '[email protected]';

session.end(); // all three writes become one undo step

tracker.undo(); // reverts firstName, lastName, and email together
const session = tracker.startSession();

model.firstName = 'Alice';
model.lastName  = 'Smith';

session.rollback(); // all writes since startSession are reverted

A second call to startSession() while a session is already active is a no-op — nesting is not supported.

Edit modal with save button

The canonical use case is a modal that edits a slice of the model. Pass a property scope — a list of [object, propertyNames] tuples — and the session exposes isDirty and isValid bounded to those properties, so a save button can be driven correctly regardless of the state of the rest of the application.

import { PropertyScope } from 'trakr';

function openEditModal(model: PersonModel) {
  const session = tracker.startSession([
    [model, ['firstName', 'lastName', 'email']],
  ]);

  showModal({
    model,
    onConfirm: () => session.end(),
    onCancel:  () => session.rollback(),
    canSave:   () => session.canCommit,
  });
}

isDirty is false when the session starts (even if other objects are already dirty elsewhere), and becomes true the moment the user writes to any property listed in the scope. Defaults to false when no scope is provided.

isValid checks validationMessages for every declared property. If any has a validation error — including one that existed before the session started — isValid is false, keeping the save button disabled until the user resolves it. Defaults to true when no scope is provided.

canCommit is true when isDirty && isValid — ready to enable a save button.

Multiple objects in scope

Pass one tuple per object:

const session = tracker.startSession([
  [person,  ['firstName', 'email']],
  [address, ['street', 'city']],
]);

Properties not listed — and all other tracked objects — are ignored by isDirty and isValid. The scope has no effect on what gets committed or rolled back: session.end() always merges everything written since startSession() into one undo step, and session.rollback() always reverts it all.

Dependency tracking

Validators can read other properties of the same model — for example, a scheduleDays field might be required only when isEnabled is true. trakr automatically tracks which properties each validator reads, and re-runs only the affected validators when those properties change.

This works via a lightweight dependency tracking mechanism built into the @Tracked getter. Every time a validator runs, trakr collects every @Tracked property that is read during the call. These are recorded as dependencies. When any of those properties is written next, only the validators that declared a dependency on it are re-evaluated — not the entire model.

Consequence for get/set pairs: The dependency is registered through the getter, not the setter. If a property is written via a plain setter and its getter is not decorated with @Tracked, any validator that reads it will not discover the dependency — and will not re-run when the property changes.

// WRONG — isEnabled getter is plain; validators that read self.isEnabled
// will not re-run when isEnabled changes
get isEnabled(): boolean { return this._isEnabled; }

@Tracked()
set isEnabled(value: boolean) { this._isEnabled = value; }
// CORRECT — both getter and setter are decorated
@Tracked()
get isEnabled(): boolean { return this._isEnabled; }

@Tracked()
set isEnabled(value: boolean) { this._isEnabled = value; }

When using accessor fields this is never an issue — the getter and setter share the same decoration.

Validation

Validators are inline functions passed as the first argument to @Tracked(). They receive the model instance and the incoming value and return an error string on failure or undefined on success.

trakr runs validators automatically — you never call them directly. They run:

  • After every tracked write to the decorated property
  • After every undo and redo
  • Once for every property on every model after tracker.construct() or tracker.new() completes
  • When any @Tracked property read during the previous validator call is written on any object (cross-object dependency re-evaluation)

Results are stored per-property in model.validationMessages: Map<string, string> and aggregated into:

  • model.trakrIsValid: booleantrue when all validators on this model pass
  • tracker.isValid: booleantrue when every model and collection passes
  • tracker.canCommit: booleantrue when isDirty && isValid

tracker.isValidChanged and tracker.canCommitChanged fire whenever these values change, so UI can bind directly to them without polling.

Collection validators are a separate function passed as the third argument to the TrackedCollection constructor. They receive the full array and return an error string or undefined. The result is exposed on collection.error and collection.trakrIsValid, and rolls up into tracker.isValid. Collection validators participate in the same cross-object dependency tracking as scalar validators — if the validator reads a @Tracked property on another object, the validator re-runs automatically when that property changes.

const items = new TrackedCollection<string>(
  tracker,
  [],
  (list) => list.length === 0 ? 'At least one item is required' : undefined,
);

items.trakrIsValid; // false — empty
items.push('a');
items.trakrIsValid; // true

Object construction

Tracked objects must always be created with one of two construction methods. Using bare new MyModel(tracker) outside either method throws in development mode.

tracker.construct() — loading existing objects

Suppresses tracking for the entire constructor body — property writes during construction are silently applied without creating undo entries. The tracker is clean (canUndo === false) immediately after it returns.

// Single object — returns the constructed instance
const invoice = tracker.construct(() => new InvoiceModel(tracker));

// Multiple objects — pass them all in one callback
tracker.construct(() => {
  for (const row of serverRows) {
    const item = new ItemModel(tracker, row);
  }
});
// tracker.revalidate() is called once here — not once per object

tracker.new() — creating new objects

Defaults set in the constructor fire changed events and appear in EventTracker.generateEvents() on the next save. Constructor writes are not added to the undo stack, so the tracker is clean immediately after it returns.

const invoice = tracker.new(() => new InvoiceModel(tracker));
// tracker.isDirty === false
// defaults appear in generateEvents()

Both methods validate every constructed object once and run tracker.revalidate() exactly once at the end. The same constructor can serve both roles by branching on whether saved data was provided:

class InvoiceModel extends TrackedObject {
  @EventTracked(undefined, undefined, { eventType: 'InvoiceCreated' })
  accessor status: string = '';

  constructor(tracker: Tracker, data?: { status: string }) {
    super(tracker);
    if (data) {
      this.status = data.status; // suppressed when called via tracker.construct()
    } else {
      this.status = 'draft';     // fires changed, appears in generateEvents()
    }
  }
}

// Loading from DB — defaults suppressed, tracker stays clean
const saved = tracker.construct(() => new InvoiceModel(tracker, { status: 'sent' }));

// User creates new — defaults tracked, appear in generateEvents()
const fresh = tracker.new(() => new InvoiceModel(tracker));

Development vs production builds

trakr ships two builds: a development build (dist/dev/) and a production build (dist/prod/).

Development build — creating a tracked object outside tracker.construct() throws immediately with a descriptive error:

MyModel must be created inside tracker.construct()

This catches accidental bare new MyModel(tracker) calls at the earliest possible moment during development.

Production build — the construction guard is compiled away entirely. There is zero runtime overhead for the check.

Build selection is automatic. Bundlers that support the exports field in package.json — Vite, webpack 5+, and others — pick the development build when building in development mode and the production build when building for production. Nothing extra is required from consumers; the correct build is selected via the development export condition in trakr's package.json.

Default state: Unchanged

TrackedObject defaults to Unchanged at construction time. This matches the most common scenario — objects are loaded from the database and are already persisted.

const item = tracker.construct(() => new ItemModel(tracker)); // state: Unchanged (DB-loaded default)

To create a new item that needs to be inserted, add it to a TrackedCollection via push. The collection is responsible for transitioning the object to Insert:

const item = tracker.construct(() => new ItemModel(tracker));
items.push(item);          // state: Insert — tracked, undoable
tracker.undo();            // state: Unchanged, removed from collection

Items passed to the TrackedCollection constructor are treated as already-persisted rows and are not marked as Insert:

const items = new TrackedCollection<ItemModel>(tracker, [dbItem]); // dbItem stays Unchanged

Insert/Delete lifecycle

State transitions to Insert and Deleted are triggered by two mechanisms: collection mutations and @Tracked property assignments.

Via TrackedCollection

Adding or removing a TrackedObject from a TrackedCollection transitions its state automatically:

const item = tracker.construct(() => new ItemModel(tracker)); // Unchanged
items.push(item);   // → Insert
items.remove(item); // → Unchanged, and untracked (was never saved)

const loaded = tracker.construct(() => new ItemModel(tracker, { id: 1 })); // Unchanged
items.push(loaded);   // → Insert
items.remove(loaded); // → Deleted

Removing an item whose state is Insert collapses it as if it had never existed: the state resets to Unchanged and the object is removed from tracker.trackedObjects in the same undo step. You do not need to call destroy() yourself — the collection handles it. Undo of the remove re-tracks the item and puts it back in the collection at state Insert.

Via @Tracked property

When a @Tracked property holds a TrackedObject value, assigning to it has the same effect: the outgoing value transitions to Deleted (or Unchanged if it was Insert), and the incoming value transitions to Insert:

class OrderModel extends TrackedObject {
  @Tracked()
  accessor detail: DetailModel | null = null;

  constructor(tracker: Tracker) { super(tracker); }
}

const order = tracker.construct(() => new OrderModel(tracker));
const detail = tracker.construct(() => new DetailModel(tracker)); // Unchanged

order.detail = detail; // detail → Insert
order.detail = null;   // detail → Deleted

Setting a new value while one is already assigned marks the old one removed and the new one added in the same undo step:

const detail2 = tracker.construct(() => new DetailModel(tracker));
order.detail = detail2; // detail → Deleted, detail2 → Insert (one undo step)
tracker.undo();         // detail2 → Unchanged, detail → Insert

Suppression

State transitions respect tracking suppression. Inside tracker.construct() and tracker.withTrackingSuppressed(), collection mutations and property assignments are applied silently without state transitions. This means loading data inside tracker.construct() never accidentally marks objects as Insert or Deleted.

Object state machine

Every TrackedObject has a trakrState: State property — the single source of truth for what the save layer needs to do with that object. State transitions are driven by three types of events:

  • edit — a @Tracked property is written
  • collection mutation — the object is pushed to or removed from a TrackedCollection
  • committracker.onCommit() is called after a successful server save

Redo is always the same as do

There is no separate redo transition. Redo simply re-runs the original do action. trackingId is assigned at construction and never changes, so it is always available regardless of undo/redo cycles.

Full transition table

| Event | Direction | From | To | @AutoId field | |---|---|---|---|---| | edit | do / redo | Unchanged | Changed | untouched | | edit | do / redo | Changed | Changed | untouched | | edit | undo (last edit) | Changed | Unchanged | untouched | | edit | undo (not last) | Changed | Changed | untouched | | added | do / redo | Unchanged | Insert | untouched | | added | undo | Insert | Unchanged | untouched | | removed | do / redo | Insert | Unchanged | untouched, dirtyCounter reset | | removed | do / redo | Unchanged | Deleted | untouched | | removed | undo | Unchanged (was Insert) | Insert | untouched | | removed | undo | Deleted | Unchanged | untouched | | committed | do / redo | Insert | Unchanged | written with real id (if key supplied) | | committed | do / redo | Changed | Unchanged | written with real id (if key supplied) | | committed | do / redo | Deleted | Unchanged | untouched | | committed | undo | was Insert | Deleted | kept (real id for DELETE) | | committed | undo | was Changed | Changed | untouched | | committed | undo | was Deleted | Insert | kept (stale — use trackingId for POST) |

Key notes

removed/do from Insert collapses to Unchanged and untracks the object — if an object was added and then removed before ever being committed, it was never persisted. The transition resets dirtyCounter to zero, removes the object from tracker.trackedObjects, and clears its dependency-tracking entries — as if the add never happened. Nothing needs to be sent to the server, and the object no longer contributes to tracker.isValid. Undo of the remove re-tracks the object and restores its previous validity accounting.

removed/do from Unchanged or Changed moves the object to Deleted but keeps it tracked — committed objects must stay in tracker.trackedObjects so the save layer can send a DELETE request. Their validity contribution is released immediately on removal: if the object was invalid, tracker.isValid updates as soon as the remove is applied. Undo of the remove restores the object to its prior state and, if it was invalid, reinstates its validity contribution.

committed/undo reverses the server operation — undoing past a commit puts the object into the state that requires the inverse server operation. Undoing a committed INSERT requires a DELETE; undoing a committed DELETE requires a new INSERT; undoing a committed UPDATE requires another UPDATE with the pre-edit values.

@AutoId is never zeroed out — when committed/undo runs after a committed INSERT, the real server id stays on the @AutoId field so the save layer can send DELETE /resource/{id}. Similarly, after committed/undo of a DELETE, the @AutoId field still holds the old real id — but since trakrState is now Insert, the save layer must use trakrId to identify the item in the POST payload, not @AutoId.

trakrId for Insert and Changed itemstrakrId is assigned at construction and never changes. Include it in the save payload for Insert and Changed items so the backend can echo back the new server-assigned PK for each. See Recommended save pattern and Temporally versioned tables for usage.

Recommended save pattern

trakr does not mandate a specific save strategy — you can send changes per-object, batch selectively, or structure your API however your application requires.

That said, a pattern that works well with trakr's design is all-or-nothing saves: when the user clicks Save, the frontend collects every dirty object across the tracker, serialises them into a single request, and the backend saves everything inside one transaction — either succeeding fully or returning an error without applying partial changes. The frontend then calls tracker.onCommit() only on success.

Every TrackedObject has a trakrId — a positive integer assigned at construction time, stable for the lifetime of the object, unique across the tracker. Include trakrId in the save payload for Insert and Changed items. The backend echoes it back alongside the server-assigned PK for any item that produced a new row. onCommit(keys) then iterates every entry in keys, matches by trakrId, and writes the real PK to the @AutoId field of any match — regardless of whether the item was Insert or Changed.

New objects can reference each other via their trakrId in the payload (e.g. a new parent and its new children share consistent temp IDs before the server assigns real ones). After a successful save, tracker.onCommit(keys) updates all matched objects in place — no page reload is needed. This is the intended experience for form-heavy back-office pages, though reloading or restructuring state on save is equally valid.

On failure, do not call onCommit().

If the server returns an error, simply surface the error to the user and leave the tracker as-is. The tracker stays dirty, canUndo remains true, and the user can fix the problem and try again — or undo their changes. Nothing needs to be reset manually.


API Reference

Tracker

The central coordinator. Create one per page or form context and pass it to every model and collection.

const tracker = new Tracker();

State properties

| Property | Type | Description | |---|---|---| | isDirty | boolean | true when uncommitted changes exist | | canUndo | boolean | true when there is at least one undo step | | canRedo | boolean | true when there are undone steps to redo | | isValid | boolean | true when every registered model and collection passes validation | | canCommit | boolean | true when isDirty && isValid — ready to submit to the server | | isDirtyChanged | TypedEvent<boolean> | Fires whenever isDirty changes | | isValidChanged | TypedEvent<boolean> | Fires whenever isValid changes | | canCommitChanged | TypedEvent<boolean> | Fires whenever canCommit changes | | version | number | Monotonically changing counter — starts at 0, increments on every new operation, decrements on undo, increments on redo. Auto-coalesced writes do not increment version (no new undo step is created) but still emit versionChanged | | versionChanged | TypedEvent<number> | Fires on every tracked write, undo, and redo — including auto-coalesced writes where version does not change. Use this as the notification signal for external subscribers such as React's useSyncExternalStore | | trackedObjects | TrackedObject[] | All registered models. Read-only — iterate for save payloads; do not mutate directly | | deletedObjects | TrackedObject[] | Subset of trackedObjects where state === Deleted. Use this to build delete requests — deleted objects are removed from collections and composed properties, making them unreachable from the model tree | | trackedCollections | TrackedCollection<any>[] | All registered collections. Read-only — do not mutate directly |

Undo / redo

tracker.undo();  // reverts the last undo step
tracker.redo();  // re-applies the last undone step

Calling undo() or redo() when the respective flag is false is a no-op.

Commit lifecycle

tracker.onCommit();           // mark current state as committed — isDirty → false
tracker.onCommit(keys);       // same, plus write real server IDs to @AutoId fields

onCommit(keys?) does three things:

  1. Iterates every entry in keys. For each entry it finds a tracked object whose trakrId matches entry.trackingId and writes entry.value to its @AutoId field. This applies to both Insert items (new rows) and Changed items (e.g. temporal tables where an update produces a new row with a new PK).
  2. Transitions every tracked object's trakrState to Unchanged and resets dirtyCounter.
  3. Appends the state change into the existing last undo operation — so undo atomically reverts both the user's edits and the committed state together (no spurious extra undo steps).

Lookup by trakrId

const obj = tracker.getByTrackingId(42);   // TrackedObject | undefined

Returns the tracked object whose trakrId matches the given value, or undefined if none. Deleted objects are still findable this way (they remain in trackedObjects until destroy()). Useful for debugging, correlating server responses against known objects, or hydrating a UI selection from a persisted trakrId.

Sessions

const session = tracker.startSession();    // begin a session
const session = tracker.startSession([…]); // same, with a property scope
session.end();                             // commit — all changes become one undo step
session.rollback();                        // revert — all changes since startSession

session.isDirty;        // false until a scoped property is written
session.isValid;        // false if any scoped property has a validation error
session.canCommit;      // isDirty && isValid
session.canUndo;        // delegates to tracker
session.canRedo;        // delegates to tracker
session.undo();         // delegates to tracker
session.redo();         // delegates to tracker
session.trackedObjects; // objects in scope ([] when no scope)
session.deletedObjects; // scoped objects in Deleted state
session.isDirtyChanged;    // same event as tracker.isDirtyChanged
session.canCommitChanged;  // same event as tracker.canCommitChanged
session.versionChanged;    // same event as tracker.versionChanged

Object construction

// Load existing data — tracking suppressed, tracker stays clean
const model = tracker.construct(() => new MyModel(tracker, savedData));

// Multiple objects at once
tracker.construct(() => {
  new ModelA(tracker, rowA);
  new ModelB(tracker, rowB);
});

// Create a new object — tracking active, defaults appear in generateEvents()
const fresh = tracker.new(() => new MyModel(tracker));

tracker.construct() suppresses tracking entirely. tracker.new() lets changed events fire during construction — so defaults appear in generateEvents() — but discards the undo entries and resets the object to Unchanged, leaving the tracker clean. The Unchanged guarantee means you can immediately push the returned object into an EventTrackedCollection and it will correctly transition to Insert. Both run validators once after all objects are created and call tracker.revalidate() exactly once at the end.

Tracking suppression

// Callback form — preferred
tracker.withTrackingSuppressed(() => {
  model.field = 'silent';   // applied but not recorded, not dirty
});

// Explicit begin/end — useful when the suppressed block spans async boundaries
tracker.beginSuppressTracking();
model.field = 'silent';
tracker.endSuppressTracking();

Suppression is nestable via a counter, so calling beginSuppressTracking() twice requires two endSuppressTracking() calls to resume tracking.

React integration — useSyncExternalStore

version and versionChanged are designed to plug directly into React's useSyncExternalStore. Subscribe to versionChanged as the store and snapshot tracker.version — any component that calls the hook will automatically re-render on every tracked mutation, undo, or redo with no bridging code required:

import { useSyncExternalStore } from 'react';
import { Tracker } from '@katn30/trakr';

function useTrackerVersion(tracker: Tracker): number {
  return useSyncExternalStore(
    (onStoreChange) => tracker.versionChanged.subscribe(onStoreChange),
    () => tracker.version,
  );
}

Any component that calls useTrackerVersion(tracker) will re-render whenever the tracker's state changes.

function InvoiceForm({ tracker, invoice }: { tracker: Tracker; invoice: InvoiceModel }) {
  useTrackerVersion(tracker); // re-renders on every mutation, undo, or redo

  return (
    <form>
      <input value={invoice.status} onChange={(e) => { invoice.status = e.target.value; }} />
      <button disabled={!tracker.canUndo} onClick={() => tracker.undo()}>Undo</button>
      <button disabled={!tracker.canCommit} onClick={save}>Save</button>
    </form>
  );
}

TrackedObject

The abstract base class for all trackable models. All subclass instances must be created via tracker.construct().

class InvoiceModel extends TrackedObject {
  constructor(tracker: Tracker) {
    super(tracker); // registers the model with the tracker
  }
}

const invoice = tracker.construct(() => new InvoiceModel(tracker));

Model properties and methods

| Member | Type | Description | |---|---|---| | tracker | Tracker | The tracker this model belongs to (set via super(tracker)) | | trakrState | State | The current persistence state — Unchanged, Insert, Changed, or Deleted | | trakrId | number | Positive client-assigned identifier, unique across the tracker, set at construction and never changed. Include in the save payload for Insert and Changed items so the backend can return the new server PK | | isDirty | boolean | true when this model has uncommitted property changes | | dirtyCounter | number | Net count of uncommitted property writes. Increments on each write, decrements on undo. Reset to 0 by onCommit(). Can be negative after undoing past a committed save | | trakrIsValid | boolean | true when all @Tracked() validators pass | | validationMessages | Map<string, string> | Maps property name → error message for each failing validator | | changed | TypedEvent<TrackedPropertyChanged> | Fires on every property change, including changes triggered by undo and redo | | trackedChanged | TypedEvent<TrackedPropertyChanged> | Fires only on direct user-initiated writes — never during undo or redo | | destroy() | void | Removes this model from the tracker |

Property change events

Both changed and trackedChanged carry a TrackedPropertyChanged payload:

import type { TrackedPropertyChanged } from '@katn30/trakr';
// { property: string; oldValue: unknown; newValue: unknown }

| Field | Description | |---|---| | property | The decorated property name | | oldValue | The value before the write | | newValue | The value after the write |

Both events fire synchronously inside the tracked operation, so any @Tracked property write made inside either listener is automatically composed into the same undo step as the triggering write (see Automatic composing).

The difference is when they fire:

  • changed fires on every write, including during undo and redo replays
  • trackedChanged fires only on direct user-initiated writes — never during undo or redo
// changed — fires on initial write, undo, and redo; writes in callback are composed
this.changed.subscribe(({ property }) => {
  if (property === 'price' || property === 'quantity') {
    this.total = this.price * this.quantity; // composed into the same undo step
  }
});

// trackedChanged — fires only on initial write; writes in callback are still composed
this.trackedChanged.subscribe(({ property, newValue }) => {
  if (property === 'title') {
    this.summary = `Summary: ${newValue}`; // composed on initial write only
  }
});

State

Read via obj.trakrState.

import { State } from '@katn30/trakr';

| Value | Meaning | Required DB operation | |---|---|---| | Unchanged | Loaded from DB or just saved — no pending action | — | | Insert | Added to a collection, never committed | INSERT | | Changed | Loaded or committed, then edited | UPDATE | | Deleted | Removed from a collection | DELETE |

For the full set of transitions between these states — driven by edits, collection mutations, undo, redo, and commit — see Object state machine in Concepts.

Loading from DB:

Objects default to Unchanged. Property values set inside the constructor are suppressed by tracker.construct():

class InvoiceModel extends TrackedObject {
  @Tracked() accessor status: string = '';
  constructor(tracker: Tracker, data?: { status: string }) {
    super(tracker);
    if (data) this.status = data.status; // suppressed — not tracked
  }
}

const invoice = tracker.construct(() => new InvoiceModel(tracker, { status: 'active' })); // trakrState: Unchanged

Saving:

Iterate tracker.trackedObjects, read trakrState and the appropriate ID on each model, and call tracker.onCommit() after the server responds successfully.

Why tracker.trackedObjects and not your own model tree? Deleted objects are no longer reachable through your model graph — a TrackedCollection removes them from its array, and a @Tracked property set to null (or replaced with another object) removes the reference. The tracker holds every registered object regardless of its state, so iterating trackedObjects is the only way to reach objects that need a DELETE request. tracker.deletedObjects is a convenience getter for the deleted subset only, but both approaches work.

import { Tracker, TrackedObject, State, Tracked, AutoId, TrackedCollection } from '@katn30/trakr';

class InvoiceModel extends TrackedObject {
  @AutoId
  id: number = 0;

  @Tracked()
  accessor status: string = '';

  constructor(tracker: Tracker, data?: { id: number; status: string }) {
    super(tracker);
    if (data) {
      this.id = data.id;
      this.status = data.status;
    }
  }
}

const tracker = new Tracker();

// Load existing rows from the server
tracker.construct(() => {
  new InvoiceModel(tracker, { id: 1, status: 'draft' });
  new InvoiceModel(tracker, { id: 2, status: 'sent' });
});

// Create a new invoice and add it to a collection (state → Insert)
const invoices = new TrackedCollection<InvoiceModel>(tracker);
const newInvoice = tracker.construct(() => new InvoiceModel(tracker));
invoices.push(newInvoice);
newInvoice.status = 'pending';
// newInvoice.trakrId === 3  (assigned at construction, never changes)
// newInvoice.id      === 0  (untouched by the library until onCommit)

// --- Save ---

// Build the payload by reading each object's trakrState
const payload: {
  inserts: { trackingId: number; status: string }[];
  updates: { trackingId: number; id: number; status: string }[];
  deletes: { id: number }[];
} = { inserts: [], updates: [], deletes: [] };

for (const obj of tracker.trackedObjects) {
  if (!(obj instanceof InvoiceModel)) continue;
  switch (obj.trakrState) {
    case State.Insert:
      // Send trakrId so the backend can echo back the new server PK
      payload.inserts.push({ trackingId: obj.trakrId, status: obj.status });
      break;
    case State.Changed:
      payload.updates.push({ trackingId: obj.trakrId, id: obj.id, status: obj.status });
      break;
    case State.Deleted:
      payload.deletes.push({ id: obj.id });
      break;
    case State.Unchanged:
      break;
  }
}

// Send to server — backend runs everything in one transaction
const response = await api.save(payload);
// response.ids: [{ trackingId: 3, value: 42 }]

// Apply real IDs and mark everything clean — no page reload needed
tracker.onCommit(response.ids);
// newInvoice.id === 42, trakrState === Unchanged
// tracker.isDirty === false

// When no new PKs were assigned, keys can be omitted:
// tracker.onCommit();

@AutoId

Marks a property as the server-assigned autoincrement primary key for this model. Only one @AutoId field is allowed per class. Enables the onCommit lifecycle for real-ID assignment.

@AutoId is a specialisation of @Id: it also registers the property as part of the model's identity (so getIdentity(obj) includes it), but it additionally singles the property out as the one trakr will overwrite from onCommit(keys). Use @Id for identity properties you assign yourself; use @AutoId for the single property trakr should patch on commit. See @Id for identity-only properties (composite keys, caller-assigned UUIDs, natural keys).

class InvoiceModel extends TrackedObject {
  @AutoId
  id: number = 0;

  @Tracked()
  accessor status: string = '';

  constructor(tracker: Tracker) {
    super(tracker);
  }
}

The @AutoId field is left at its initial value until onCommit(keys) writes the real server ID. The save layer identifies items that need a new PK via trakrId — a stable, positive integer assigned at construction and never changed. Include trakrId in the save payload for Insert (and Changed, for temporal tables) items; the backend returns it alongside the new PK.

Typical save flow:

const invoice = tracker.construct(() => new InvoiceModel(tracker));
invoices.push(invoice);
// invoice.trakrId === 1  (assigned at construction, never changes)
// invoice.id      === 0  (untouched by the library)

invoice.status = 'draft';

// 1. Build payload — send trakrId for Insert items:
const serverIds = [{ trackingId: invoice.trakrId, value: 42 }];

// 2. Send to server, receive real IDs back.

// 3. Apply real IDs and mark clean:
tracker.onCommit(serverIds);
// invoice.id        === 42  (written by onCommit)
// tracker.isDirty   === false

onCommit() with no arguments (or an empty array) still marks the tracker as clean — it just skips the ID replacement step.

trakrId values are globally unique across the lifetime of the tracker and never reused, so they can safely serve as correlation keys across multiple save cycles.

Reactivity gate. The @AutoId write performed by onCommit(keys) is a library-internal write, not a user edit. It does not fire TrackedObject.changed for the @AutoId property, does not bump dirtyCounter, does not re-run @Tracked validators, and does not flicker tracker.isDirty back to true during commit. After onCommit returns, trakrState === Unchanged and isDirty === false — as if the object were freshly loaded with the real PK. Treat the write as authoritative baseline update, not as a change event.


@Id

Marks a property as part of the model's caller-provided identity. Any type is allowed (string, number, UUID, ULID, tuple-like composite via multiple decorators). Trakr never mutates the value — you set it (typically from the server, or client-generated for UUID schemas), and trakr uses it purely to compute getIdentity(obj), getIdentityObject(obj), and getIdentityProperties(proto).

class TenantModel extends TrackedObject {
  @Id
  code: string = '';        // caller-assigned string PK

  @Tracked()
  accessor name: string = '';
}

class LineItemModel extends TrackedObject {
  @Id
  orderId: number = 0;      // composite key part 1
  @Id
  lineNo: number = 0;       // composite key part 2

  @Tracked()
  accessor qty: number = 0;
}

Relationship to @AutoId.

| | @Id | @AutoId | |----------------------------------|-----------------------------------|-------------------------------------------------| | Registers property as identity | yes | yes (implicitly) | | Value type | any | any (via IdAssignment<V>; historically number) | | Written by onCommit(keys) | never | yes, from the matching IdAssignment.value | | Multiple per class | yes (composite key) | no (at most one) |

Use @Id for identity you own (client-generated UUIDs, natural keys, composite keys). Use @AutoId for the single property whose value only the server can produce and that must be patched back into the model after onCommit. A class may combine them: several @Id properties for a composite key plus one @AutoId for a surrogate PK, if that matches your schema.


ITracked

The common interface implemented by both TrackedObject and TrackedCollection. Useful for writing utility functions that accept either:

import { ITracked } from '@katn30/trakr';

function isReady(item: ITracked): boolean {
  return item.isDirty && item.trakrIsValid;
}

| Member | Type | Description | |---|---|---| | tracker | Tracker | The tracker this object belongs to | | isDirty | boolean | true when there are uncommitted changes | | dirtyCounter | number | Net count of uncommitted writes | | trakrState | State | Current persistence state (always Unchanged for collections) | | destroy() | void | Removes this object from the tracker |


IdAssignment<V>

The shape of each entry in the keys array passed to tracker.onCommit(keys). Generic in the PK value type, defaulting to number:

import type { IdAssignment } from '@katn30/trakr';
// IdAssignment<V = number> = { trackingId: number; value: V }

const numericKeys: IdAssignment[]           = [{ trackingId: 1, value: 42 }];
const uuidKeys:    IdAssignment<string>[]   = [{ trackingId: 1, value: '01HXYZ-ULID' }];

| Field | Type | Description | |---|---|---| | trackingId | number | The trakrId of the object that received a new server-assigned PK | | value | V (default number) | The real server-assigned ID to write to the @AutoId field |

tracker.onCommit<V>(keys) is generic in V: pass IdAssignment<string>[] for UUID/ULID schemas, IdAssignment[] for the numeric default. Trakr writes entry.value straight into the @AutoId field — the field's declared type is what enforces the match at the call site (e.g. @AutoId id: string = '' with onCommit<string>(...)).

The server returns one IdAssignment per item that produced a new database row — both inserted objects and, in temporal tables, updated objects (see Temporally versioned tables). onCommit() iterates every entry, matches by trakrId against every tracked object, and writes value to the @AutoId field of any match.


@Tracked()

The property decorator. Intercepts every write, records an undo/redo pair, and optionally validates the new value. Works with accessor fields, explicit get/set pairs, and plain getters. Place it on the accessor, the setter, or the getter.

With accessor (recommended):

class ProductModel extends TrackedObject {
  @Tracked()
  accessor name: string = '';

  @Tracked()
  accessor price: number = 0;

  @Tracked()
  accessor active: boolean = true;

  @Tracked()
  accessor config: Record<string, unknown> = {};

  @Tracked()
  accessor createdAt: Date = new Date();

  constructor(tracker: Tracker) {
    super(tracker);
  }
}

With get/set — decorate the setter:

class ProductModel extends TrackedObject {
  private _name: string = '';

  get name(): string { return this._name; }

  @Tracked()
  set name(value: string) { this._name = value; }

  constructor(tracker: Tracker) {
    super(tracker);
  }
}

With get/set and side effects — decorate both getter and setter:

When the setter contains side-effect logic that must stay intact (e.g. cascading writes to other properties), decorate both the getter and the setter. The getter decoration registers isEnabled as a dependency source — any validator that reads it will automatically re-run when the setter fires. The setter decoration handles undo/redo as usual.

class RuleModel extends TrackedObject {
  private _isEnabled: boolean = false;

  @Tracked()
  get isEnabled(): boolean { return this._isEnabled; }

  @Tracked()
  set isEnabled(value: boolean) {
    this._isEnabled = value;
    if (value) {
      this.scheduleDays = 'mon';
    } else {
      this.scheduleDays = '';
    }
  }

  @Tracked((self: RuleModel, v) =>
    self.isEnabled && !v ? 'Day is required' : undefined
  )
  accessor scheduleDays: string = '';

  constructor(tracker: Tracker) {
    super(tracker);
  }
}

When isEnabled is set to true, scheduleDays's validator automatically re-runs because the getter declared the dependency. No manual revalidate() call is needed.

Note: decorating just the getter (without the setter) is valid when the getter is purely computed — it registers the property as a dependency source without attaching any undo/redo logic.

With a validator:

The validator receives the model instance and the incoming value. Return an error string to fail, undefined to pass.

class OrderModel extends TrackedObject {
  @Tracked((self, value) => !value ? 'Status is required' : undefined)
  accessor status: string = '';

  @Tracked((self, value) => value < 0 ? 'Price must be positive' : undefined)
  accessor price: number = 0;

  // Validator can inspect other properties of the model
  @Tracked((self: OrderModel, value) =>
    value > self.price ? 'Discount exceeds price' : undefined
  )
  accessor discount: number = 0;

  constructor(tracker: Tracker) {
    super(tracker);
  }
}

Validators are re-evaluated after every tracked write and after every undo/redo. Results are stored in model.validationMessages and rolled up into tracker.isValid.

Validators that read other properties automatically re-run when those properties change — this is handled by the dependency tracking mechanism (see Dependency tracking in Concepts). For this to work, every property read inside a validator must be exposed through a @Tracked-decorated getter. accessor fields satisfy this automatically. For get/set pairs, both the getter and setter must be decorated with @Tracked — see the "getter + setter with side effects" example above.

No-op detection

Assigning the same value twice does not create an undo step and does not mark the model dirty. Equality is checked with strict ===null, undefined, and '' are all distinct values.

invoice.status = '';      // no-op (already '')
invoice.status = null;    // recorded (null !== '')
invoice.status = 'draft'; // recorded
invoice.status = 'draft'; // no-op

Signature

@Tracked(validator?, onChange?, options?)

| Parameter | Type | Applies to | Description | |---|---|---|---| | validator | (self, newValue) => string \| undefined | accessor, setter | Returns an error string on failure, undefined on success | | onChange | (self, newValue, oldValue) => void | accessor, setter | Side-effect callback. Runs inside the tracked operation — writes to other @Tracked properties or TrackedCollections are composed into the same undo step. Does not fire during undo or redo | | options.coalesceWithin | number | accessor, setter | Maximum gap in ms between two consecutive writes to merge into one undo step. Omit to never coalesce |

// validator only:
@Tracked((_, v) => v < 0 ? 'Must be positive' : undefined)
accessor price: number = 0;

// onChange only — side effects composed into the same undo step:
@Tracked(
  undefined,
  (self: TagModel, newValue, oldValue) => {
    if (oldValue) self.tags.remove(oldValue);
    if (newValue) self.tags.push(newValue);
  },
)
accessor tag: string = '';

// validator + onChange + coalesceWithin:
@Tracked(
  (_, v) => !v ? 'Required' : undefined,
  (self: MyModel, newValue) => { self.log.push(newValue); },
  { coalesceWithin: 3000 },
)
accessor name: string = '';

// coalesceWithin only:
@Tracked(undefined, undefined, { coalesceWithin: 3000 })
accessor status: string = '';

Supported property types: string, number, boolean, Date, object. Unsupported types throw at runtime.


TrackedCollection<T>

A tracked collection with a full array-shaped API. All mutation methods are recorded and undoable. Implements Array<T> for type-level interop with array-consuming APIs — with one runtime gap, described in Positional access at runtime below.

const items = new TrackedCollection<string>(tracker);

// With initial items:
const items = new TrackedCollection<string>(tracker, ['a', 'b']);

// With a validator:
const items = new TrackedCollection<string>(
  tracker,
  [],
  (list) => list.length === 0 ? 'At least one item is required' : undefined,
);

Positional access at runtime

TrackedCollection<T> declares the [n: number]: T index signature (inherited from implements Array<T>), but numeric-index access is not wired up at runtime. TypeScript accepts col[n] and col[n] = x without complaint, but neither behaves like a real array:

  • col[0] returns undefined at runtime, even when col.length > 0.
  • col[0] = x does not mutate the collection and does not trigger tracking. It silently sets a data property on the class instance that shadows the (unimplemented) indexer — subsequent col[0] reads will return x while col.length, iteration, and col.collection still reflect the real state. This divergence will not surface as an error; it will surface as inconsistent UI or a save payload missing the write.

The interface is kept for interop (passing a TrackedCollection to code typed against Array<T>), but the runtime backing is deliberately not provided: wiring it up requires either a Proxy (breaks instance identity across the tracker API and adds an indirection layer on every property access) or per-index defineProperty accessors (allocates one closure per element, and numeric properties become enumerable and appear in Object.keys and for...in). Neither trade-off is worth it for a pattern the class already has first-class alternatives for.

Use these instead:

| Instead of | Use | |---|---| | col[n] (read) | col.at(n) — participates in dependency tracking | | — | col.collection[n] — direct read of the underlying array, no dependency tracking | | col[n] = x (write) | col.replaceAt(n, x) — tracked, undoable | | — | col.splice(n, 1, x) — tracked, undoable | | for (let i = 0; i < col.length; i++) col[i] | for (const item of col) ... | | — | col.forEach(...), col.map(...), etc. |

When passing a TrackedCollection to a third-party function typed against Array<T> that reads by index internally, pass col.collection instead. It exposes the underlying T[] with zero copying. Treat it as read-only: any mutation applied to that reference bypasses the tracker entirely.

Tracked mutation methods

All of these create undo steps:

| Method | Description | |---|---| | push(...items) | Appends one or more items | | pop() | Removes and returns the last item | | shift() | Removes and returns the first item | | unshift(...items) | Prepends one or more items | | splice(start, deleteCount, ...items) | Low-level insert/remove at a position | | remove(item) | Removes a specific item by reference. Returns false if not found | | replace(item, replacement) | Replaces a specific item by reference. Returns false if not found | | replaceAt(index, replacement) | Replaces the item at a given index | | clear() | Removes all items | | reset(newItems) | Replaces the entire collection with a new array | | fill(value, start?, end?) | Fills a range with a value | | copyWithin(target, start, end?) | Copies a slice to another position |

Read-only / non-mutating methods

indexOf, lastIndexOf, includes, find, findIndex, findLast, findLastIndex, every, some, forEach, map, filter, flatMap, reduce, reduceRight, concat, join, slice, at, entries, keys, values, flat, reverse, sort, toReversed, toSorted, toSpliced, with, toString, toLocaleString

Additional properties

| Member | Description | |---|---| | length | Number of items | | isDirty | true when the collection has unsaved mutations | | isValid | true when the validator passes (or no validator was provided) | | error | The current validation error message, or undefined | | changed | TypedEvent<TrackedCollectionChanged<T>> — fires on every mutation, including during undo and redo | | trackedChanged | TypedEvent<TrackedCollectionChanged<T>> — fires only on direct user-initiated mutations, never during undo or redo | | first() | Returns the first item, or undefined if empty | | destroy() | Removes the collection from the tracker |

Collection change events

Both events carry a TrackedCollectionChanged<T> payload:

| Property | Description | |---|---| | added | Items that were inserted | | removed | Items that were removed | | newCollection | The full collection after the mutation |

Both events fire synchronously inside the tracked operation, so any @Tracked property write made inside either listener is automatically composed into the same undo step as the collection mutation (see Automatic composing).

The difference is when they fire:

  • changed fires on every mutation, including during undo and redo replays
  • trackedChanged fires only on direct user-initiated mutations — never during undo or redo
// changed — fires on initial write, undo, and redo; writes in callback are composed
items.changed.subscribe(() => {
  this.itemCount = items.length;
});

// trackedChanged — fires only on initial write; writes in callback are still composed
items.trackedChanged.subscribe((e) => {
  this.lastAdded = e.added[e.added.length - 1] ?? ''; // composed on initial write only
});

TrackedContainer

An abstract base class that extends TrackedObject with the ability to compose child objects and collections into a single validity and dirty check.

Extend it when a model owns children whose isValid and isDirty state should roll up to the parent — for example, a form section that has its own validated fields and owns a collection of sub-items.

Simple example: one section, one owned child object

import {
  TrackedContainer,
  TrackedObject,
  Tracked,
  Tracker,
} from '@katn30/trakr';

class SubtaskDraft extends TrackedObject {
  @Tracked((_, v: string) => (!v ? 'Name required' : undefined))
  accessor name: string = '';

  constructor(tracker: Tracker) { super(tracker); }
}

class ActionsSection extends TrackedContainer {
  @Tracked((_, v: string) => (!v ? 'Owner required' : undefined))
  accessor owner: string = '';

  constructor(tracker: Tracker, subtask: SubtaskDraft) {
    super(tracker);
    this.trackChild(subtask);  // register the child object
  }
}

const tracker = new Tracker();
const sub = tracker.construct(() => new SubtaskDraft(tracker));      // name='' → invalid
const section = tracker.construct(() => new ActionsSection(tracker, sub));

section.trakrIsValid;    // false — sub.name is '' (invalid)
sub.name = 'Fix the bug';
section.trakrIsValid;    // false — own owner field is still ''
section.owner = 'Alice';
section.trakrIsValid;    // true  — both own field and child are now valid

Example: section owns a collection of sub-items

When trackChild receives a TrackedCollection, every item already in the collection is tracked immediately, and items pushed or removed later are tracked/untracked automatically — including across undo and redo.

class SubtaskDraft extends TrackedObject {
  @Tracked((_, v: string) => (!v ? 'Name required' : undefined))
  accessor name: string = '';

  constructor(tracker: Tracker) { super(tracker); }
}

class ActionsSection extends TrackedContainer {
  @Tracked((_, v: string) => (!v ? 'Owner required' : undefined))
  accessor owner: string = '';

  readonly subtasks: TrackedCollection<SubtaskDraft>;

  constructor(tracker: Tracker, subtasks: TrackedCollection<SubtaskDraft>) {
    super(tracker);
    this.subtasks = subtasks;
    this.trackChild(subtasks);  // registers the collection AND its items
  }
}

const tracker  = new Tracker();
const subtasks = new TrackedCollection<SubtaskDraft>(tracker);
const section  = tracker.construct(() => new ActionsSection(tracker, subtasks));

section.owner = 'Alice';
section.trakrIsValid;    // true  — own field valid, no items yet

const sub = tracker.construct(() => new SubtaskDraft(tracker)); // name='' → invalid
subtasks.push(sub);
section.trakrIsValid;    // false — sub.name is '' (item is tracked automatically on push)

sub.name = 'Fix the bug';
section.trakrIsValid;    // true  — all own fields and all items are now valid

subtasks.remove(sub);
section.trakrIsValid;    // true  — invalid item removed, nothing left to fail

protected trackChild(child)

Registers a TrackedObject or TrackedCollection as a child. Call it from the subclass constructor. A container can have any number of children.

When child is a TrackedCollection, trackChild does three things automatically:

  1. Registers the collection itself (so its own validator, if any, contributes to trakrIsValid).
  2. Registers every TrackedObject already inside the collection as a child.
  3. Subscribes to collection.changed so that items pushed in later are added to tracking and items removed are dropped from tracking — including across undo and redo.

This means item-level validity and dirty state propagate to the container without any extra wiring: an invalid item anywhere in a registered collection makes container.trakrIsValid false, and fixing that item makes it true again.

protected untrackChild(child)

Removes a previously registered child. The symmetric counterpart to trackChild.

  • For a TrackedObject child: removes it from the container's child list.
  • For a TrackedCollection child: removes the collection, unsubscribes from its changed event, and removes all items currently in the collection from the child list. Items pushed to the collection after this call are ignored.

Calling untrackChild on a child that was never registered is a no-op.

trakrIsValid

Returns true when all of the following hold:

  • Every @Tracked validator on the container's own fields passes
  • Every registered child's trakrIsValid is true

This is a per-obj