Freo is a module library for creating reactive data-stores/streams intended to be used as a global-store, where a single source of truth controls the whole interface in interconnected ways, it introduces the concepts of Adapter
and Adaptable-like
to augment the Observer pattern
with stateful properties and boundaries, and offers several Adapters
and Adaptable-like
implementations to make working with a changing data-source easier to manage and reason about.
Freo enables the use of Functional Reactive Programming (FRP) principles over the top of (some) ordinary javascript primitives by extending the Observer Pattern
with stateful properties, Freo itself isn't functional, but it is reactive. It acts as a wrapper around a data-store/source allowing Adapters
access to a change-stream, the StreamAdapter
module is responsible for bridging the gap between Freo and FRP, by publishing a non-functional change-stream to a functional one.
If this is the first time you're hearing the term FRP then this link* provides a great introduction to Reactive Programming (written by Staltz - the author of xstream and cycle.js - * note that what's being described within relates to an old version of RxJS
, but most of the ideas still apply and they loosely apply to @gdixon/fre
too (which this library depends on)), if you are familiar with FRP, RxJS
or @gdixon/fre
, then put simply: Freo offers an alternative to flatMapping
over Observable-Observables
when working against a changing (optionally nested) data-source, it works by treating sets, deletes and transactions as Publishers
on a global stream, which is then filtered
and multicast
to efficiently reduce the number of streams we need to create when we want to watch any particular unit of state (and its descendants) and to regulate how those changes enter the change-stream.
Nested objects or data-sources, have an inferred relationship, when we modify a nested property, a change has been made to every property in the object back to the root of it's store, this is best exemplified by immutable objects but for the purpose of a data-source/change-stream the same is true of mutable objects too, in order to be reactive to a nested change on a stream at any parent position, the parent(s) must be aware of the stream the child is emitting to or we must emit to a shared stream, without proper management, this can lead to mixed concerns and brittle code that is difficult to test.
Freo introduces 4 new types of Subject-like
entites; Adaptable
, Preparable
, Readable
and Writable
and a new Observer-like
called Adapter
.
The Subject-like
entities could also be referred to as Adaptable-like
(that is to say, all 4 are instances of Adaptable
) and they serve to split the source down into structured boundaries, the Adapters
are registered against Adaptable-likes
and are used to pull those disparate pieces back together, allowing for interdependency without having to explicitly control each intersection ourselves. Adapters
can be used independently from the targets that they're registered against, further separating the data-control mechanisms from the data-stream procedures, allowing us to pass around limited access to the data-source by way of controlled streams.
Freo's Adaptable-likes
and Adapters
work in slightly different ways to the original Subject
and Observer
constructs and they don't extend from their base nor do they depend on Subscriptions
or Subscribers
(by default). Instead, they introduce a stateful boundary to the Observer pattern
, they allow for each (Adaptable-like
/Adapter
) to be .registered()
and to hold state. This is useful in the context of a data-store because there should always be a single source of truth, which is inherently stateful. The rest of the implementation is very similar [to the classical FRP/RxJS approach], however each method has a slightly different signature to allow the Adapters
to be context aware without resorting to saving local references to the target(s) that the Adapter
is registered to, it should also be noted that although it might work, subscribing Observers
/Operators
directly to an Adaptable-like
is not the intended use-case because of these discrepancies [but in some cases it might work].
The basic premise behind Freo is as follows:
-
Produce a data-store by instantiating a new
Writable
instances feeding in araw target
(any supported primitive), akey
(dot-delimited key pointing to a position in the raw target (it doesn't have to exist yet)) and a set ofoptions
(an object containing flags and additional methods):-
Writable wraps the
raw target
and exposes;.set()
,.delete()
and.transaction()
, so that properties on theraw target
can be altered by referencing nested positions with akey
, these operations are resolved and then notified to the registeredAdapters
via.next()
such that the previousAdapter
can alter themessage
before the nextAdapter
sees it. -
Writable
extendsReadable
, which provides methods for exposing the instance'skeyed
value from theraw target
(.raw()
,.valueOf()
,.toJSON()
, etc) as well as a.get()
method to move from the current instance to a new instance holding; a desiredkey
, any additionaloptions
and always the sameroot target
that the source holds (theroot target
is always the topWritable
(orReadable
) instance that holds theraw target
to give each child a shared reference to the same raw object that can be modified immutably without holding reference to any intermediary instances, which helps to avoid trapping references/leaking memory). -
Readable
extendsPreparable
, and allows for the registration/preparation procedure (described below) to be waited on via.onReady()
: passing.onReady()
a function will enqueue it to be called when the instance moves into._isReady
state.Preparable
also exposes a.ready()
method, which is used internally byReadable
/Writable
to set the._isReady
state and to flush the queue, as well as a.prepare()
method (which is also called internally byReadable
/Writable
) which will take an (optional) function supplied in the instancesoptions
and call it before calling.ready()
, this function might return aPromise
, and if it does, the.ready()
call is chained allowing for a target to be asynchronously prepared (such as when we need to get the initial state from a server). -
Preparable
extendsAdaptable
, which gives usSubject
like properties (.subscribe()
,.next()
,.error()
,.complete()
and.unsubscribe()
) as well as a.register()
method.-
Calling
.register()
is what initiates the whole start-up (and happens during construct when we create a newReadable
/Writable
instance), it runs through the suppliedAdapters
and calls.register()
on each, passing in the current target. -
An
Adapters
.register()
method might return aPromise
, and if it does, any Adapters that follow must wait for thePromise
to.resolve()
before continuing. -
Once all
Adapters
are registered the.prepare()
method is called to finalise the preparation, once resolved it will call.ready()
, which calls any functions queued via.onReady()
in FIFO order.
-
-
-
Adapters
will receive the.next()
/.error()
/.complete()
and.unsubscribe()
calls issued to any instance that they're registered against much like aSubject
/Observer
, the control mechanism also follows the example set bySubjects
;.complete()
marks us as.isStopped
and.unsubscribe()
marks.closed
and.isStopped
- however Freo gives us slightly more control over how and when we enter those states, enabling us to run the method within the context of a single target without closing the connection to other targets.
As stated before, Freo itself isn't functional, nearly every operation in Freo carries side-effects. We can however bridge the gap and produce a functionally-reactive stream by subscribing a StreamAdapter
to a Readable/Writable
instance; on register, a method (.stream()
) is added to the target instance, which when invoked will return a true Subject-like
that points to the targets key
position on the StreamAdapter
instance's stream.
The StreamAdapter
works against a single root Subject
/BehaviourSubject
(held against the Adapter
) and multicasts
inferred relationships using ShareReplay
and filter
, such that each property in an object or member in an array has it's own (efficient) stream that sources it's events from it's parent, each stream can be controlled in isolation and additional StreamAdapters
can be applied at any time and in any position to further isolate events (and to control the complete/unsubscribe procedure(s)), this allows for unrelated parts to never need to know about each other, components can depend on the change-stream rather than the data-source, leading to code that's fully testable in isolation, but completely interdependent in practice. At its base, the stream is absorbing .next()
messages in the form of objects which detail the operation ({key:*, method:*, value:*, ...}
) but by default subscribing to a StreamAdapter
that is target aware will bind a value-stream mapped from the target's key
position in the raw target
object, if we wanted access to the raw stream we can either pass a flag to the targets .stream()
method (.stream(true)
), or call the StreamAdapter
instance's .stream()
method without passing in a target.
npm install @gdixon/freo --save
Utilising Writable, Sync (a singleton constructed from the SyncAdapter) and StreamAdapter:
// Construct a single Writable instance to act as global store...
import { Writable } from "@gdixon/freo";
// Associate a singleton of the SyncAdapter to sync all data coms from all connected tabs (via a ServiceWorker)
import { Sync } from "@gdixon/freo/adapter";
// Associate the StreamAdapter to build a Fre compatible BeahviourSubject-Stream
import { StreamAdapter } from "@gdixon/freo/adapter";
// Construct the Writable and register the Adapter's
// * note - if this was the nth+1 time we used the SyncAdapter's instance against a root level Writable it would replace
// the given target ({}) with the target held against the Adapter instance inorder to share the worker connection
// and global root target
const store = new Writable({}, "", {
// working the source as an immutable object is optional
immutable: true,
// provide all adapters we want associated with the source
adapters: [
// associate an instance of the StreamAdapter
new StreamAdapter(),
// associate the Sync singleton to subvert the root
{
adapter: Sync,
options: {
// set the control scope for the service worker
scope: "./",
// pass the filename for the serviceWorker we want to load
filename: "publically-accessable-and-relatively-scoped-serviceWorker-file-that-imports-worker-from-@gdixon/freo/worker.js"
}
}
],
// prepare is called as soon as the worker is .ready and the Writable has received ServiceWorkers state
prepare: function () {
// return a Promise -> will have writable.ready chained to it so that resolve sets .isReady and runs through enqueued fns
return new Promise((resolve) => {
// if the ServiceWorker holds no data...
if (Object.keys(this.raw(true)).length === 0) {
// carry out a set to deliver initial payload (this could be a fetch request which would justify this Promise)
this.set({
a:{
b: 1
}
});
}
// resolve the promise
resolve(this);
});
},
});
// when subscribed to this stream the connected Observer would be invoked with every message to every key and would
// provide the whole source object as message
// (* note that conventions suggest we suffix our stream instances with a $ sign to label them as Observable)
const store$ = store.stream();
// after the state has been prepared...
store.onReady(function () {
// collect the a.b entry
const ab = this.get("a.b");
// construct a stream against the position
const ab$ = ab.stream();
// subscribe to the stream logging all messages
ab$.subscribe((value) => console.log(value));
// ... later modify the source ...
ab.set(2);
ab.set("string");
ab.delete();
ab.set({"c": 1});
ab.set(4);
// * note that every set/delete/transaction which successfully writes data will return an obj with an undo method to revert the changes
const operation = ab.transaction(function() {
this.set(5);
console.log(ab.raw()); // still 4
});
// undo the transaction
operation.undo();
});
/*
logs (on first load):
$ 1
$ 2
$ "string"
$ undefined
$ {"c": 1}
$ 4
$ 4
$ 5
$ 4
logs (after refresh):
$ 4
$ 2
$ "string"
$ undefined
$ {"c": 1}
$ 4
$ 4
$ 5
$ 4
*/
- Adaptable
- Preparable
- Readable
- Writable
- clone
- merge
- join
- equal
- isObject
- isConstructor
- length
- typeOf
- toArray
- matchKey
- hasDefinition
- valueAt
- prepareMeta
- getValueAt
- setValueAt
- stream
- getType
- array
- object
- arrayBuffer
- date
- bool
- float
- integer
- string
- castArray
- castObject
- castArrayBuffer
- castDate
- castBool
- castFloat
- castInteger
- castString
- Adapter
- CacheAdapter
- ChangesAdapter
- ErrorAdapter
- GroupAdapter
- HistoryAdapter
- MessageAdapter
- StreamAdapter
- SyncAdapter
- The SyncAdapter is also exported as a singleton (
Sync
) to allow for the serviceWorker instance to be shared.
- The SyncAdapter is also exported as a singleton (
- TypeAdapter
// Load the worker inside a ServiceWorker via require (changes to the worker.js file will trigger clients to re-register)
require("@gdixon/freo/worker.js");
or
// Import the worker inside a ServiceWorker via importScript (changes to the worker.js file will NOT trigger clients to re-register)
importScript("./worker.js");
npm run test[:watch]
npm run coverage[:watch]
npm run lint[:fix]
npm run build
- We use SemVer for versioning. For available versions, see the tags on this repository.
-
Graham Dixon - Initial work - GDixon
See also the list of contributors who participated in this project.
- This project is licensed under the MIT License - see the license file for details
- RxJS - A reactive programming library for JavaScript (with thanks to Ben Lesh and all RxJS contributors)