ä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' })
Node:
npm i andern
Browser / Deno:
import { createRoot } from 'https://esm.sh/andern'
ä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 usenode.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')
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,
)
)
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()
usesSafeNode
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.
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
)
}
ändern
uses trees, composed of Node
s, 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. ANode
can only track changes that are applied by (or via) other nodes in the same tree.
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.
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 bycreatedRoot()
is aSafeNode
, so it also checks validity of bounced patches, dropping invalid ones.
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):
a patch is applied to observer #2 (for example, setting its value to
32
)
observer #2 up-propagates the following patch to its parent, observer #1:{ "path": "", "op": "replace", "value": 32 }observer #1 up-propagates a similar patch with updated path to its parent, the root node:
{ "path": "/0/age", ... }root up-propagates a similar patch with updated path:
{ "path": "/people/0/age", ... }the patch is echoed back to root.
root notifies all subscribers of change.
root down-propagates similar patches with altered paths to its children, observer #3 and observer #1, respectively:{ "path": "/age", ... }{ "path": "/0/age", ... }observers #3 and #1 notify their subscribers of change.
observer #1 down-propagates a similar patch with altered path to its child, observer #2:{ "path": "", ... }
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