/andern

change propagation for object trees

Primary LanguageTypeScriptMIT LicenseMIT

npm package minimized gzipped size version tests

ändern helps with tracking changes across an object tree. Use it to wrap and modify an object, and you'd get observables notifying you when some part of the object changes, and what the changes are.

import { createRoot } from 'andern'

const root = createRoot({
  people: [
    { name: 'John', age: 20 },
    { name: 'Jane', age: 21 },
  ]
})

// 👇 subscribe to a part of the tree
root.child('/people/1').subscribe(console.log)

// 👇 manually set a specific part of the tree
root.child('/people/1/age').set('', 32)

// 👇 apply a patch to a specific part of the tree
root.child('/people').patch({ op: 'remove', path: '/1/age' })

▷ TRY IT


Contents


Installation

Node:

npm i andern

Browser / Deno:

import { createRoot } from 'https://esm.sh/andern'

Usage

ändern helps with propagating and tracking changes across some object (for example, managing application state). It treats the object as a tree: the root represents the whole object, and child nodes representing various parts of it. You can subscribe to different parts of the tree, modify or patch them, etc, and ändern will make sure that all changes are propagated exactly to the correct subscribers.

👉 Create a root node:

import { createRoot } from 'andern'

const root = createRoot({
  people: [
    { name: 'John', age: 20 },
    { name: 'Jane', age: 21 },
  ]
})

👉 Get a child node:

const john = root.child('/people/0')

NOTE

Child nodes are specified in JSON Pointer format.


👉 Subscribe to a node's values:

john.subscribe(console.log)
root.child('/people/1').subscribe(console.log)

Each node is an observable that emits the current value of the node whenever it changes.


👉 Update the node:

john.set('/name', 'Johnny')
john.add('/title', 'Dr.')
john.remove('/age')

NOTE

To change the whole object, use '' as the path. Alternatively, you can use node.next(...), as each node is also an observer.


👉 Subscribe to its changes:

john.patches.subscribe(console.log)

👉 Apply a patch:

john.patch({ op: 'replace', path: '/name', value: 'Johnny' })

NOTE

Changes are expressed in JSON Patch format.


👉 Use .read() method to get readonly nodes:

const john = root.read('/people/0')

Advanced Usage

Nodes

The core construct of ändern is the Node class, which tracks and notifies of changes in some part of an object tree.

class Node<T>
  extends Observable<T>
  implements Observer<T> {

  constructor(
    initial:  T,
    channel: PatchChannel,
  );

  read(path: string): ReadonlyNode<T>;
  child(path: string): Node<T>;
  patch(patch: Patch | Operation): this;
  set(path: string, value: any): this;  
  remove(path: string): this;

  // and some inherited methods
}

To create a Node, you need a channel, which:

  • is an Observable of patches, through which the parent informs the node of changes to its value.

  • is an Observer of patches, through which the node informs its parent of requested changes to its value.


When a node is requested to change (through its .set(), .remove(), .patch(), or .next() methods), it calculates the necessary changes and sends them to the parent (via its channel). It will apply the changes when they are received from the channel, notifying subscribers (read more).

👉 Use bundle() to combine an Observable and an Observer to create channels. For example, this is a root node that debounces incoming changes:

import { Node, bundle } from 'andern'
import { Subject, debounceTime } from 'rxjs'

const bounce = new Subject()
const root = new Node(
  intialValue,
  bundle(
    bounce.pipe(debounceTime(100)),
    bounce,
  )
)

▷ TRY IT

Safety

A Node receiving a patch that it can't apply will result in an error, closing the stream. This can happen, when for example another node sends a conflicting patch earlier.

Use SafeNode class to ignore erroneous patches.

import { SafeNode } from 'andern'

const root = new SafeNode(
  /* ... */
)

NOTE

createRoot() uses SafeNode by default, so you don't need to worry about safety in normal use cases. You need to think about it only if you're creating custom nodes.


Persistence

For simple persistence, you can subscribe to the root node of the tree and save the object upon changes. If, however, you need more than one root node (or persisting node), all sharing the same storage, then this solution would NOT work.

In such cases, use the PersistedNode class. It differs from a normal Node in that it will attach sender info (its own identifier) alongside the patch data to its channel, and then stores changes when it receives its own messages from the channel. This way, you can have multiple nodes distributing the load of persisting changes without doing redundant work, while retaining data consistency.

class PersistedNode<T> extends Node<T> {
  constructor(
    initial: T,
    persist: (patch: Patch) => Promise<void>,
    channel: MessageChannel,
    identifier?: string
  )
}

How it Works

ändern uses trees, composed of Nodes, for tracking changes across objects. Each node is an Observable and an Observer for a designated part of the tree, represented by some JSON pointer. For an object (tree) like this:

const object = {
  people: [
    { name: 'John', age: 20 },
    { name: 'Jane', age: 21 },
  ],
  orgname: 'ACME',
}

You can track (or apply) changes to the organisation name like this:

const root = createRoot(object)
const orgname = root.child('/orgname')

you can track (or apply) changes to the first person's name like this:

const john = root.child('/people/0/name')

IMPORTANT

ändern does not track changes made to objects directly. A Node can only track changes that are applied by (or via) other nodes in the same tree.


Step 1: Initialisation

When a change is requested (through .set(), .remove(), .patch(), or .next() methods), the node will NOT apply the change, instead calculating the necessary alterations and sends them, as a patch, to its parent (via its channel).

WARNING

.next() compares the node's current and given values to calculate the patch, making it computationally expensive compared to other mutation methods.


Step 2: Up-propagation

The parent updates the patch's path so that it reflects the child it originated from, then sends it to its own parent. Eventually, the patch reaches the root and is bounced back (if valid).

NOTE

The root is like other nodes, except its channel is a Subject, bouncing back any patches it receives. The root created by createdRoot() is a SafeNode, so it also checks validity of bounced patches, dropping invalid ones.


Step 3: Down-propagation

Starting with the root, each node then sends the patch to its matching children, recorrecting the path for each matching child. Each node also applies the patch and notifies its subscribers. The patch eventually reaches the originating node, which does the same.

NOTE

This is basically a master / replica model, where the root node acts as the master and all other nodes are replicas. The root node determines the correct order of changes, resolving potential conflicts.


The whole process looks like this (you can also checkout the live demo):

0 a patch is applied to observer #2 (for example, setting its value to 32)
1 observer #2 up-propagates the following patch to its parent, observer #1:

{ "path": "", "op": "replace", "value": 32 }

2 observer #1 up-propagates a similar patch with updated path to its parent, the root node:

{ "path": "/0/age", ... }

3 root up-propagates a similar patch with updated path:

{ "path": "/people/0/age", ... }

4 the patch is echoed back to root.
5 root notifies all subscribers of change.
5 root down-propagates similar patches with altered paths to its children, observer #3 and observer #1, respectively:

{ "path": "/age", ... }
{ "path": "/0/age", ... }

6 observers #3 and #1 notify their subscribers of change.
6 observer #1 down-propagates a similar patch with altered path to its child, observer #2:

{ "path": "", ... }

7 observer #2 notifies its subscribers of change.


Contribution

You need node, NPM to start and git to start.

# clone the code
git clone git@github.com:loreanvictor/andern.git
# install stuff
npm i

Make sure all checks are successful on your PRs. This includes all tests passing, high code coverage, correct typings and abiding all the linting rules. The code is typed with TypeScript, Jest is used for testing and coverage reports, ESLint and TypeScript ESLint are used for linting. Subsequently, IDE integrations for TypeScript and ESLint would make your life much easier (for example, VSCode supports TypeScript out of the box and has this nice ESLint plugin), but you could also use the following commands:

# run tests
npm test
# check code coverage
npm run coverage
# run linter
npm run lint
# run type checker
npm run typecheck