Forest 2.0

@wonderlandlabs/forest: data in time

Forest is a transactional state engine. It allows you to create trees of leaves linked in a hierarchy of any useful depth.

It is designed to allow for expansively controlled data; that is, you can design rapidly without data value constraints and add type and value constraints as your application matures.

Compatibility

Forest was written targeting React. It conforms to the Observable API of RxJS standards and can be used in any environment JavaScript environment.

Rapid Action Development

Actions are written directly in the configuration; they can use as their base scope the root, or any child at any depth.

const { Forest } = require("@wonderlandlabs/forest")
 
const pointValueActions = {
  double: (leaf) => leaf.value = 2 * leaf.value,
  halve: (leaf) => leaf.value = leaf.value / 2
}
 
const point = new Forest({
  $value: { x: 0, y: 0 },
  children: {
    x: { $value: 0, type: 'number', actions: pointValueActions },
    y: { $value: 0, type: 'number', actions: pointValueActions }
  },
  actions: {
    double(leaf) {
      leaf.child('x').do.double()
      leaf.child('y').do.double()
    },
    magnitude(leaf) {
      const { x, y } = leaf.value
      return (x ** 2 + y ** 2) ** 0.5
    },
    offset(leaf, x, y) {
      leaf.do.set_x(leaf.value.x + x)
      leaf.do.set_y(leaf.value.y + y)
    }
  }
})
 
point.subscribe({
  next(value) {
    console.log('value:', value)
  },
  error(err) {
    console.log('error:', err)
  }
})
 
point.value = { x: 10, y: 20 }
console.log('point.magnitude:', point.do.magnitude())
point.child('x').do.double()
console.log('point.magnitude:', point.do.magnitude())
point.do.offset(5, 15)
 
/**
 * value: { x: 0, y: 0 }
 * value: { x: 10, y: 20 }
 * value: { x: 10, y: 20 }
 * point.magnitude: 22.360679774997898
 * value: { x: 20, y: 20 }
 * value: { x: 20, y: 20 }
 * point.magnitude: 28.284271247461902
 * value: { x: 25, y: 35 }
 */
 

This lets you create easily reusable patterns that can be injected anywhere in the tree that it is convenient.

  • The actions are also composable -- actions can call other actions.
  • Actions can (a) return values, (b) change the leafs' value(s), or (c) do both.
  • Actions can take in parameters
  • Actions are plain ,synchronous methods. The change is immediately inspect-able as soon as they are called
    -- or even directly inside the action code itself.
  • Actions provide the leaf as the first argument: no confusion around "this", binding, etc.

Transactional binding

Actions can throw errors; if you compose actions this creates a dilemma. If you call three actions from inside another action and then, on calling the fourth action, generate an error, what state is the forests leaves in? In standard procedural flow, this is a problem.

However, given that all activity in Forest is encapsulated inside Transactions, activity follows these rules:

  1. All change is kept in a list of pending changes(on each leaf), and associated with the transaction that requested it
  2. Whenever a transaction causes an error to be thrown, leaves with pending changes are cleared of any pending changes associated with the failed transaction,
    or any transactions that came after it.
  3. If the calling context doesn't trap the thrown error, the previous transaction will be undone, and so on,
    ultimately returning the forest to the last validated state.
  4. Only after all transactions are validated and completed are the leaves' last values committed into the leaf.
  5. All actions are performed inside a transaction; so, if any activity inside an action throws an error (and it is not trapped),
    ALL activity performed by the action will be undone.
  6. Only after all transactions are completed and validated will any subscribers be notified of the forest's next value.

Given that all change in a Forest instance's leaves is synchronous and immediate, this means that if you actively inspect a forest after calling an action or updating a leaves' value, you will see the result of that activity -- however, the value of the leaves will be temporarily pointed towards the pending changes in the leaves' buffer.

Rolling back actions and changes

Rule 5 above states that actions are all transactions. This means that if your action does three things and then fails, ALL the changes done up to that point (inside the action) will be undone -- even if they themselves are not flawed.

const { Forest } = require("@wonderlandlabs/forest")
 
const pointValueActions = {
  double: (leaf) => leaf.value = 2 * leaf.value,
  halve: (leaf) => leaf.value = leaf.value / 2
}
 
const point = new Forest({
  $value: { },
  children: {
    x: { $value: 0, type: true, actions: pointValueActions },
    y: { $value: 0, type: true, actions: pointValueActions }
  },
  actions: {
    double(leaf) {
      leaf.child('x').do.double()
      leaf.child('y').do.double()
    },
    magnitude(leaf) {
      const { x, y } = leaf.value
      return (x ** 2 + y ** 2) ** 0.5
    },
    offset(leaf, x, y) {
      leaf.do.set_x(leaf.value.x + x)
      leaf.do.set_y(leaf.value.y + y)
    }
  }
})
 
point.subscribe({
  next(value) {
    console.log('next value:', value)
  },
  error(err) {
    console.log('error:', err)
  }
})
 
point.value = { x: 10, y: 20 }
console.log('point.magnitude:', point.do.magnitude())
point.child('x').do.double();
 
try {
  point.value = {x: 40, y: 'fifty'}
} catch (err) {
  console.log('error:', err.message);
}
console.log('value unchanged:', point.value);
 
try {
  point.do.offset(10, 'six');
} catch (err) {
  console.log('error in offset', err.message);
}
console.log('value STILL unchanged:', point.value);
console.log('point.magnitude:', point.do.magnitude())
point.do.offset(5, 15)
 
/**
 * /Users/davidedelhart/.nvm/versions/node/v16.18.0/bin/node /Users/davidedelhart/Documents/repos/forest2-docs/lib/examples/index/transactions.js
 * next value: { x: 0, y: 0 }
 * next value: { x: 10, y: 20 }
 * point.magnitude: 22.360679774997898
 * next value: { x: 20, y: 20 }
 * error: cannot add value of type string to leaf root:y:0fab282c-9fc2-4f17-a97b-775c704303c0 (type number)
 * value unchanged: { x: 20, y: 20 }
 * error in offset cannot add value of type string to leaf root:y:0fab282c-9fc2-4f17-a97b-775c704303c0 (type number)
 * value STILL unchanged: { x: 20, y: 20 }
 * point.magnitude: 28.284271247461902
 * next value: { x: 25, y: 35 }
 */
 
 

So setting the value to {x: 40, y: 'fifty'} will _completely fail -- even the update of y to 40 is not accepted. Similarly, the offset function calls two set methods. The first one succeeds -- but BOTH changes are wiped out when ONE of them fails, because they reverse the entire transaction that encapsulates all activity done inside the action.

Only real change is broadcast

Not only are the results of intermediate changes not broadcast to subscribers, but "non-changes" are suppressed as well.

 
const num = new Forest({ $value: 0 });
const history = watch(num);
num.value = 1;
num.value = 1;
num.value = 2;
num.value = 1;
num.value = 1;
num.value = 3;
 
console.log(history);
// [0, 1, 2, 1, 3]
 

Setting a forest's value to the same value it currently has will not alert subscribers of a "false change." Objects are compared by reference AND JSON.stringify(value) value.

Version Notes This documentation is for Forest 2.0. Version 2.0 is a complete rebuild; the techniques here are not guaranteed to be compatible with previous editions

Last updated on August 31, 2023