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/chronicle-unit-of-work

v1.2.0

Published

Unit of Work for TypeScript models: tracks new, changed and deleted objects, with undo/redo and validation, and commits them in one save.

Readme

@katn30/chronicle-unit-of-work

A Unit of Work for TypeScript models: chronicle tracks which objects are new, changed or removed, with undo/redo and validation, and saves them together with one call.

npm install @katn30/chronicle-unit-of-work

Requires TypeScript 5 or later, with experimentalDecorators not set (chronicle uses the standard decorators). Everything is imported from this package; @katn30/chronicle-core is installed with it.

The shared concepts (models, construct() / new(), @Tracked, validation, hooks and events, collections, containers, undo/redo and the history) are described in the main README. This page covers what is specific to a unit of work.


A model

import { UnitOfWork, Entity, EntityContainer, TrackedCollection, Tracked, AutoId } from "@katn30/chronicle-unit-of-work";

class Line extends Entity {
  @AutoId id: number | null = null;
  @Tracked((_self, qty: number) => (qty <= 0 ? "At least 1" : undefined))
  accessor qty: number = 1;
  @Tracked() accessor product: string = "";
  constructor(tracker: UnitOfWork, product = "") { super(tracker); this.product = product; }
}

class Invoice extends EntityContainer {
  @AutoId id: number | null = null;
  @Tracked() accessor customer: string = "";
  readonly lines = new TrackedCollection<Line>(this.tracker, []);
  constructor(tracker: UnitOfWork) {
    super(tracker);
    this.trackChild(this.lines);    // the invoice's validity and dirtiness include its lines
  }
}

Loading what the server has:

const tracker = new UnitOfWork();
const invoice = tracker.construct(() => {
  const inv = new Invoice(tracker);
  inv.id = row.id;
  inv.customer = row.customer;
  inv.lines.push(...row.lines.map((l) => {
    const line = new Line(tracker, l.product);
    line.id = l.id;
    line.qty = l.qty;
    return line;
  }));
  return inv;
});

Everything written inside construct() is the saved state: nothing is pending.


Object state

Every model has a chronicleState: what the next save has to do with it.

| State | Meaning | Becomes it when | |---|---|---| | Unchanged | nothing to save | loaded, or saved | | Insert | new: to be inserted | added to a collection (or assigned to a tracked property) while Unchanged and not saved yet | | Changed | to be updated | one of its tracked properties is written | | Deleted | to be deleted | removed from its collection (or its property cleared) |

const line = tracker.new(() => new Line(tracker, "Pen"));
line.chronicleState;          // Unchanged: nothing to save yet
invoice.lines.push(line);
line.chronicleState;          // Insert

invoice.customer = "ACME";
invoice.chronicleState;       // Changed

invoice.lines.remove(invoice.lines[0]);
// the removed line is Deleted
  • Removing a new (Insert) object simply forgets it: the server never had it.
  • Putting a removed object back, in the same collection or in another one, makes it Changed: the server still has it, and its place in the model changed.
  • Undo restores the state: undoing the push makes the line Unchanged again.
  • An object lives in one collection at a time. If the same object is in two collections and is removed from one of them, it becomes Deleted, and the next commit deletes it, even though the other collection still shows it. To move an object, remove it from one collection and add it to the other.
  • collection.reset(items) leaves the items in both untouched: only those that left become Deleted (or are forgotten, if new), and those that arrived Insert (or Changed, if they were removed before).
  • tracker.batch(action) makes several writes one step: one undo, and each object it touched is saved once.
  • Undo and redo cross saves. Undoing a saved addition makes the object Deleted, undoing a saved removal makes it Insert, and redoing them brings it back to Unchanged.
  • dirtyCounter is the net number of unsaved writes, adding and removing included (+1 per write, −1 per undo); a save resets it. isDirty is true while the object has unsaved writes. A container's isDirty also covers its children.

Saving: commit(save)

await tracker.commit(async ({ inserted, changed, deleted }) => {
  const response = await api.save({
    inserted: inserted.map(toDto),
    changed: changed.map(toDto),
    deleted: deleted.map((obj) => obj.id),
  });
  return response.ids;       // [{ chronicleId, value }] for the inserted objects
});

save receives the objects to insert, update and delete, and returns the ids the server assigned, as { chronicleId, value } pairs. Send each new object's chronicleId with it, so the server can echo it back next to the new id.

When the returned promise resolves:

  • every object is Unchanged and tracker.isDirty is false;
  • the returned ids are written into the objects' @AutoId fields.

commit resolves to false when there was nothing to save. Changes that cancel out (a new line added and removed again) are committed without calling save.

While a save runs

  • Edits made meanwhile stay pending for the next commit. The objects they touch stay Changed (or Insert) after the save completes.
  • Undo stops at the changes being saved until the save completes: they cannot be undone halfway through being written. canUndoChanged reports when undo goes off and comes back.
  • Commits run one after another: a second commit waits for the first.
  • If save throws (or its promise rejects), nothing is committed and the error propagates. Everything stays pending.
  • tracker.isSaving is true meanwhile, and the history entries being saved have isSaving: true.

Undo after a save

Undo crosses commits. Undoing a saved change makes the object dirty again, so the next commit saves the reversal:

invoice.customer = "ACME";
await tracker.commit(save);   // saved
tracker.undo();               // customer is back to its previous value
invoice.chronicleState;       // Changed: the next commit saves the old value

What the next commit does depends on the step undone:

| Undone step (already saved) | The object becomes | |---|---| | an edit | Changed: the server gets the previous values | | the step that added it | Deleted: the server has it, the model no longer does | | the step that removed it | Insert: the server deleted it, the model has it again |

Redoing a step brings the object back to what was saved (Unchanged, or Changed if it was edited since).

Tables that version their rows

Some databases never update a row in place: an update closes the current row and inserts a new one, with a new id. Return a { chronicleId, value } pair for those changed objects too, and chronicle writes the new id into their @AutoId, so the next save refers to the current row.


Autosave

tracker.changed.subscribe(() => {
  if (tracker.canCommit) tracker.commit(save);
});

changed fires once an operation is recorded, undone or redone. Commits queue themselves, so edits made while a save runs are sent by the next one.


Discarding

tracker.discardPendingChanges() goes back to the saved state and clears the undo/redo history: it undoes what was not saved, and redoes saved steps that were undone. While a save runs, what it is saving is kept.

If a saved step was undone and then replaced by a new edit, the saved state can no longer be reached: discard reverts what it can, and the tracker stays dirty, so the next commit saves the difference.


Reference

What this package adds to the shared API:

| Export | Members | |---|---| | UnitOfWork | commit(save), and everything every tracker has (batch, undo, …) | | Entity | chronicleState, dirtyCounter, isDirty | | EntityContainer | Entity, plus the protected trackChild(child) / untrackChild(child) | | State | Unchanged, Insert, Changed, Deleted | | CommitBatch | { inserted, changed, deleted }: what save receives | | SaveFunction<V> | (batch) => ids, sync or async; V is the id type (number by default) |

License

MIT