Skip to content

Instantly share code, notes, and snippets.

@leonardpauli
Created August 28, 2019 13:00
Show Gist options
  • Save leonardpauli/d219a6c71eea60eea57ac4aa93b27331 to your computer and use it in GitHub Desktop.
Save leonardpauli/d219a6c71eea60eea57ac4aa93b27331 to your computer and use it in GitHub Desktop.
// own.rim.db.gundb-graphdb-api-notes.js
// JavaScript based graph-DB module developer user guide wishes
// created by Leonard Pauli, 28 aug 2019
//
/* includes:
install/access module
(add, remove, change, traverse) values
get current value
simple data structures (set)
(subscribe, unsubscribe) to changes
users (signin, signout, rename, change pass)
permissions (private, public read, public read-write)
permissions (add readers, add writers, permission inheritance/propagation in graph)
storage solutions (at lead one somewhat simple to setup + stable + somewhat production ready)
*/
/* Step 1: Get a reference to MyDBModule
in browser, option 1, manual:
- include the source:
option 1, use a CDN:
- find a relevant url to the pre-bundled source file:
- regiser it globally with a script tag
<script src="https://some-cdn.com/some/path/mydbmodule.min.js"></script>
option 2, self hosted:
- donwload the pre-bundled source file
- put it along your static files
- regiser it globally with a script tag
<script src="/some/path/mydbmodule.min.js"></script>
- access it
option 1, globally:
const MyDBModule = (window || global).MyDBModule
option 2, using a module system:
// UMD, combing support for AMD (RequireJS), CommonJS (Browserify), and global
// see https://www.davidbcalhoun.com/2014/what-is-amd-commonjs-and-umd/
in browser, option 2, using bundler:
- install a bundler (eg. webpack, usually comes with a starter project,
eg. when using vue or react)
- install the package
- option 1, npm:
npm install mydbmodule // also includes typescript + flow typings as sub-packages
- access it: // depending on your environment
- option 1, require:
const MyDBModule = require('mydbmodule')
- option 2, es6 import:
import MyDBModule from 'mydbmodule'
in nodejs, option 1:
- like "in browser, option 2", except you don't need the bundler
*/
const MyDBModule = require('mydbmodule')
const {DB, LocalStorage, DataNode, DataPermission, LocalSync, WebSocketSync} = MyDBModule
// (put your code in an async function so we can use await)
const main = async ()=> {
// Step 2: instantiate a DB object / "DB node"
// (to this, you may add plugins, storage engines, etc)
const db = new DB()
// Step 3: add a storage engine (optional)
// adding it allows for data persistance and possibly
// handeling larger amounts of data
db.storage = new LocalStorage()
// Step 4: get a reference to your applications corner
// to avoid collisions when syncing multiple apps data
// db.root is the "root data node", containing all other "data nodes"
const myAppNamespace = 'com.example.app'
const app = db.root.get(myAppNamespace)
// .once, .put, notes:
{
// Step 5: initialize your corner
// a DataNode represents a "node" in the "data graph",
// from with you can travel to other nodes using .get(linkName)
const nameDataNode = app.get('name')
// dataNode.once(options) will retrieve it's value once
// it will simultaneously ask the network engine, storage engine, and cache
// it will then resolve the values fetched within the limits
// resolve it to the cache and storage (if there are any conflicts), and return
// default limits:
db.storage.once.timeout = 0.1 // in seconds
db.network.once.timeout = 0.1
db.network.once.maxNrOfPeersToAsk = 3
db.network.once.enabled = true
// may be overwritten, to eg. exclude the network:
const name = await nameDataNode.once({network: {enabled: false}})
// a value is always null, string, bool, number, or another DataNode, cannot be undefined
if (name===null) {
// first time
}
// dataNode.put(value, options) will update the associated value
// it has the same kind of options as once, default:
db.storage.put.timeout = 7.0
db.network.put.timeout = 7.0
db.network.put.minNrOfPeersToConfirm = Math.min(3, db.network.peers.length)
db.network.put.enabled = true
await nameDataNode.put('My Awesome App', {network: {minNrOfPeersToConfirm: 0}})
// a node cannot be deleted, only garbage collected (by removing all references to it)
// the same goes for a value, this is done by resetting it to null
// (why: to allow syncing + resolving it's deletion with other peers)
// nodes with null values will be garbage collected / auto deleted when
// it's deletion has been synced to enough peers and enough time has passed
// according to default settings:
const day = 60*60*24
db.storage.delete.garbageCollect.period = 7*day
db.storage.delete.garbageCollect.minNrOfPeersToConfirm = Math.min(3, db.network.peers.length)
await nameDataNode.put(null)
// to remove it immediately, eg. during testing on a local system
// (otherwise risk getting it back from other peer)
await nameDataNode.put(null, {garbageCollect: {period: 0, minNrOfPeersToConfirm: 0}})
// or the shorthand:
await nameDataNode.put(null, {garbageCollect: {immediately: true}})
// now, let's put the value back, it will update the cache directly, thus,
// we don't need to await it if we don't care about waiting for the
// storage persistance or network propatation
nameDataNode.put('My Awesome App')
}
// get, once, put, in short:
{
const nameDataNode = app.get('name')
let name = await nameDataNode.once() // name === null, first time
await nameDataNode.put('new name') // wait for storage + network propagation
nameDataNode.put('next name') // don't wait, it will still propagate in the background
.catch(console.error) // log if it fails at a later moment
name = await nameDataNode.once() // name === 'next name'
nameDataNode.put(null) // eventually delete it
name = await nameDataNode.once() // name === null
}
// value types, in short:
{
app.get('invalidValue').put(undefined)
.catch(console.log) // will log ".get('invalidValue').put: (undefined) is an invalid DataNode value"
app.get('nullValue').put(null)
app.get('stringValue').put('text')
app.get('boolValue').put(true)
app.get('numberValue').put(5.2)
let dataNode = new DataNode()
dataNode.get('age').put(5)
dataNode.get('name').put('anna')
app.get('nodeValue').put(dataNode)
// or
const node = app.get('nodeValue')
dataNode = new DataNode({age: 5, name: 'anna'})
node.put(dataNode)
// or
node.put({age: 5, name: 'anna'})
// notice how this will replace all of nodeValue
// if you just want to change a subfield, use .get or .patch, eg:
node.put({age: 5, name: 'anna'})
await node.get('name').once() // == 'anna'
node.put({age: 7}) // override the full node
await node.get('name').once() // == null
node.put({age: 5, name: 'anna'})
await node.get('name').once() // == 'anna'
node.get('age').put(7)
await node.get('name').once() // == 'anna'
// or
node.patch({age: 7}) // iteratively does .get and .put for subfields
await node.get('name').once() // == 'anna'
// or for deep nodes
app.patchDeep({nodeValue: {age: 7}})
await node.get('name').once() // == 'anna'
// app.patch({nodeValue: {age: 7}}) would have treated {age: 7} as a DataNode,
// and overriden the old one, patchDeep treats all objects not marked DataNode
// as pure objects, recursively
}
// .get traversal note:
{
// creating a deep chain will create unique DataNode representations in memory,
// without writing/reading anything from disk or network
const a = app.get('some').get('deep')
const b = app.get('some').get('deep')
a === b
a.id === undefined
// it's first when you acctually .once or .put as the read/write is performed,
// and the DataNode chain gets their ids pupulated
const value = await app.get('some').get('deep').get('path').once()
if (value) {
a.id !== undefined
} else {
// undefined if path not existed before, defined if it's been deleted
// but not garbage collected (hints at the importance to keep "deleted" nodes
// in the system until propagation ensured)
a.id
}
app.get('some').get('deep').get('path').put(5)
a.id !== undefined // on put, the path is enforced
// Note: this may lead to issues with inconsistent data models:
app.get('parent').put('text')
await app.get('parent').once() // == 'text'
app.get('parent').get('field').put('other text')
await app.get('parent').once() // == DataNode({field: 'other text'})
// 'text' value overriden with a DataNode
// Sidenote / suggestion:
// I would propose treating the "links" and values separate, eg.
// DataNode = {value: primitiveValue, links: HashMap{String -> DataNode}}
// This would allow storing values in all nodes, not just the leaves
// .once would always return the value, and .get would always traverse the links
// (However, that is not how it is currently implemented)
//
// (I would also like to sign my app-namespace root node to prevent other apps to
// inadvertently use it inexplicitly, this can be done as it is, but developer-friendly
// syntax around it to encurage sanitary behaviour)
}
// subscribe, fields, map, notes:
{
// default:
// get at most 1 update 60 times a second
// it will debounce with same duration
// eg. recieve update -> start timeout for duration
// -> possibly recieve more updates -> use latest value after duration
db.subscribe.debouncedThrottleInterval = 1/60
// send cached value or null if no other value
// is sent from start of subscription until timeout
db.subscribe.initialValueTimeout = false // false or seconds as float >= 0
let unsubscribe = app.get('dataNode').subscribe((newValue, dataNode, unsubscribe)=> {
// if (newValue === null) unsubscribe()
// send any value almost immediately, then at most one every 100ms
}, {debouncedThrottleInterval: 0.1, initialValueTimeout: 0})
unsubscribe() // to stop subscription
// .fields()
// returns a DataNodeFields object, wrapper around DataNode
// thus, has many of the same methods, like once and subscribe
app.get('dataNode').put({age: 7, name: 'anna', friend: {name: 'erik'}})
let obj = await app.get('dataNode').fields().once() // == {age: 7, name: 'anna', friend: DataNode(null, 'some-id')}
obj.name === 'anna'
obj.name.fields === undefined
obj.friend.name === undefined // .friend is a DataNode
obj = await obj.friend.fields().once()
obj.name == 'erik'
// won't be called, as we only update a subfield, not the node value itself
unsubscribe = app.get('dataNode').subscribe(newDataNode=> {})
await app.get('dataNode').get('age').put(9)
unsubscribe()
// solved by subscribing to specific fields instead (or just .fields() for all)
unsubscribe = app.get('dataNode').fields(['age', 'name']).subscribe(newValuesObj=> {
newValuesObj.age === 8
newValuesObj.name === 'anna'
})
await app.get('dataNode').get('age').put(8)
unsubscribe()
// use map to transform values in an "observable chain"
// the .map(fn) inner function will not be invoked if there are no subscribers
// and only once per change even if there are multiple subscribers
const observable = app.get('dataNode').get('name')
.map(value=> `Hello ${name}`)
.map(value=> value+'!')
await observable.once() // == 'Hello anna!'
let unsubscribe1 = observable.subscribe(value=> console.log(1, value)) // 'Hello Anna!', 'Hello Maria!'
let unsubscribe2 = observable.subscribe(value=> console.log(2, value)) // 'Hello Anna!', 'Hello Maria!'
await app.get('dataNode').get('name').put('Erik') // not shown because value changed afterwards within default debunce duration
await app.get('dataNode').get('name').put('Anna')
setTimeout(async ()=> {
await app.get('dataNode').get('name').put('Maria')
unsubscribe1()
unsubscribe2()
}, 200) // to get around debounce delay
}
// arrays/lists/sets notes:
{
// Because of the "decentralized nature" of the graph db, usual arrays becomes problematic
// A usefult datastructure primitive then is sets:
// A node with unordered links to its elements, where the link name is the unique id of the element
// To remove an element, we just resets its link value
// const add = function add(dataNodeOrObject, options) {
// const parentDataNode = this
// const dataNode = new DataNode(dataNodeOrObject)
// return parentDataNode.get(dataNode.id).put(dataNode, options)
// }
// const items = function items() {
// const parentDataNode = this
// return parentDataNode.fields().map(fields=> {
// const values = Object.keys(fields).map(k=> fields[k])
// const items = values.filter(v=> v instanceof DataNode)
// return items
// })
// }
// await and options is optional, as with get
await app.get('fruits').add({name: 'apple'}, {})
app.get('fruits').add({name: 'pear'})
app.get('fruits').add({name: 'orange'})
const extractName = async item=> ({dataNode: item, name: await item.get('name').once()})
const extractNames = items=> Promise.all(items.map(extractName))
const dataNodesWithNameLoaded = app.get('fruits').items().map(extractNames)
const asString = items=> items.map(({name})=> name).join(',')
const unsubscribe = dataNodesWithNameLoaded.map(asString).subscribe(value=> {
console.log(value) // apple, orange, pear (any order); then: apple, orange (any order)
}, {initialValueTimeout: 0})
// find and remove
let items = await dataNodesWithNameLoaded.once()
let pearDataNode = items.find(({name})=> name=='pear').dataNode
await app.get('fruits').remove(pearDataNode)
unsubscribe()
}
// accounts part
{
db.user.current() // always set, defaults to a guest user
db.user.withUsername(username) // like get, so await + opt if you like, though returns DataNodeUser wrapper
db.user.withPubkey(pubkey)
// creates if unexisting, errors if existing and password not match, else sets as current
await db.user.withUsername(username).signin(password, {
// default options:
expires: false, // or Date()*1 + 1000*60*60*24*7
storeToken: async token=> localStorage.token = token,
retrieveToken: async ()=> localStorage.token,
})
db.user.current().pubkey
await db.user.current().changePassword(oldPassword, newPassword)
db.user.current().addUsernameAlias(username)
db.user.current().removeUsernameAlias(username) // will be garbageCollected after ~1mo?
await db.user.signout() // resets unencrypted data + resets current user to a new guest user
// Just like db.root, user has a node data root.
// All data stored there is by default readonly by others and write only by its user
// namespace it just like with db.root
const userApp = db.user.current().root.get(myAppNamespace)
userApp.get('profile').patch({name: 'Anna'})
await db.pendingWrites()
db.user.signout()
await userApp.get('profile').get('name').once() // Anna
await userApp.get('profile').get('name').put('Erik')
.catch(console.log) // error: unauthorized write attempt
await db.user.withUsername(username).signin(password)
userApp.get('profile').get('name').once() // Anna
userApp.get('profile').get('name').put('Erik') // ok
// permission roles
// default for user root: public read, private write
let permission = db.user.current().root.permission()
permission.get('inherits').items() // []
permission.get('defaultReadOpen') // true
permission.get('defaultWriteOpen') // false
permission.get('writers').items() // [user.current()]
userApp.permission().get('inherits').items() // [(parent = db.user.current().root).permission()]
permission = await userApp.get('profile').permission().once() // returns DataPermission NodeData wrapper
permission.get('inherits').items() // [parent.permission()] // auto update when adding/removing nodes
permission.get('defaultReadOpen') // true
permission.get('defaultWriteOpen') // false
permission.get('writers').items() // [user.current()]
// will traverse .inherits items recursively + reload them, then update defaultReadOpen,
// etc to match the most open variant
await permission.resolve() // usually not needed, only if part of graph where permissions became more open changed
const guestbook = userApp.get('profile').get('guestbook')
permission = new DataPermission()
permission.defaultReadOpen = true
permission.defaultWriteOpen = true
guestbook.putPermission(permission)
// a permission node is just like any other NodeData node, so it can be shared
userApp.get('profile').get('guestbook2')
.putPermission(guestbook.permission())
// now, modifying guestbook permission will also affect guestbook2
// (though not if totally changed with putPermission)
const post = new NodeData({type: 'blogpost', title: 'Today'})
userApp.get('profile').get('posts').add(post)
const collaborator = await db.user.withUsername('somename')
post.permission().addWriter(collaborator)
// this will make permission not just inherit from parents, thus, first time, it will instantiate
// a new permission node, generate a private/public key-pair, add it to both users permissions inbox (with links),
// root.get('permissionsInbox') // inbox: a public append but private read structure
// and re-encrypt all sibling data with inherited permission with the new key
// when user tries to read it, it will be unencrypted with matching keypair in users permissionsInbox
}
// networking
{
const db2 = new DB()
db2.network = new LocalSync()
// db2.network.peers.add(new LocalSync.Peer(db)) // adding it before or after subscribe doesn't matter
const unsubscribe = db2.root.get(myAppNamespace).get('name').subscribe(value=> console.log(value))
db2.network.peers.add(new LocalSync.Peer(db)) // it will start syncing subscribed nodes
unsubscribe()
db.network = WebSocketSync()
db.network.peers.add(new WebSocketSync.Peer('wss://example.com/my-db-endpoint'))
// available in server environment
db.network.startServer({
publicUrlDomain: 'other-example.com', // used to tell peers peers about itself
publicUrlPath: '/my-db-endpoint',
certs: {
private: '',
public: '',
}
})
}
}
// start our program, and log if something failed
main().catch(console.error)
@leonardpauli
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment