@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.
Files changed (46) hide show
  1. package/.github/workflows/node.js.yml +31 -0
  2. package/LICENSE.md +55 -0
  3. package/README.md +24 -0
  4. package/build/holster.js +4 -0
  5. package/build/holster.js.map +7 -0
  6. package/examples/index.html +33 -0
  7. package/examples/index.js +49 -0
  8. package/examples/user.html +31 -0
  9. package/examples/user.js +88 -0
  10. package/examples/wire.html +27 -0
  11. package/examples/wire.js +55 -0
  12. package/package.json +44 -0
  13. package/src/array.js +34 -0
  14. package/src/buffer.js +83 -0
  15. package/src/dup.js +23 -0
  16. package/src/get.js +19 -0
  17. package/src/ham.js +151 -0
  18. package/src/holster.js +502 -0
  19. package/src/index.js +21 -0
  20. package/src/radisk.js +438 -0
  21. package/src/radix.js +106 -0
  22. package/src/sea-utils.js +65 -0
  23. package/src/sea.js +224 -0
  24. package/src/store.js +225 -0
  25. package/src/user.js +330 -0
  26. package/src/utils.js +124 -0
  27. package/src/wire.js +290 -0
  28. package/test/holster.get.test.js +60 -0
  29. package/test/holster.lex.test.js +196 -0
  30. package/test/holster.off.test.js +53 -0
  31. package/test/holster.on.test.js +168 -0
  32. package/test/holster.put.test.js +278 -0
  33. package/test/holster.secure.test.js +210 -0
  34. package/test/holster.user.get.test.js +86 -0
  35. package/test/holster.user.lex.test.js +148 -0
  36. package/test/holster.user.off.test.js +72 -0
  37. package/test/holster.user.on.test.js +185 -0
  38. package/test/holster.user.put.test.js +381 -0
  39. package/test/kitty/! +9 -0
  40. package/test/names.js +225 -0
  41. package/test/radisk.test.js +191 -0
  42. package/test/radix.test.js +117 -0
  43. package/test/sea.test.js +284 -0
  44. package/test/store.test.js +316 -0
  45. package/test/user.test.js +173 -0
  46. package/test/wire.test.js +159 -0
package/src/wire.js ADDED
@@ -0,0 +1,290 @@
1
+ import Dup from "./dup.js"
2
+ import Get from "./get.js"
3
+ import Ham from "./ham.js"
4
+ import Store from "./store.js"
5
+ import * as utils from "./utils.js"
6
+
7
+ const isNode = typeof document === "undefined"
8
+
9
+ const wsModule = isNode ? await import("ws") : undefined
10
+
11
+ if (typeof globalThis.WebSocket === "undefined") {
12
+ globalThis.WebSocket = wsModule?.WebSocket
13
+ }
14
+
15
+ // Wire starts a websocket client or server and returns get and put methods
16
+ // for access to the wire spec and storage.
17
+ const Wire = opt => {
18
+ if (!utils.obj.is(opt)) opt = {}
19
+
20
+ const dup = Dup(opt.maxAge)
21
+ const store = Store(opt)
22
+ const graph = {}
23
+ const queue = {}
24
+ const listen = {}
25
+
26
+ // The check function is required because user data must provide a public key
27
+ // so that it can be verified. The public key might verify the provided
28
+ // signature but not actually match the user under which the data is being
29
+ // stored. To avoid this, the current data on a soul needs to be checked to
30
+ // make sure the stored public key matches the one provided with the update.
31
+ const check = async (data, send, cb) => {
32
+ for (const soul of Object.keys(data)) {
33
+ const msg = await new Promise(res => {
34
+ getWithCallback({"#": soul}, res, send)
35
+ })
36
+ if (msg.err) {
37
+ if (cb) cb(msg.err)
38
+ return false
39
+ }
40
+
41
+ const node = data[soul]
42
+ const pub = node._ ? node._.p : undefined
43
+ const key = utils.userPublicKey
44
+ // If there is no current node then the data is ok to write without
45
+ // matching public keys, as the provided soul also needs a rel on the
46
+ // parent node which then also requires checking. Otherwise publc keys
47
+ // need to match for existing data.
48
+ if (!msg.put || !msg.put[soul] || msg.put[soul][key] === pub) {
49
+ continue
50
+ }
51
+
52
+ // If a soul exists but does not have a public key, then one should not be
53
+ // added because the node is not user data. The above check fails in this
54
+ // case if a public key is provided. Note that this is only an error case
55
+ // if called via the API, which is when a callback is provided here.
56
+ // (The wire spec can fetch and put data on the wire without a signature
57
+ // or public key and this can be ignored.)
58
+ if (cb) {
59
+ cb(`error in wire check public key does not match for soul: ${soul}`)
60
+ }
61
+ return false
62
+ }
63
+
64
+ return true
65
+ }
66
+
67
+ const get = (msg, send) => {
68
+ const ack = Get(msg.get, graph)
69
+ if (ack) {
70
+ send(
71
+ JSON.stringify({
72
+ "#": dup.track(utils.text.random(9)),
73
+ "@": msg["#"],
74
+ put: ack,
75
+ }),
76
+ )
77
+ } else {
78
+ store.get(msg.get, (err, ack) => {
79
+ send(
80
+ JSON.stringify({
81
+ "#": dup.track(utils.text.random(9)),
82
+ "@": msg["#"],
83
+ put: ack,
84
+ err: err,
85
+ }),
86
+ )
87
+ })
88
+ }
89
+ }
90
+
91
+ const put = async (msg, send) => {
92
+ // Store updates returned from Ham.mix and defer updates if required.
93
+ const update = await Ham.mix(msg.put, graph, opt.secure, listen)
94
+ if (Object.keys(update.now).length) {
95
+ if (!(await check(update.now, send))) return
96
+
97
+ store.put(update.now, err => {
98
+ send(
99
+ JSON.stringify({
100
+ "#": dup.track(utils.text.random(9)),
101
+ "@": msg["#"],
102
+ err: err,
103
+ }),
104
+ )
105
+ })
106
+ }
107
+ if (Object.keys(update.defer).length) {
108
+ setTimeout(() => put({put: update.defer}, send), update.wait)
109
+ }
110
+ }
111
+
112
+ const getWithCallback = (lex, cb, send, opt) => {
113
+ if (!cb) return
114
+
115
+ if (!utils.obj.is(opt)) opt = {}
116
+ const ack = Get(lex, graph)
117
+ if (ack) {
118
+ cb({put: ack})
119
+ return
120
+ }
121
+
122
+ store.get(lex, (err, ack) => {
123
+ if (ack) {
124
+ cb({put: ack, err: err})
125
+ return
126
+ }
127
+
128
+ if (err) console.log(err)
129
+
130
+ const track = utils.text.random(9)
131
+ queue[track] = cb
132
+ send(
133
+ JSON.stringify({
134
+ "#": dup.track(track),
135
+ get: lex,
136
+ }),
137
+ )
138
+ // Respond to callback with null if no response.
139
+ setTimeout(() => {
140
+ const cb = queue[track]
141
+ if (cb) {
142
+ const id = lex["#"]
143
+ const ack = {[id]: null}
144
+ if (typeof lex["."] === "string") ack[id] = {[lex["."]]: null}
145
+ cb({put: ack})
146
+ delete queue[track]
147
+ }
148
+ }, opt.wait || 100)
149
+ })
150
+ }
151
+
152
+ const api = send => {
153
+ return {
154
+ get: (lex, cb, opt) => {
155
+ getWithCallback(lex, cb, send, opt)
156
+ },
157
+ put: async (data, cb) => {
158
+ // Deferred updates are only stored using wire spec, they're ignored
159
+ // here using the api. This is ok because correct timestamps should be
160
+ // used whereas wire spec needs to handle clock skew for updates
161
+ // across the network.
162
+ const update = await Ham.mix(data, graph, opt.secure, listen)
163
+ const none = Object.keys(update.now).length === 0
164
+ if (none || !(await check(update.now, send, cb))) return
165
+
166
+ store.put(update.now, cb)
167
+ // Also put data on the wire spec.
168
+ // TODO: Note that this means all clients now receive all updates, so
169
+ // need to filter what should be stored, both in graph and on disk.
170
+ send(
171
+ JSON.stringify({
172
+ "#": dup.track(utils.text.random(9)),
173
+ put: data,
174
+ }),
175
+ )
176
+ },
177
+ on: (lex, cb) => {
178
+ const soul = lex && lex["#"]
179
+ if (!soul || !cb) return
180
+
181
+ if (listen[soul]) {
182
+ listen[soul].push({".": lex["."], cb: cb})
183
+ } else {
184
+ listen[soul] = [{".": lex["."], cb: cb}]
185
+ }
186
+ },
187
+ off: (lex, cb) => {
188
+ const soul = lex && lex["#"]
189
+ if (!soul || !listen[soul]) return
190
+
191
+ if (cb) {
192
+ const found = listen[soul].find(l => l.cb === cb)
193
+ if (found) {
194
+ listen[soul].splice(listen[soul].indexOf(found), 1)
195
+ }
196
+ } else {
197
+ // Remove all callbacks when none provided.
198
+ delete listen[soul]
199
+ }
200
+ },
201
+ }
202
+ }
203
+
204
+ if (isNode) {
205
+ let wss = opt.wss
206
+ // Node's websocket server provides clients as an array, whereas
207
+ // mock-sockets provides clients as a function that returns an array.
208
+ let clients = () => wss.clients()
209
+ if (!wss) {
210
+ wss = new wsModule.WebSocketServer({port: 8080})
211
+ clients = () => wss.clients
212
+ }
213
+
214
+ const send = (data, isBinary) => {
215
+ clients().forEach(client => {
216
+ if (client.readyState === WebSocket.OPEN) {
217
+ client.send(data, {binary: isBinary})
218
+ }
219
+ })
220
+ }
221
+ wss.on("connection", ws => {
222
+ ws.on("error", console.error)
223
+
224
+ ws.on("message", (data, isBinary) => {
225
+ const msg = JSON.parse(data)
226
+ if (dup.check(msg["#"])) return
227
+
228
+ dup.track(msg["#"])
229
+ if (msg.get) get(msg, send)
230
+ if (msg.put) put(msg, send)
231
+ send(data, isBinary)
232
+
233
+ const id = msg["@"]
234
+ const cb = queue[id]
235
+ if (cb) {
236
+ delete msg["#"]
237
+ delete msg["@"]
238
+ cb(msg)
239
+
240
+ delete queue[id]
241
+ }
242
+ })
243
+ })
244
+ return api(send)
245
+ }
246
+
247
+ let ws = new WebSocket("ws://localhost:8080")
248
+ const send = data => {
249
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
250
+ console.log("websocket not available")
251
+ return
252
+ }
253
+
254
+ ws.send(data)
255
+ }
256
+ const start = () => {
257
+ if (!ws) ws = new WebSocket("ws://localhost:8080")
258
+ ws.onclose = c => {
259
+ ws = null
260
+ setTimeout(start, Math.floor(Math.random() * 5000))
261
+ }
262
+ ws.onerror = e => {
263
+ console.error(e)
264
+ }
265
+ ws.onmessage = m => {
266
+ const msg = JSON.parse(m.data)
267
+ if (dup.check(msg["#"])) return
268
+
269
+ dup.track(msg["#"])
270
+ if (msg.get) get(msg, send)
271
+ if (msg.put) put(msg, send)
272
+ send(m.data)
273
+
274
+ const id = msg["@"]
275
+ const cb = queue[id]
276
+ if (cb) {
277
+ delete msg["#"]
278
+ delete msg["@"]
279
+ cb(msg)
280
+
281
+ delete queue[id]
282
+ }
283
+ }
284
+ }
285
+
286
+ start()
287
+ return api(send)
288
+ }
289
+
290
+ export default Wire
@@ -0,0 +1,60 @@
1
+ import fs from "fs"
2
+ import {Server} from "mock-socket"
3
+ import {describe, test} from "node:test"
4
+ import assert from "node:assert/strict"
5
+ import Holster from "../src/holster.js"
6
+
7
+ describe("holster.get", () => {
8
+ const wss = new Server("ws://localhost:1234")
9
+ const holster = Holster({file: "test/holster.get", wss: wss, maxAge: 100})
10
+
11
+ test("empty key callback null", (t, done) => {
12
+ holster.get("", data => {
13
+ assert.equal(data, null)
14
+ done()
15
+ })
16
+ })
17
+
18
+ test("null key callback null", (t, done) => {
19
+ holster.get(null, data => {
20
+ assert.equal(data, null)
21
+ done()
22
+ })
23
+ })
24
+
25
+ test("underscore as key callback null", (t, done) => {
26
+ holster.get("_", data => {
27
+ assert.equal(data, null)
28
+ done()
29
+ })
30
+ })
31
+
32
+ test("get unknown key callback null", (t, done) => {
33
+ holster.get("unknown", data => {
34
+ assert.equal(data, null)
35
+ done()
36
+ })
37
+ })
38
+
39
+ // Chaining get creates a new context for each call.
40
+ test("get chained unknown keys callback null", (t, done) => {
41
+ holster.get("chained").get("unknown", data => {
42
+ assert.equal(data, null)
43
+ done()
44
+ })
45
+ })
46
+
47
+ test("next chained unknown keys callback null", (t, done) => {
48
+ holster.get("chained").next("unknown", data => {
49
+ assert.equal(data, null)
50
+ done()
51
+ })
52
+ })
53
+
54
+ test("cleanup", (t, done) => {
55
+ fs.rm("test/holster.get", {recursive: true, force: true}, err => {
56
+ assert.equal(err, null)
57
+ done()
58
+ })
59
+ })
60
+ })
@@ -0,0 +1,196 @@
1
+ import fs from "fs"
2
+ import {Server} from "mock-socket"
3
+ import {describe, test} from "node:test"
4
+ import assert from "node:assert/strict"
5
+ import Holster from "../src/holster.js"
6
+
7
+ describe("holster.lex", () => {
8
+ const wss = new Server("ws://localhost:1234")
9
+ const holster = Holster({file: "test/holster.lex", wss: wss, maxAge: 100})
10
+
11
+ test("object on root in graph format", (t, done) => {
12
+ const plain = {
13
+ key: "plain value",
14
+ true: true,
15
+ false: false,
16
+ number: 42,
17
+ }
18
+ holster.get("plain").put(plain, err => {
19
+ assert.equal(err, null)
20
+
21
+ // Prefix tests. (Need to be sequential to match return values.)
22
+ holster.get("plain", {".": {"*": "k"}}, data => {
23
+ assert.deepEqual(data, {key: "plain value"})
24
+
25
+ holster.get("plain", {".": {"*": "t"}}, data => {
26
+ assert.deepEqual(data, {true: true})
27
+
28
+ holster.get("plain", {".": {"*": "f"}}, data => {
29
+ assert.deepEqual(data, {false: false})
30
+
31
+ holster.get("plain", {".": {"*": "n"}}, data => {
32
+ assert.deepEqual(data, {number: 42})
33
+ })
34
+
35
+ // Both less than and greater than.
36
+ holster.get("plain", {".": {"<": "n", ">": "falsy"}}, data => {
37
+ assert.deepEqual(data, {key: "plain value"})
38
+
39
+ // Only less than.
40
+ holster.get("plain", {".": {"<": "k"}}, data => {
41
+ assert.deepEqual(data, {false: false})
42
+
43
+ // Only greater than.
44
+ holster.get("plain", {".": {">": "numbers"}}, data => {
45
+ assert.deepEqual(data, {true: true})
46
+ done()
47
+ })
48
+ })
49
+ })
50
+ })
51
+ })
52
+ })
53
+ })
54
+ })
55
+
56
+ test("put and get nested object", (t, done) => {
57
+ const nested = {
58
+ key: "nested value",
59
+ other: "other value",
60
+ child: {
61
+ has: "child value",
62
+ other: "other child value",
63
+ },
64
+ }
65
+ holster.get("nested").put(nested, err => {
66
+ assert.equal(err, null)
67
+
68
+ // Getting a nested object requires waiting for radisk to write to disk,
69
+ // as it will batch the writes. (Default wait is 1 millisecond.)
70
+ setTimeout(() => {
71
+ holster.get("nested", {".": {"*": "k"}}, data => {
72
+ assert.deepEqual(data, {key: "nested value"})
73
+
74
+ holster.get("nested").next("child", {".": {"*": "h"}}, data => {
75
+ assert.deepEqual(data, {has: "child value"})
76
+
77
+ holster.get("nested").next("child", {".": {"<": "has"}}, data => {
78
+ // Less than means less than or equal to in lex.
79
+ assert.deepEqual(data, {has: "child value"})
80
+ done()
81
+ })
82
+ })
83
+ })
84
+ }, 2)
85
+ })
86
+ })
87
+
88
+ test("chained get before put nested object", (t, done) => {
89
+ const nested = {
90
+ key: "hello nested value",
91
+ other: "other value",
92
+ child: {
93
+ has: "hello child value",
94
+ other: "other child value",
95
+ },
96
+ }
97
+ holster
98
+ .get("hello")
99
+ .next("nested")
100
+ .put(nested, err => {
101
+ assert.equal(err, null)
102
+
103
+ setTimeout(() => {
104
+ holster.get("hello").next("nested", {".": {"*": "k"}}, data => {
105
+ assert.deepEqual(data, {key: "hello nested value"})
106
+
107
+ holster
108
+ .get("hello")
109
+ .next("nested")
110
+ .next("child", {".": {"*": "h"}}, data => {
111
+ assert.deepEqual(data, {has: "hello child value"})
112
+
113
+ holster
114
+ .get("hello")
115
+ .next("nested")
116
+ .next("child", {".": {">": "other"}}, data => {
117
+ // Greater than means greater than or equal to in lex.
118
+ assert.deepEqual(data, {other: "other child value"})
119
+ done()
120
+ })
121
+ })
122
+ })
123
+ }, 2)
124
+ })
125
+ })
126
+
127
+ test("put and get two nested objects", (t, done) => {
128
+ const two = {
129
+ key: "two nested values",
130
+ child1: {
131
+ has: "child value 1",
132
+ },
133
+ child2: {
134
+ has: "child value 2",
135
+ },
136
+ }
137
+ holster.get("two").put(two, err => {
138
+ assert.equal(err, null)
139
+
140
+ setTimeout(() => {
141
+ holster.get("two", {".": {"*": "child"}}, data => {
142
+ assert.deepEqual(data, {
143
+ child1: two.child1,
144
+ child2: two.child2,
145
+ })
146
+
147
+ holster
148
+ .get("two")
149
+ .next("child1", {".": {">": "g", "<": "i"}}, data => {
150
+ assert.deepEqual(data, {has: "child value 1"})
151
+ done()
152
+ })
153
+ })
154
+ }, 2)
155
+ })
156
+ })
157
+
158
+ test("on and put nested object", (t, done) => {
159
+ const nested = {
160
+ key: "nested key",
161
+ child: {
162
+ has: "child key",
163
+ },
164
+ }
165
+ // The node needs to exist before it can be listend to for updates.
166
+ holster.get("on-nested").put(nested, err => {
167
+ assert.equal(err, null)
168
+
169
+ const update = {
170
+ child: {
171
+ has: "child update",
172
+ },
173
+ }
174
+ holster
175
+ .get("on-nested")
176
+ .next("child")
177
+ .on({".": {">": "h"}}, data => {
178
+ assert.deepEqual(data, update.child)
179
+ done()
180
+ })
181
+
182
+ setTimeout(() => {
183
+ holster.get("on-nested").put(update, err => {
184
+ assert.equal(err, null)
185
+ })
186
+ }, 10)
187
+ })
188
+ })
189
+
190
+ test("cleanup", (t, done) => {
191
+ fs.rm("test/holster.lex", {recursive: true, force: true}, err => {
192
+ assert.equal(err, null)
193
+ done()
194
+ })
195
+ })
196
+ })
@@ -0,0 +1,53 @@
1
+ import fs from "fs"
2
+ import {Server} from "mock-socket"
3
+ import {describe, test} from "node:test"
4
+ import assert from "node:assert/strict"
5
+ import Holster from "../src/holster.js"
6
+
7
+ describe("holster.off", () => {
8
+ const wss = new Server("ws://localhost:1234")
9
+ const holster = Holster({file: "test/holster.off", wss: wss, maxAge: 100})
10
+
11
+ test("calling off without get callback null", (t, done) => {
12
+ holster.off(data => {
13
+ assert.equal(data, null)
14
+ done()
15
+ })
16
+ })
17
+
18
+ test("off with cb for property on root then update - no event", (t, done) => {
19
+ const cb = data => {
20
+ console.log("should not be called for key:", data)
21
+ done()
22
+ }
23
+ holster.get("key").on(cb)
24
+ holster.get("key").off(cb)
25
+
26
+ holster.get("key").put("value", err => {
27
+ assert.equal(err, null)
28
+ done()
29
+ })
30
+ })
31
+
32
+ test("off no cb for property on root then update - no event", (t, done) => {
33
+ holster.get("key1").on(data => {
34
+ console.log("should not be called for key1:", data)
35
+ done()
36
+ })
37
+ setTimeout(() => {
38
+ holster.get("key1").off()
39
+
40
+ holster.get("key1").put("value1", err => {
41
+ assert.equal(err, null)
42
+ done()
43
+ })
44
+ }, 10)
45
+ })
46
+
47
+ test("cleanup", (t, done) => {
48
+ fs.rm("test/holster.off", {recursive: true, force: true}, err => {
49
+ assert.equal(err, null)
50
+ done()
51
+ })
52
+ })
53
+ })