stx documentation

ⓘ The line below is removed from every example for sake of readability. Consider it as the first line of every example.

// on the server
const { create } = require('stx')

// on the browser
const { create } = require('stx/dist/browser')

Mutation

ⓘ Every state has a root and can have sub leaves: state means root or leaf.

create([value]) or root.create([value])

Creates a new master state or creates a new branch from an existing master state.

Arguments

Returns

Root of newly created state.

Examples

Create with initial value
const state = create({
  key: 'value',
  nested: {
    one: 1
  }
})
Create without initial value
const state = create()
Create a branch from master
const master = create({
  first: 1
})
const branch1 = master.create({
  second: 2
})
const branch2 = branch1.create({
  third: 3
})
master.serialize() // → { first: 1 }
branch1.serialize() // → { first: 1, second: 2 }
branch2.serialize() // → { first: 1, second: 2, third: 3 }

state.get(path, [value])

Gets a sub leaf from any state.

Arguments

Returns

Sub leaf for the relative path.

Examples

Get an immediate child leaf
const state = create({
  key: 'value'
})
state.get('key').compute() // → 'value'
Get a second level leaf
const state = create({
  nested: {
    key: 'value'
  }
})
state.get([ 'nested', 'key' ]).compute() // → 'value'
Get inexisting leaf
const state = create()
state.get([ 'nested', 'key' ], 'value').compute() // → 'value'
state.serialize() // → { nested: { key: 'value' } }
Get sub leaf of a leaf
const state = create({
  nested: {
    key: 'value'
  }
})
const nested = state.get('nested')
nested.get('key').compute() // → 'value'

state.set(value)

Update value of an existing state.

Arguments

Returns

Same state.

Examples

Update a primitive value
const state = create({
  key: 'value'
})
state.get('key').set('updated value').compute() // → 'updated value'
Update a nested value
const state = create({
  first: 1
})
state.set({
  first: 'updated 1',
  second: 'added 2'
})
state.serialize() // → { first: 'updated 1', second: 'added 2' }
Adding a sub leaf to a primitive leaf

ⓘ Unlike a JSON object, a state can have own value and children at the same time.

const state = create({
  key: 'value'
})
state.get('key').set({
  subKey: 'subValue'
})
state.get('key').compute() // → 'value'
state.get('key').serialize() // → { val: 'value', subKey: 'subValue' }
state.serialize() // → { key: { val: 'value', subKey: 'subValue' }}
Using reserved key val
const state = create({
  nested: {
    key: 'value'
  }
})
const nested = state.get('nested')
nested.set({ val: 'parent value' })
nested.get('key').set({ val: 'updated value' })
nested.compute() // → 'parent value'
nested.serialize() // → { val: 'parent value', key: 'updated value' }
Removing

ⓘ Nested children of a leaf will be also removed recursively.

const state = create({
  nested: {
    first: 1,
    second: 2,
    third: 3
  }
})
state.get([ 'nested', 'second' ]).set(null)
state.get('nested').serialize() // → { first: 1, third: 3 }
state.set({
  nested: null
})
state.serialize() // → {}

leaf.parent()

Returns

Parent state of the leaf.

Examples

Get parent of a leaf
const state = create({
  key: 'value'
})
state.get('key').parent().serialize() // → { key: 'value' }
Get parent of the root
const state = create()
state.parent() // → undefined

state.root()

Returns

Root for the leaf.

Examples

Get root of a leaf
const state = create({
  nested: {
    key: 'value'
  }
})
const nestedKey = state.get([ 'nested', 'key' ])
nestedKey.root().serialize() // → { nested: { key: 'value' } }
Get root of the root
const state = create('value')
state.root().compute() // → value

state.path()

⚠ Despite being useful for debugging, this operation has a high time complexity. Avoid using it in production code.

Returns

Path array for the state.

Examples

Get path of a leaf
const state = create({
  nested: {
    key: 'value'
  }
})
const nestedKey = state.get([ 'nested', 'key' ])
nestedKey.path() // → [ 'nested', 'key' ]
Get path of the root
const state = create()
state.path() // → []

References

Special Notation

A reference is an absolute path array starting with an @ sign.

get and compute will follow references by default, serialize will not.

const state = create({
  nested: {
    key: 'value'
  },
  pointer: {
    p1: [ '@', 'nested', 'key' ],
    p2: [ '@', 'nested' ]
  }
})
state.get([ 'pointer', 'p1' ]).compute() // → value
state.get([ 'pointer', 'p2' ]).serialize() // → [ '@', 'nested' ]
state.get([ 'pointer', 'p2' ]).origin().serialize() // → { key: 'value' }
state.get([ 'pointer', 'p2', 'key' ]).compute() // → value
state.get('pointer').serialize()
// → { p1: [ '@', 'nested', 'key' ], p2: [ '@', 'nested' ] }

Merge of children

References will have children leaves both from their own and from referred leaf.

const state = create({
  nested: {
    first: 1
  },
  pointer: {
    val: [ '@', 'nested' ],
    second: 2
  }
})
const pointer = state.get('pointer')
const nested = state.get('nested')
pointer.get('first').compute() // → 1
pointer.get('second').compute() // → 2

// local keys override referred keys
pointer.set({ first: 'local 1' })
pointer.get('first').compute() // → local 1

// anything can have a value and keys at the same time
nested.set('value')
pointer.compute() // → value

// keys added to referred will always be reflected at reference
nested.set({ third: 3 })
pointer.get('third').compute() // → 3

Inheritence of references and referred

const master = create({
  nested: {
    first: 1,
    second: 2
  },
  pointer: {
    p1: [ '@', 'nested', 'first' ]
  }
})

const branch1 = master.create({
  nested: {
    first: '1 in branch'
  }
})
branch1.get([ 'pointer', 'p1' ]).compute() // → '1 in branch'

const branch2 = branch1.create({
  pointer: {
    p1: [ '@', 'nested', 'second' ]
  }
})
branch2.get([ 'pointer', 'p1' ]).compute() // → 2

Corner cases of set with get

const state = create({
  nested: {
    first: 1
  },
  pointer: {
    val: [ '@', 'nested' ]
  }
})
const pointer = state.get('pointer')

// if get doesn't need to follow the reference yet, it will set the value in reference
pointer.get('second', 2).path() // → [ 'pointer', 'second' ]

// if get needs to follow the reference, it will set the value in referred
pointer.get([ 'first', 'subKey' ], 'value').path()
// → [ 'nested', 'first', 'subKey' ]

Iteration

state.forEach(fn)

Iterate over children leaves of a state.

Arguments

Examples

Recursive iteration
const state = create({
  nested: {
    first: {
      f1: 1,
      f2: 2
    },
    second: {
      s1: 1,
      s2: 2
    }
  }
})

// recursive key listing
const keys = []
const iterator = (leaf, key) => {
  keys.push(key)
  leaf.forEach(iterator)
}
state.forEach(iterator)

keys // → [ 'nested', 'first', 'f1', 'f2', 'second', 's1', 's2' ]

state.map(fn)

Map children leaves of a state to an Array.

Arguments

Examples

List computed values
const state = create({
  first: 1,
  second: 2,
  third: 3
})

const values = state.map(item => item.compute())
values // → [ 1, 2, 3 ]

state.filter(fn)

Filter children leaves of a state into an Array.

Arguments

Returns

Array of leaves which passed the filter.

Examples

Filter and map the values
const state = create({
  first: 1,
  second: 2,
  third: 3,
  fourth: 4
})

const values = state
  .filter(item => item.compute() % 2)
  .map(item => item.compute())

values // → [ 1, 3 ]

state.find(fn)

Find a certain child of a state.

Arguments

Returns

The first child leaf matching the condition.

Examples

Find the strange key
const state = create({
  first: 1,
  strange: 'value',
  third: 3
})

const strange = state.find((_, key) => key === 'strange')
strange.compute() // → value

state.reduce(fn, [accumulator])

Create a single variable out of all children leaves of a state.

Arguments

Returns

Final state of the accumulator

Examples

Sum of values
const state = create({
  first: 1,
  second: 2,
  third: 3
})

const sum = state.reduce((sum, item) => sum + item.compute())
sum // → 6

Listeners

state.on([eventName], fn)

Register a new listener for an event.

Arguments

Returns

A listener object where you can turn off the event listening later.

Examples

Data events
const state = create({
  nested: {
    first: 1
  }
})

const events = []
state.get('nested').on(val => {
  events.push(`nested-${val}`)
})

state.get('nested').set('value')
events // → [ 'nested-set' ]

state.get('nested').set({ second: 2 })
events // → [ 'nested-set', 'nested-add-key' ]

// reset events array
events.length = 0

state.get([ 'nested', 'second' ]).on(val => {
  events.push(`second-${val}`)
})

state.get('nested').set({ first: null })
events // → [ 'nested-remove-key' ]

state.get('nested').set(null)
events // → [ 'nested-remove-key', 'nested-remove', 'second-remove' ]

state.serialize() // → {}
Other events
const state = create({
  key: 'value'
})

const events = []
state.get('key').on('anEvent', val => {
  events.push(val)
})

// different path listener will not fire
state.emit('anEvent', 'aValue')
events // → []

// same path listener will fire
state.get('key').emit('anEvent', 'aValue')
events // → [ 'aValue' ]
Data events on references
const state = create({
  nested: {
    first: 1
  },
  pointer: [ '@', 'nested' ]
})

const events = []
state.get('pointer').on(val => {
  events.push(`pointer-${val}`)
})

state.get('nested').set('value')
events // → [ 'pointer-set' ]

state.get('nested').set({ second: 2 })
events // → [ 'pointer-set', 'pointer-add-key' ]

// reset events array
events.length = 0

state.get('nested').set({ first: null })
events // → [ 'pointer-remove-key' ]

state.get('nested').set(null)
events // → [ 'pointer-remove-key', 'pointer-remove' ]

// notice that reference at pointer is also removed with the referred
state.serialize() // → { pointer: {} }
Other events on references
const state = create({
  key: 'value',
  pointer: [ '@', 'key' ]
})

const events = []
state.get('pointer').on('anEvent', val => {
  events.push(val)
})

state.get('key').emit('anEvent', 'aValue')
events // → [ 'aValue' ]

state.get('pointer').emit('anEvent', 'anotherValue')
events // → [ 'aValue', 'anotherValue' ]

listener.off()

Turns event listener off so it will not be fired for the following events emitted.

Examples

Data events
const state = create({
  key: 'value'
})

const events = []
const listener = state.get('key').on('data', val => {
  events.push(`key-${val}`)
})

state.get('key').set('updated')
events // → [ 'key-set' ]

listener.off()

// not fired for second time
state.get('key').set('again')
events // → [ 'key-set' ]
Other events
const state = create({
  key: 'value'
})

const events = []
const listener = state.get('key').on('anEvent', val => {
  events.push(val)
})

state.get('key').emit('anEvent', 'aValue')
events // → [ 'aValue' ]

listener.off()

// not fired for the second time
state.get('key').emit('anEvent', 'aValue')
events // → [ 'aValue' ]

Subscription

Subscriptions are necessary to transfer data over the network. Only the necessary parts of your state will be transferred to the clients, depending on your subscriptions.

state.subscribe([options], fn)

Create a new subscription on a state.

Arguments

Returns

A subscription object where you can unsubscribe later.

Examples

Subscribe without options

Data updates will fire subscriptions on references as well.

const state = create({
  first: {
    p1: [ '@', 'second' ]
  },
  second: {
    p2: [ '@', 'third' ]
  },
  third: {
    id: 3
  }
})

const fires = []
state.get('first').subscribe(first => fires.push('first'))
state.get('second').subscribe(second => fires.push('second'))
state.get('third').subscribe(third => fires.push('third'))

// all fired once for existing values
fires // → [ 'first', 'second', 'third' ]

// empty fires
fires.length = 0

// update third
state.get([ 'third', 'id' ]).set('three')

// all fired for the update
fires // → [ 'third', 'second', 'first' ]
Subscribe with depth option
const state = create({
  first: {
    p1: [ '@', 'second' ]
  },
  second: {
    p2: [ '@', 'third' ]
  },
  third: {
    id: 3
  }
})

const fires = []
state.get('first').subscribe(
  { depth: 2 }, first => fires.push('first')
)
state.get('second').subscribe(
  { depth: 2 }, second => fires.push('second')
)

// both fired once for existing values
fires // → [ 'first', 'second' ]

// empty fires
fires.length = 0

// update third
state.get([ 'third', 'id' ]).set('three')

// only second fired for the update
fires // → [ 'second' ]
Subscribe with keys option
const state = create({
  first: {
    id: 1
  },
  second: {
    id: 2
  },
  third: {
    id: 3
  }
})

const fires = []
state.subscribe(
  { keys: [ 'first', 'third' ] },
  state => fires.push('first or third')
)
state.subscribe(
  {
    excludeKeys: [ 'first' ]
  },
  state => fires.push('second or third')
)

// both fired once for existing values
fires // → [ 'first or third', 'second or third' ]

// empty fires
fires.length = 0

// update second
state.set({
  second: {
    id: 'two'
  }
})

// only 'second or third' fired for the update
fires // → [ 'second or third' ]

subscription.unsubscribe()

Turns subscription off so it will not be fired for the following data updates.

Example

const state = create({
  first: {
    p1: [ '@', 'second' ]
  },
  second: {
    id: 2
  }
})

const fires = []
const sub1 = state.get('first').subscribe(
  first => fires.push('first')
)
const sub2 = state.get('second').subscribe(
  second => fires.push('second')
)

// both fired once for existing values
fires // → [ 'first', 'second' ]

// unsubscribe second
sub2.unsubscribe()

// empty fires
fires.length = 0

// update second
state.get([ 'second', 'id' ]).set('wwo')

// second did not fire after unsubscribe
fires // → [ 'first' ]

Persistency

Persistency is supported through plugins, so anyone can create a new plugin to persist any state in any database or storage. There is only one persistency plugin implemented so far and it supports RocksDB.

Persistency plugins are utilized by a createPersist method replacing the non-persistent create method.

async createPersist([value], persistencyInstance)

Creates a new master state or creates a new branch from an existing master state.

Arguments

Returns

Root of newly created state.

Example

Persistency with RocksDB
const { createPersist } = require('stx')
const { PersistRocksDB } = require('stx-persist-rocksdb')

createPersist(
  {
    // initial state
  },
  new PersistRocksDB('database_file_name')
)
  .then(master => {
    // master state is loaded from disk
    // and ready to use
    // every update to master state will be
    // automatically saved to the database
  })

Network

state.listen(port)

Creates a websocket server listening the given port.

Arguments

Returns

A server object where you can stop listening the port later.

Example

const state = create()

// Listen port 7171
state.listen(7171)

server.close()

Stops listening the port for a graceful exit.

Example

const state = create()

// Listen port 7171
const server = state.listen(7171)

// Stop listenin the port
server.close()

state.connect(url)

Creates a websocket client and connects to the url of a websocket server.

Arguments

Returns

A client object where you can close the connection later.

Example

const state = create()

// Connect to the server
state.connect('ws://localhost:7171')

client.socket.close()

Closes the connection to the server.

Example

const state = create()

// Connect to the server
const client = state.connect('ws://localhost:7171')

// Close the connection once connected
state.on('connected', val => {
  // when connected val is true
  // when disconnected val is false
  if (val) {
    client.socket.close()
  }
})

Example server and client working together

Server

const state = create({
  items: {
    i1: {
      id: 1
    },
    i2: 2
  }
})
const server = state.listen(7171)
state.on('log', line => {
  line // → Hello!
  server.close()
})

Client

const state = create()
const client = state.connect('ws://localhost:7171')
state.get('items', {}).subscribe(
  { depth: 1 },
  items => {
    if (items.get('i2')) {
      items.serialize() // → { i1: {}, i2: 2 }
      state.emit('log', 'Hello!')
      client.socket.close()
    }
  }
)

state.switchBranch(branchKey)

When this method is called with a branch key in the client, server.switchBranch method will be fired on the server and client will get into sync with returned branch on the server.

Arguments

Example

const state = create()

// Connect to the server
state.connect('ws://localhost:7171')

state.on('connected', val => {
  if (val) {
    state.switchBranch('branchKey')
  }
})

async server.switchBranch(masterRoot, branchKey, async switcher)

This method should be implemented on server to define branch switching logic. If this method is not implemented on the server, state.switchBranch method on client will not do anything.

Arguments

These arguments will be passed by stx to the implemented method.

Examples

Create branch with incoming key

This is the most simple approach for creating a branch from master state. It is using the branch key as received from the client without any validation or translation.

const state = create()

// Listen port 7171
const server = state.listen(7171)

server.switchBranch = async (_, branchKey, switcher)
  => switcher(branchKey)
Create branch with translated key

If the client sends a username/password pair, a social media token or any other kind of claimed identifier, this is the function where it has to be verified and translated.

const state = create()

// Listen port 7171
const server = state.listen(7171)

server.switchBranch = async (_, branchKey, switcher) => {
  // derive a validated / translated branchKey
  // using the branchKey recieved from client
  const derivedBranchKey = // ....
  switcher(derivedBranchKey)
}

master.branch.newBranchMiddleware(newBranch)

Whenever a branch is created from a state, stx will call a middleware method if it is implemented. This middleware is useful to add listeners to every branch of a master state on the server.

Arguments

These arguments will be passed by stx to the implemented method.

Example

Updating read count of articles
const master = create({
  articles: {
    articleA: {
      title: 'Article A',
      readCount: 0
    },
    articleB: {
      title: 'Article B',
      readCount: 0
    }
  }
})

const incrementRead = (articleId, _, branchArticles) => {
  const readCount = master.get(
    ['articles', articleId, 'readCount']
  )

  if (readCount) {
    const isRead = branchArticles.get(
      [articleId, 'read'], false
    )

    if (!isRead.compute()) {
      isRead.set(true)
      readCount.set(readCount.compute() + 1)
    }
  }
}

master.branch.newBranchMiddleware = newBranch => {
  newBranch.get('articles').on('read', incrementRead)
}

newBranch.branch.clientCanUpdate

By default, updates to state on client are not sycnhronised back to server as it would be a security threat. It is possible to whitelist some certain paths of state on the server, so client can update directly. When a path is whitelisted by server as clientCanUpdate, that path still has to be already generated in the server. Client can only set a value to an existing path, creating new paths is not allowed and should be implemented in other ways.

This is an Array of whitelisted paths by the server. Each item on the array should be an Object with the following schema.

Schema of every item on clientCanUpdate

Example

Updating favourite count of articles
const master = create({
  articles: {
    articleA: {
      title: 'Article A',
      favourite: false,
      favCount: 0
    },
    articleB: {
      title: 'Article B',
      favourite: false,
      favCount: 0
    }
  }
})

const updateFavCount = favourite => {
  const favCount = master.get(
    favourite.parent().path()
  ).get('favCount')

  if (favourite.compute()) {
    favCount.set(favCount.compute() + 1)
  } else {
    favCount.set(favCount.compute() - 1)
  }
}

master.branch.newBranchMiddleware = newBranch => {
  newBranch.branch.clientCanUpdate = [
    {
      path: ['articles', '*', 'favourite'],
      after: updateFavCount
    }
  ]
}