@mblaney/holster 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/node.js.yml +31 -0
- package/LICENSE.md +55 -0
- package/README.md +24 -0
- package/build/holster.js +4 -0
- package/build/holster.js.map +7 -0
- package/examples/index.html +33 -0
- package/examples/index.js +49 -0
- package/examples/user.html +31 -0
- package/examples/user.js +88 -0
- package/examples/wire.html +27 -0
- package/examples/wire.js +55 -0
- package/package.json +44 -0
- package/src/array.js +34 -0
- package/src/buffer.js +83 -0
- package/src/dup.js +23 -0
- package/src/get.js +19 -0
- package/src/ham.js +151 -0
- package/src/holster.js +502 -0
- package/src/index.js +21 -0
- package/src/radisk.js +438 -0
- package/src/radix.js +106 -0
- package/src/sea-utils.js +65 -0
- package/src/sea.js +224 -0
- package/src/store.js +225 -0
- package/src/user.js +330 -0
- package/src/utils.js +124 -0
- package/src/wire.js +290 -0
- package/test/holster.get.test.js +60 -0
- package/test/holster.lex.test.js +196 -0
- package/test/holster.off.test.js +53 -0
- package/test/holster.on.test.js +168 -0
- package/test/holster.put.test.js +278 -0
- package/test/holster.secure.test.js +210 -0
- package/test/holster.user.get.test.js +86 -0
- package/test/holster.user.lex.test.js +148 -0
- package/test/holster.user.off.test.js +72 -0
- package/test/holster.user.on.test.js +185 -0
- package/test/holster.user.put.test.js +381 -0
- package/test/kitty/! +9 -0
- package/test/names.js +225 -0
- package/test/radisk.test.js +191 -0
- package/test/radix.test.js +117 -0
- package/test/sea.test.js +284 -0
- package/test/store.test.js +316 -0
- package/test/user.test.js +173 -0
- package/test/wire.test.js +159 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>Holster.user - test examples</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<h2>Holster.user - test examples</h2>
|
|
9
|
+
<p>
|
|
10
|
+
This file loads <code>user.js</code> which contains examples that test the
|
|
11
|
+
Holster.user API. See the console log for test output.
|
|
12
|
+
</p>
|
|
13
|
+
<p>
|
|
14
|
+
The tests create two users, Alice and Bob. They both write data to their
|
|
15
|
+
own user space. Bob writes secret data that can only be read by Alice,
|
|
16
|
+
then Alice logs in again to read the message.
|
|
17
|
+
</p>
|
|
18
|
+
<p>
|
|
19
|
+
These tests also show how to use the SEA API (Security, Encryption, and
|
|
20
|
+
Authorization), which can be loaded on it's own, but it made available via
|
|
21
|
+
holster.SEA. The holster.user API uses the SEA API internally for creating
|
|
22
|
+
and authenticating users.
|
|
23
|
+
</p>
|
|
24
|
+
<p>
|
|
25
|
+
The <code>user.js</code> file is loaded as a module, which allows it to
|
|
26
|
+
import the development build of Holster. You can switch to the production
|
|
27
|
+
build by changing the import path at the top of the file.
|
|
28
|
+
</p>
|
|
29
|
+
<script type="module" src="./user.js"></script>
|
|
30
|
+
</body>
|
|
31
|
+
</html>
|
package/examples/user.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Switch src to build for the production version.
|
|
2
|
+
import Holster from "../src/holster.js"
|
|
3
|
+
|
|
4
|
+
const holster = Holster({indexedDB: true})
|
|
5
|
+
globalThis.holster = holster
|
|
6
|
+
|
|
7
|
+
const user = holster.user()
|
|
8
|
+
|
|
9
|
+
let alice, bob
|
|
10
|
+
// Wait for the websocket to connect.
|
|
11
|
+
setTimeout(() => {
|
|
12
|
+
user.create("alice", "password", err => {
|
|
13
|
+
if (err !== "Username already exists") {
|
|
14
|
+
console.log(err)
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
user.auth("alice", "password", err => {
|
|
19
|
+
if (err) {
|
|
20
|
+
console.log(err)
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Store Alice's encryption key so Bob can use it to create shared data.
|
|
25
|
+
alice = {epub: user.is.epub}
|
|
26
|
+
console.log("Alice logged in.", user.is)
|
|
27
|
+
|
|
28
|
+
const data = "Success! Only Alice can put data on their account"
|
|
29
|
+
user.get("protected").put(data, err => {
|
|
30
|
+
if (err) {
|
|
31
|
+
console.log(err)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
user.get("protected", console.log)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
}, 1000)
|
|
40
|
+
|
|
41
|
+
setTimeout(() => {
|
|
42
|
+
user.create("bob", "stronger password", err => {
|
|
43
|
+
if (err !== "Username already exists") {
|
|
44
|
+
console.log(err)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
user.auth("bob", "stronger password", async err => {
|
|
49
|
+
if (err) {
|
|
50
|
+
console.log(err)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Store Bob's public keys so Alice can use them to read shared data.
|
|
55
|
+
bob = {pub: user.is.pub, epub: user.is.epub}
|
|
56
|
+
console.log("Bob logged in.", user.is)
|
|
57
|
+
|
|
58
|
+
// For this test let's create shared private data for Alice.
|
|
59
|
+
const secret = await holster.SEA.secret(alice, user.is)
|
|
60
|
+
const shared = await holster.SEA.encrypt("Hello Alice!", secret)
|
|
61
|
+
user.get("shared").put(shared, err => {
|
|
62
|
+
if (err) {
|
|
63
|
+
console.log(err)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log("Success! Bob wrote secret data to his account.")
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
}, 2000)
|
|
72
|
+
|
|
73
|
+
setTimeout(() => {
|
|
74
|
+
// Use Bob's public key to fetch shared data under his account.
|
|
75
|
+
holster.user(bob.pub).get("shared", enc => {
|
|
76
|
+
user.auth("alice", "password", async err => {
|
|
77
|
+
if (err) {
|
|
78
|
+
console.log(err)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log("Alice logged in again.", user.is)
|
|
83
|
+
const secret = await holster.SEA.secret(bob, user.is)
|
|
84
|
+
const shared = await holster.SEA.decrypt(enc, secret)
|
|
85
|
+
console.log("Alice's shared data from Bob:", shared)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
}, 3000)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>Holster.wire - test examples</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<h2>Holster.wire - test examples</h2>
|
|
9
|
+
<p>
|
|
10
|
+
This file loads <code>wire.js</code> which contains examples that use the
|
|
11
|
+
Holster.wire API. These examples show how conflict resolution works, see
|
|
12
|
+
the console log for test output.
|
|
13
|
+
</p>
|
|
14
|
+
<p>
|
|
15
|
+
These tests also make two updates in random order, but the wire API can
|
|
16
|
+
set state vectors on updates to control which value should be applied.
|
|
17
|
+
This means when the "species" field is logged it will always be the same
|
|
18
|
+
no matter which order the tests run in.
|
|
19
|
+
</p>
|
|
20
|
+
<p>
|
|
21
|
+
The <code>wire.js</code> file is loaded as a module, which allows it to
|
|
22
|
+
import the development build of Holster. You can switch to the production
|
|
23
|
+
build by changing the import path at the top of the file.
|
|
24
|
+
</p>
|
|
25
|
+
<script type="module" src="./wire.js"></script>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
package/examples/wire.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Switch src to build for the production version.
|
|
2
|
+
import Holster from "../src/holster.js"
|
|
3
|
+
|
|
4
|
+
// Set readFromDisk to true to force reading from disk.
|
|
5
|
+
// (Restart the server so that graph is not in memory.)
|
|
6
|
+
const readFromDisk = false
|
|
7
|
+
|
|
8
|
+
const holster = Holster({indexedDB: !readFromDisk})
|
|
9
|
+
globalThis.holster = holster
|
|
10
|
+
|
|
11
|
+
if (readFromDisk) {
|
|
12
|
+
// Wait for the websocket to connect.
|
|
13
|
+
setTimeout(() => {
|
|
14
|
+
holster.wire.get({"#": "FDSA", ".": "species"}, msg => {
|
|
15
|
+
console.log("read from disk:", msg)
|
|
16
|
+
})
|
|
17
|
+
}, 1000)
|
|
18
|
+
} else {
|
|
19
|
+
// Create two updates that could happen in either order.
|
|
20
|
+
setTimeout(() => {
|
|
21
|
+
const update1 = {
|
|
22
|
+
ASDF: {
|
|
23
|
+
_: {"#": "ASDF", ">": {name: 2, boss: 2}},
|
|
24
|
+
name: "Mark Nadal",
|
|
25
|
+
boss: {"#": "FDSA"},
|
|
26
|
+
},
|
|
27
|
+
FDSA: {
|
|
28
|
+
_: {"#": "FDSA", ">": {name: 2, species: 2, slave: 2}},
|
|
29
|
+
name: "Fluffy",
|
|
30
|
+
species: "a kitty",
|
|
31
|
+
slave: {"#": "ASDF"},
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
holster.wire.put(update1, msg => console.log("update1:", msg))
|
|
35
|
+
}, 1000 * Math.random())
|
|
36
|
+
|
|
37
|
+
setTimeout(() => {
|
|
38
|
+
const update2 = {
|
|
39
|
+
ASDF: {_: {"#": "ASDF", ">": {name: 1}}, name: "Mark"},
|
|
40
|
+
FDSA: {
|
|
41
|
+
_: {"#": "FDSA", ">": {species: 2, color: 3}},
|
|
42
|
+
species: "felis silvestris",
|
|
43
|
+
color: "ginger",
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
holster.wire.put(update2, msg => console.log("update2:", msg))
|
|
47
|
+
}, 1000 * Math.random())
|
|
48
|
+
|
|
49
|
+
// They should always produce the same result no matter which order.
|
|
50
|
+
setTimeout(() => {
|
|
51
|
+
holster.wire.get({"#": "FDSA", ".": "species"}, msg => {
|
|
52
|
+
console.log("get:", msg)
|
|
53
|
+
})
|
|
54
|
+
}, 2000)
|
|
55
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mblaney/holster",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Holster is a service that allows synchronising data between devices using Node, Deno, Bun or the browser.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --test",
|
|
9
|
+
"build": "esbuild src/holster.js --bundle --format=esm --sourcemap \"--external:node:*\" \"--external:ws\" --minify --outdir=build"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [],
|
|
12
|
+
"author": "Malcolm Blaney",
|
|
13
|
+
"license": "Zlib OR MIT OR Apache-2.0",
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"express": "^5.1.0",
|
|
16
|
+
"fs": "^0.0.1-security",
|
|
17
|
+
"ws": "^8.18.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"esbuild": "0.25.3",
|
|
21
|
+
"mock-socket": "^9.3.1",
|
|
22
|
+
"prettier": "^3.4.2"
|
|
23
|
+
},
|
|
24
|
+
"prettier": {
|
|
25
|
+
"semi": false,
|
|
26
|
+
"bracketSpacing": false,
|
|
27
|
+
"arrowParens": "avoid"
|
|
28
|
+
},
|
|
29
|
+
"browser": {
|
|
30
|
+
"fs": false
|
|
31
|
+
},
|
|
32
|
+
"directories": {
|
|
33
|
+
"example": "examples",
|
|
34
|
+
"test": "test"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/mblaney/holster.git"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/mblaney/holster/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/mblaney/holster/wiki"
|
|
44
|
+
}
|
package/src/array.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
if (typeof btoa === "undefined") {
|
|
2
|
+
globalThis.btoa = data => Buffer.from(data, "binary").toString("base64")
|
|
3
|
+
globalThis.atob = data => Buffer.from(data, "base64").toString("binary")
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// This is Array extended to have .toString(["utf8"|"hex"|"base64"])
|
|
7
|
+
function SeaArray() {}
|
|
8
|
+
|
|
9
|
+
Object.assign(SeaArray, {from: Array.from})
|
|
10
|
+
SeaArray.prototype = Object.create(Array.prototype)
|
|
11
|
+
SeaArray.prototype.toString = function (enc, start, end) {
|
|
12
|
+
if (!enc) enc = "utf8"
|
|
13
|
+
if (!start) start = 0
|
|
14
|
+
|
|
15
|
+
const length = this.length
|
|
16
|
+
if (enc === "hex") {
|
|
17
|
+
const buf = new Uint8Array(this)
|
|
18
|
+
return [...Array(((end && end + 1) || length) - start).keys()]
|
|
19
|
+
.map(i => buf[i + start].toString(16).padStart(2, "0"))
|
|
20
|
+
.join("")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (enc === "utf8") {
|
|
24
|
+
return Array.from({length: (end || length) - start}, (_, i) =>
|
|
25
|
+
String.fromCharCode(this[i + start]),
|
|
26
|
+
).join("")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (enc === "base64") {
|
|
30
|
+
return btoa(this)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default SeaArray
|
package/src/buffer.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import SeaArray from "./array.js"
|
|
2
|
+
|
|
3
|
+
// This is Buffer implementation used in SEA. Functionality is mostly
|
|
4
|
+
// compatible with NodeJS "safe-buffer" and is used for encoding conversions
|
|
5
|
+
// between binary and "hex" | "utf8" | "base64"
|
|
6
|
+
// See documentation and validation for safe implementation in:
|
|
7
|
+
// https://github.com/feross/safe-buffer#update
|
|
8
|
+
function SafeBuffer(...props) {
|
|
9
|
+
console.warn("new SafeBuffer() is deprecated, please use SafeBuffer.from()")
|
|
10
|
+
return SafeBuffer.from(...props)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
SafeBuffer.prototype = Object.create(Array.prototype)
|
|
14
|
+
Object.assign(SafeBuffer, {
|
|
15
|
+
// (data, enc) where typeof data === "string" then
|
|
16
|
+
// enc === "utf8"|"hex"|"base64"
|
|
17
|
+
from() {
|
|
18
|
+
if (!Object.keys(arguments).length || arguments[0] == null) {
|
|
19
|
+
throw new TypeError(
|
|
20
|
+
"First argument must be a string, Buffer, ArrayBuffer, Array, or array-like object.",
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
const input = arguments[0]
|
|
24
|
+
let buf
|
|
25
|
+
if (typeof input === "string") {
|
|
26
|
+
const enc = arguments[1] || "utf8"
|
|
27
|
+
if (enc === "hex") {
|
|
28
|
+
const bytes = input
|
|
29
|
+
.match(/([\da-fA-F]{2})/g)
|
|
30
|
+
.map(byte => parseInt(byte, 16))
|
|
31
|
+
if (!bytes || !bytes.length) {
|
|
32
|
+
throw new TypeError("Invalid first argument for type 'hex'.")
|
|
33
|
+
}
|
|
34
|
+
buf = SeaArray.from(bytes)
|
|
35
|
+
} else if (enc === "utf8" || enc === "binary") {
|
|
36
|
+
const length = input.length
|
|
37
|
+
const words = new Uint16Array(length)
|
|
38
|
+
Array.from({length: length}, (_, i) => (words[i] = input.charCodeAt(i)))
|
|
39
|
+
buf = SeaArray.from(words)
|
|
40
|
+
} else if (enc === "base64") {
|
|
41
|
+
const dec = atob(input)
|
|
42
|
+
const length = dec.length
|
|
43
|
+
const bytes = new Uint8Array(length)
|
|
44
|
+
Array.from({length: length}, (_, i) => (bytes[i] = dec.charCodeAt(i)))
|
|
45
|
+
buf = SeaArray.from(bytes)
|
|
46
|
+
} else {
|
|
47
|
+
console.info("SafeBuffer.from unknown encoding: " + enc)
|
|
48
|
+
}
|
|
49
|
+
return buf
|
|
50
|
+
}
|
|
51
|
+
const length = input.byteLength ? input.byteLength : input.length
|
|
52
|
+
if (length) {
|
|
53
|
+
let buf
|
|
54
|
+
if (input instanceof ArrayBuffer) {
|
|
55
|
+
buf = new Uint8Array(input)
|
|
56
|
+
}
|
|
57
|
+
const check = SeaArray.from(buf || input)
|
|
58
|
+
return check
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
// This is "safe-buffer.alloc" sans encoding support
|
|
62
|
+
alloc(length, fill = 0) {
|
|
63
|
+
return SeaArray.from(
|
|
64
|
+
new Uint8Array(Array.from({length: length}, () => fill)),
|
|
65
|
+
)
|
|
66
|
+
},
|
|
67
|
+
// This puts together array of array like members
|
|
68
|
+
concat(arr) {
|
|
69
|
+
// octet array
|
|
70
|
+
if (!Array.isArray(arr)) {
|
|
71
|
+
throw new TypeError(
|
|
72
|
+
"First argument must be Array containing ArrayBuffer or Uint8Array instances.",
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
return SeaArray.from(
|
|
76
|
+
arr.reduce((ret, item) => ret.concat(Array.from(item)), []),
|
|
77
|
+
)
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
SafeBuffer.prototype.from = SafeBuffer.from
|
|
81
|
+
SafeBuffer.prototype.toString = SeaArray.prototype.toString
|
|
82
|
+
|
|
83
|
+
export default SafeBuffer
|
package/src/dup.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const Dup = maxAge => {
|
|
2
|
+
// Allow maxAge to be passed in as tests wait on the setTimeout.
|
|
3
|
+
if (!maxAge) maxAge = 9000
|
|
4
|
+
const dup = {store: {}}
|
|
5
|
+
dup.check = id => (dup.store[id] ? dup.track(id) : false)
|
|
6
|
+
dup.track = id => {
|
|
7
|
+
// Keep the liveliness of the message up while it is being received.
|
|
8
|
+
dup.store[id] = Date.now()
|
|
9
|
+
if (!dup.expiry) {
|
|
10
|
+
dup.expiry = setTimeout(() => {
|
|
11
|
+
const now = Date.now()
|
|
12
|
+
Object.keys(dup.store).forEach(id => {
|
|
13
|
+
if (now - dup.store[id] > maxAge) delete dup.store[id]
|
|
14
|
+
})
|
|
15
|
+
dup.expiry = null
|
|
16
|
+
}, maxAge)
|
|
17
|
+
}
|
|
18
|
+
return id
|
|
19
|
+
}
|
|
20
|
+
return dup
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default Dup
|
package/src/get.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const Get = (lex, graph) => {
|
|
2
|
+
const soul = lex["#"]
|
|
3
|
+
const key = typeof lex["."] === "string" ? lex["."] : false
|
|
4
|
+
var node = graph[soul]
|
|
5
|
+
|
|
6
|
+
// Can only return a node if a key is provided, because the graph may not
|
|
7
|
+
// have all the keys populated for a given soul. This is because Ham.mix
|
|
8
|
+
// only adds incoming changes to the graph.
|
|
9
|
+
if (!node || !key) return
|
|
10
|
+
|
|
11
|
+
let value = node[key]
|
|
12
|
+
if (!value) return
|
|
13
|
+
|
|
14
|
+
node = {_: node._, [key]: value}
|
|
15
|
+
node._[">"] = {[key]: node._[">"][key]}
|
|
16
|
+
return {[soul]: node}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default Get
|
package/src/ham.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import * as utils from "./utils.js"
|
|
2
|
+
import SEA from "./sea.js"
|
|
3
|
+
|
|
4
|
+
// state and value are the incoming changes.
|
|
5
|
+
// currentState and currentValue are the current graph data.
|
|
6
|
+
const Ham = (state, currentState, value, currentValue) => {
|
|
7
|
+
if (state < currentState) return {historical: true}
|
|
8
|
+
|
|
9
|
+
if (state > currentState) return {incoming: true}
|
|
10
|
+
|
|
11
|
+
// state is equal to currentState, lexically compare to resolve conflict.
|
|
12
|
+
if (typeof value !== "string") {
|
|
13
|
+
value = JSON.stringify(value) || ""
|
|
14
|
+
}
|
|
15
|
+
if (typeof currentValue !== "string") {
|
|
16
|
+
currentValue = JSON.stringify(currentValue) || ""
|
|
17
|
+
}
|
|
18
|
+
// No update required.
|
|
19
|
+
if (value === currentValue) return {state: true}
|
|
20
|
+
|
|
21
|
+
// Keep the current value.
|
|
22
|
+
if (value < currentValue) return {current: true}
|
|
23
|
+
|
|
24
|
+
// Otherwise update using the incoming value.
|
|
25
|
+
return {incoming: true}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
Ham.mix = async (change, graph, secure, listen) => {
|
|
29
|
+
var machine = Date.now()
|
|
30
|
+
var now = {}
|
|
31
|
+
var defer = {}
|
|
32
|
+
let wait = 0
|
|
33
|
+
|
|
34
|
+
for (const soul of Object.keys(change)) {
|
|
35
|
+
const node = change[soul]
|
|
36
|
+
let updated = false
|
|
37
|
+
let alias = false
|
|
38
|
+
let nodeWait = 0
|
|
39
|
+
let pub = ""
|
|
40
|
+
let verify = secure
|
|
41
|
+
|
|
42
|
+
if (!node || !node._) continue
|
|
43
|
+
|
|
44
|
+
// If a signature and public key are provided then always verify.
|
|
45
|
+
if (node._.s && node._.p) verify = true
|
|
46
|
+
|
|
47
|
+
// Special case if soul starts with "~". Node must be system data ie,
|
|
48
|
+
// ~@alias or ~publickey. For aliases, key and value must be a self
|
|
49
|
+
// identifying rel. For public keys, data needs to be signed and verified.
|
|
50
|
+
// (This is also true for any data when the secure flag is used.)
|
|
51
|
+
if (soul.length > 1 && soul[0] === "~") {
|
|
52
|
+
if (soul[1] === "@") {
|
|
53
|
+
alias = true
|
|
54
|
+
verify = false
|
|
55
|
+
} else {
|
|
56
|
+
if (node._.p && soul != "~" + node._.p) {
|
|
57
|
+
console.log(`error public key does not match for soul: ${soul}`)
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Need to be able to create public key data before a user is
|
|
62
|
+
// authenticated, in this case the soul is the public key which
|
|
63
|
+
// should've been used to sign the data.
|
|
64
|
+
node._.p = soul.slice(1)
|
|
65
|
+
verify = true
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (verify) {
|
|
69
|
+
if (!node._.s || !node._.p) {
|
|
70
|
+
console.log("error signature and public key required to verify data")
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!(await SEA.verify({m: node, s: node._.s}, {pub: node._.p}))) {
|
|
75
|
+
console.log(`error could not verify soul: ${soul}`)
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const key of Object.keys(node)) {
|
|
81
|
+
if (key === "_") continue
|
|
82
|
+
|
|
83
|
+
const value = node[key]
|
|
84
|
+
const state = node._[">"][key]
|
|
85
|
+
const currentValue = (graph[soul] || {})[key]
|
|
86
|
+
const currentState = (graph[soul] || {_: {">": {}}})._[">"][key] || 0
|
|
87
|
+
|
|
88
|
+
if (alias && key !== utils.rel.is(value)) continue
|
|
89
|
+
|
|
90
|
+
// Defer the update if ahead of machine time.
|
|
91
|
+
const skew = state - machine
|
|
92
|
+
if (skew > 0) {
|
|
93
|
+
// Ignore update if ahead by more than 24 hours.
|
|
94
|
+
if (skew > 86400000) continue
|
|
95
|
+
|
|
96
|
+
// Wait the shortest difference before trying the updates again.
|
|
97
|
+
if (wait === 0 || skew < wait) wait = nodeWait = skew
|
|
98
|
+
if (!defer[soul]) {
|
|
99
|
+
defer[soul] = {_: {"#": soul, ">": {}, s: node._.s, p: node._.p}}
|
|
100
|
+
}
|
|
101
|
+
defer[soul][key] = value
|
|
102
|
+
defer[soul]._[">"][key] = state
|
|
103
|
+
} else {
|
|
104
|
+
const result = Ham(state, currentState, value, currentValue)
|
|
105
|
+
if (result.incoming) {
|
|
106
|
+
if (!now[soul]) {
|
|
107
|
+
now[soul] = {_: {"#": soul, ">": {}, s: node._.s, p: node._.p}}
|
|
108
|
+
}
|
|
109
|
+
// TODO: graph should not just grow indefintitely in memory.
|
|
110
|
+
// Need to have a max size after which start dropping the oldest state
|
|
111
|
+
// Do something similar to Dup which can handle deletes?
|
|
112
|
+
if (!graph[soul]) {
|
|
113
|
+
graph[soul] = {_: {"#": soul, ">": {}, s: node._.s, p: node._.p}}
|
|
114
|
+
}
|
|
115
|
+
graph[soul][key] = now[soul][key] = value
|
|
116
|
+
graph[soul]._[">"][key] = now[soul]._[">"][key] = state
|
|
117
|
+
// Call event listeners for update on key, mix is called before
|
|
118
|
+
// put has finished so wait for what could be multiple nested
|
|
119
|
+
// updates on a node.
|
|
120
|
+
if (listen[soul]) {
|
|
121
|
+
setTimeout(() => {
|
|
122
|
+
if (listen[soul]) {
|
|
123
|
+
listen[soul]
|
|
124
|
+
.filter(l => utils.match(l["."], key))
|
|
125
|
+
.forEach(l => l.cb())
|
|
126
|
+
}
|
|
127
|
+
}, 100)
|
|
128
|
+
}
|
|
129
|
+
updated = true
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (verify && nodeWait !== 0 && now[soul]) {
|
|
135
|
+
// Secure updates can't be split, so move now to deferred as well.
|
|
136
|
+
Object.assign(defer[soul], now[soul])
|
|
137
|
+
delete now[soul]
|
|
138
|
+
}
|
|
139
|
+
// Call event listeners for update on soul.
|
|
140
|
+
if (updated && listen[soul]) {
|
|
141
|
+
setTimeout(() => {
|
|
142
|
+
if (listen[soul]) {
|
|
143
|
+
listen[soul].filter(l => utils.match(l["."])).forEach(l => l.cb())
|
|
144
|
+
}
|
|
145
|
+
}, 100)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return {now: now, defer: defer, wait: wait}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export default Ham
|