Created
August 28, 2019 13:00
-
-
Save leonardpauli/d219a6c71eea60eea57ac4aa93b27331 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
https://gitter.im/amark/gun?at=5d667bcec8228962acdbbe3f