@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/radisk.js ADDED
@@ -0,0 +1,438 @@
1
+ import Radix from "./radix.js"
2
+ import * as utils from "./utils.js"
3
+
4
+ // ASCII character for end of text.
5
+ const etx = String.fromCharCode(3)
6
+ // ASCII character for enquiry.
7
+ const enq = String.fromCharCode(5)
8
+ // ASCII character for unit separator.
9
+ const unit = String.fromCharCode(31)
10
+
11
+ // Radisk provides access to a radix tree that is stored in the provided
12
+ // opt.store interface.
13
+ const Radisk = opt => {
14
+ var u
15
+ var cache = null
16
+
17
+ if (!opt) opt = {}
18
+ if (!opt.log) opt.log = console.log
19
+ if (!opt.batch) opt.batch = 10 * 1000
20
+ if (!opt.write) opt.write = 1 // Wait time before write in milliseconds.
21
+ if (!opt.size) opt.size = 1024 * 1024 // File size on disk, default 1MB.
22
+ if (!opt.store) {
23
+ opt.log(
24
+ "Radisk needs `store` interface with `{get: fn, put: fn, list: fn}`",
25
+ )
26
+ return
27
+ }
28
+ if (!opt.store.get) {
29
+ opt.log("Radisk needs `store.get` interface with `(file, cb)`")
30
+ return
31
+ }
32
+ if (!opt.store.put) {
33
+ opt.log("Radisk needs `store.put` interface with `(file, data, cb)`")
34
+ return
35
+ }
36
+ if (!opt.store.list) {
37
+ opt.log("Radisk needs a streaming `store.list` interface with `(cb)`")
38
+ return
39
+ }
40
+
41
+ // Any and all storage adapters should:
42
+ // 1. Because writing to disk takes time, we should batch data to disk.
43
+ // This improves performance, and reduces potential disk corruption.
44
+ // 2. If a batch exceeds a certain number of writes, we should immediately
45
+ // write to disk when physically possible. This caps total performance,
46
+ // but reduces potential loss.
47
+ const radisk = (key, value, cb) => {
48
+ key = "" + key
49
+
50
+ // If no value is provided then the second parameter is the callback
51
+ // function. Read value from memory or disk and call callback with it.
52
+ if (typeof value === "function") {
53
+ cb = value
54
+ value = radisk.batch(key)
55
+ if (typeof value !== "undefined") {
56
+ return cb(u, value)
57
+ }
58
+
59
+ if (radisk.thrash.at) {
60
+ value = radisk.thrash.at(key)
61
+ if (typeof value !== "undefined") {
62
+ return cb(u, value)
63
+ }
64
+ }
65
+
66
+ return radisk.read(key, cb)
67
+ }
68
+
69
+ // Otherwise store the value provided.
70
+ radisk.batch(key, value)
71
+ if (cb) {
72
+ radisk.batch.acks.push(cb)
73
+ }
74
+ // Don't wait if we have batched too many.
75
+ if (++radisk.batch.ed >= opt.batch) {
76
+ return radisk.thrash()
77
+ }
78
+
79
+ // Otherwise wait for more updates before writing.
80
+ clearTimeout(radisk.batch.timeout)
81
+ radisk.batch.timeout = setTimeout(radisk.thrash, opt.write)
82
+ }
83
+
84
+ radisk.batch = Radix()
85
+ radisk.batch.acks = []
86
+ radisk.batch.ed = 0
87
+
88
+ radisk.thrash = () => {
89
+ if (radisk.thrash.ing) {
90
+ return (radisk.thrash.more = true)
91
+ }
92
+
93
+ clearTimeout(radisk.batch.timeout)
94
+ radisk.thrash.more = false
95
+ radisk.thrash.ing = true
96
+ var batch = (radisk.thrash.at = radisk.batch)
97
+ radisk.batch = null
98
+ radisk.batch = Radix()
99
+ radisk.batch.acks = []
100
+ radisk.batch.ed = 0
101
+ let i = 0
102
+ radisk.save(batch, err => {
103
+ // This is to ignore multiple callbacks from radisk.save calling
104
+ // radisk.write? It looks like multiple callbacks will be made if a
105
+ // file needs to be split.
106
+ if (++i > 1) return
107
+
108
+ if (err) opt.log(err)
109
+ batch.acks.forEach(cb => cb(err))
110
+ radisk.thrash.at = null
111
+ radisk.thrash.ing = false
112
+ if (radisk.thrash.more) radisk.thrash()
113
+ })
114
+ }
115
+
116
+ // 1. Find the first radix item in memory
117
+ // 2. Use that as the starting index in the directory of files
118
+ // 3. Find the first file that is lexically larger than it
119
+ // 4. Read the previous file into memory
120
+ // 5. Scan through in memory radix for all values lexically less than limit
121
+ // 6. Merge and write all of those to the in-memory file and back to disk
122
+ // 7. If file is to large then split. More details needed here
123
+ radisk.save = (rad, cb) => {
124
+ const save = {
125
+ find: (tree, key) => {
126
+ // This is false for any key until save.start is set to an initial key.
127
+ if (key < save.start) return
128
+
129
+ save.start = key
130
+ opt.store.list(save.lex)
131
+ return true
132
+ },
133
+ lex: file => {
134
+ if (!file || file > save.start) {
135
+ save.end = file
136
+ // ! is used as the first file name as it's the first printable
137
+ // character, so always matches as lexically less than any node.
138
+ save.mix(save.file || "!", save.start, save.end)
139
+ return true
140
+ }
141
+
142
+ save.file = file
143
+ },
144
+ mix: (file, start, end) => {
145
+ save.start = save.end = save.file = u
146
+ radisk.parse(file, (err, disk) => {
147
+ if (err) return cb(err)
148
+
149
+ Radix.map(rad, (value, key) => {
150
+ if (key < start) return
151
+
152
+ if (end && end < key) {
153
+ save.start = key
154
+ return save.start
155
+ }
156
+
157
+ disk(key, value)
158
+ })
159
+ radisk.write(file, disk, save.next)
160
+ })
161
+ },
162
+ next: err => {
163
+ if (err) return cb(err)
164
+
165
+ if (save.start) return Radix.map(rad, save.find)
166
+
167
+ cb(err)
168
+ },
169
+ }
170
+ Radix.map(rad, save.find)
171
+ }
172
+
173
+ radisk.write = (file, rad, cb) => {
174
+ // Invalidate cache on write.
175
+ cache = null
176
+ const write = {
177
+ text: "",
178
+ count: 0,
179
+ file: file,
180
+ each: (value, key, k, pre) => {
181
+ write.count++
182
+ var enc =
183
+ Radisk.encode(pre.length) +
184
+ "#" +
185
+ Radisk.encode(k) +
186
+ (typeof value === "undefined" ? "" : "=" + Radisk.encode(value)) +
187
+ "\n"
188
+ // Cannot split the file if only have one entry to write.
189
+ if (write.count > 1 && write.text.length + enc.length > opt.size) {
190
+ write.text = ""
191
+ // Otherwise split the entries in half.
192
+ write.limit = Math.ceil(write.count / 2)
193
+ write.count = 0
194
+ write.sub = Radix()
195
+ Radix.map(rad, write.slice)
196
+ return true
197
+ }
198
+
199
+ write.text += enc
200
+ },
201
+ put: () => {
202
+ opt.store.put(file, write.text, cb)
203
+ },
204
+ slice: (value, key) => {
205
+ if (key < write.file) return
206
+
207
+ if (++write.count > write.limit) {
208
+ var name = write.file
209
+ // Use only the soul of the key as the filename so that all
210
+ // properties of a soul are written to the same file.
211
+ let end = key.indexOf(enq)
212
+ if (end === -1) {
213
+ write.file = key
214
+ } else {
215
+ write.file = key.substring(0, end)
216
+ }
217
+ // write.limit can be reached after already writing properties of
218
+ // the current node, so remove it from write.sub before writing to
219
+ // disk so that it's not duplicated across files.
220
+ write.sub(write.file, null)
221
+ write.count = 0
222
+ radisk.write(name, write.sub, write.next)
223
+ return true
224
+ }
225
+
226
+ write.sub(key, value)
227
+ },
228
+ next: err => {
229
+ if (err) return cb(err)
230
+
231
+ write.sub = Radix()
232
+ if (!Radix.map(rad, write.slice)) {
233
+ radisk.write(write.file, write.sub, cb)
234
+ }
235
+ },
236
+ }
237
+ // If Radix.map doesn't return true when called with write.each as a
238
+ // callback then didn't need to split the data. The accumulated write.text
239
+ // can then be stored with write.put().
240
+ if (!Radix.map(rad, write.each, true)) write.put()
241
+ }
242
+
243
+ radisk.read = (key, cb) => {
244
+ if (cache) {
245
+ let value = cache(key)
246
+ if (typeof value !== "undefined") return cb(u, value)
247
+ }
248
+ // Only the soul of the key is compared to filenames (see radisk.write).
249
+ let soul = key
250
+ let end = key.indexOf(enq)
251
+ if (end !== -1) {
252
+ soul = key.substring(0, end)
253
+ }
254
+
255
+ const read = {
256
+ lex: file => {
257
+ // store.list should call lex without a file last, which means all file
258
+ // names were compared to soul, so the current read.file is ok to use.
259
+ if (!file) {
260
+ if (!read.file) {
261
+ cb("no file found", u)
262
+ return
263
+ }
264
+
265
+ radisk.parse(read.file, read.it)
266
+ return
267
+ }
268
+
269
+ // Want the filename closest to soul.
270
+ if (file > soul || file < read.file) return
271
+
272
+ read.file = file
273
+ },
274
+ it: (err, disk) => {
275
+ if (err) opt.log(err)
276
+ if (disk) {
277
+ cache = disk
278
+ read.value = disk(key)
279
+ }
280
+ cb(err, read.value)
281
+ },
282
+ }
283
+ opt.store.list(read.lex)
284
+ }
285
+
286
+ // Let us start by assuming we are the only process that is
287
+ // changing the directory or bucket. Not because we do not want
288
+ // to be multi-process/machine, but because we want to experiment
289
+ // with how much performance and scale we can get out of only one.
290
+ // Then we can work on the harder problem of being multi-process.
291
+ radisk.parse = (file, cb) => {
292
+ const parse = {
293
+ disk: Radix(),
294
+ read: (err, data) => {
295
+ if (err) return cb(err)
296
+
297
+ if (!data) return cb(u, parse.disk)
298
+
299
+ let pre = []
300
+ // Work though data by splitting into 3 values. The first value says
301
+ // if the second value is one of: the radix level for a key, the key
302
+ // iteself, or a value. The third is the rest of the data to work with.
303
+ let tmp = parse.split(data)
304
+ while (tmp) {
305
+ let key
306
+ let value
307
+ let i = tmp[1]
308
+ tmp = parse.split(tmp[2]) || ""
309
+ if (tmp[0] === "#") {
310
+ key = tmp[1]
311
+ pre = pre.slice(0, i)
312
+ if (i <= pre.length) pre.push(key)
313
+ }
314
+ tmp = parse.split(tmp[2]) || ""
315
+ if (tmp[0] === "\n") continue
316
+
317
+ if (tmp[0] === "=") value = tmp[1]
318
+ if (typeof key !== "undefined" && typeof value !== "undefined") {
319
+ parse.disk(pre.join(""), value)
320
+ }
321
+ tmp = parse.split(tmp[2])
322
+ }
323
+ cb(u, parse.disk)
324
+ },
325
+ split: data => {
326
+ if (!data) return
327
+
328
+ let i = -1
329
+ let a = ""
330
+ let c = null
331
+ while ((c = data[++i])) {
332
+ if (c === unit) break
333
+
334
+ a += c
335
+ }
336
+ let o = {}
337
+ if (c) {
338
+ return [a, Radisk.decode(data.slice(i), o), data.slice(i + o.i)]
339
+ }
340
+ },
341
+ }
342
+ opt.store.get(file, parse.read)
343
+ }
344
+
345
+ return radisk
346
+ }
347
+
348
+ Radisk.encode = data => {
349
+ // A key should be passed in as a string to encode, a value can optionally be
350
+ // an array of 2 items to include the value's state, as is done by store.js.
351
+ let state = ""
352
+ if (data instanceof Array && data.length === 2) {
353
+ state = etx + data[1]
354
+ data = data[0]
355
+ }
356
+
357
+ if (typeof data === "string") {
358
+ let i = 0
359
+ let current = null
360
+ let text = unit
361
+ while ((current = data[i++])) {
362
+ if (current === unit) text += unit
363
+ }
364
+ return text + '"' + data + state + unit
365
+ }
366
+
367
+ const rel = utils.rel.is(data)
368
+ if (rel) return unit + "#" + rel + state + unit
369
+
370
+ if (utils.num.is(data)) return unit + "+" + (data || 0) + state + unit
371
+
372
+ if (data === true) return unit + "+" + state + unit
373
+
374
+ if (data === false) return unit + "-" + state + unit
375
+
376
+ if (data === null) return unit + " " + state + unit
377
+ }
378
+
379
+ Radisk.decode = (data, obj) => {
380
+ var text = ""
381
+ var i = -1
382
+ var n = 0
383
+ var current = null
384
+ var previous = null
385
+ if (data[0] !== unit) return
386
+
387
+ // Find a control character previous to the text we want, skipping
388
+ // consecutive unit separator characters at the beginning of the data.
389
+ while ((current = data[++i])) {
390
+ if (previous) {
391
+ if (current === unit) {
392
+ if (--n <= 0) break
393
+ }
394
+ text += current
395
+ } else if (current === unit) {
396
+ n++
397
+ } else {
398
+ previous = current || true
399
+ }
400
+ }
401
+
402
+ if (obj) obj.i = i + 1
403
+
404
+ let [value, state] = text.split(etx)
405
+ if (!state) {
406
+ if (previous === '"') return text
407
+
408
+ if (previous === "#") return utils.rel.ify(text)
409
+
410
+ if (previous === "+") {
411
+ if (text.length === 0) return true
412
+
413
+ return parseFloat(text)
414
+ }
415
+
416
+ if (previous === "-") return false
417
+
418
+ if (previous === " ") return null
419
+ } else {
420
+ state = parseFloat(state)
421
+ // If state was found then return an array.
422
+ if (previous === '"') return [value, state]
423
+
424
+ if (previous === "#") return [utils.rel.ify(value), state]
425
+
426
+ if (previous === "+") {
427
+ if (value.length === 0) return [true, state]
428
+
429
+ return [parseFloat(value), state]
430
+ }
431
+
432
+ if (previous === "-") return [false, state]
433
+
434
+ if (previous === " ") return [null, state]
435
+ }
436
+ }
437
+
438
+ export default Radisk
package/src/radix.js ADDED
@@ -0,0 +1,106 @@
1
+ import * as utils from "./utils.js"
2
+
3
+ // ASCII character for group separator.
4
+ const group = String.fromCharCode(29)
5
+ // ASCII character for record separator.
6
+ const record = String.fromCharCode(30)
7
+
8
+ const Radix = () => {
9
+ const radix = (keys, value, tree) => {
10
+ if (!tree) {
11
+ if (!radix[group]) radix[group] = {}
12
+ tree = radix[group]
13
+ }
14
+ if (!keys) return tree
15
+
16
+ let i = 0
17
+ let tmp = {}
18
+ let key = keys[i]
19
+ const max = keys.length - 1
20
+ const noValue = typeof value === "undefined"
21
+ // Find a matching value using the shortest string from keys.
22
+ let found = tree[key]
23
+ while (!found && i < max) {
24
+ key += keys[++i]
25
+ found = tree[key]
26
+ }
27
+
28
+ if (!found) {
29
+ // If not found from the provided keys try matching with an existing key.
30
+ const result = utils.obj.map(tree, (hasValue, hasKey) => {
31
+ let j = 0
32
+ let matchingKey = ""
33
+ while (hasKey[j] === keys[j]) {
34
+ matchingKey += hasKey[j++]
35
+ }
36
+ if (matchingKey) {
37
+ if (noValue) {
38
+ // matchingKey has to be as long as the original keys when reading.
39
+ if (j <= max) return
40
+
41
+ tmp[hasKey.slice(j)] = hasValue
42
+ return hasValue
43
+ }
44
+
45
+ let replace = {
46
+ [hasKey.slice(j)]: hasValue,
47
+ [keys.slice(j)]: {[record]: value},
48
+ }
49
+ tree[matchingKey] = {[group]: replace}
50
+ delete tree[hasKey]
51
+ return true
52
+ }
53
+ })
54
+ if (!result) {
55
+ if (noValue) return
56
+
57
+ if (!tree[key]) tree[key] = {}
58
+ tree[key][record] = value
59
+ } else if (noValue) {
60
+ return tmp
61
+ }
62
+ } else if (i === max) {
63
+ // If no value use the key provided to return a whole group or record.
64
+ if (noValue) {
65
+ // If an individual record isn't found then return the whole group.
66
+ return typeof found[record] === "undefined"
67
+ ? found[group]
68
+ : found[record]
69
+ }
70
+ // Otherwise create a new record at the provided key for value.
71
+ found[record] = value
72
+ } else {
73
+ // Found at a shorter key, try again.
74
+ if (!found[group] && !noValue) found[group] = {}
75
+ return radix(keys.slice(++i), value, found[group])
76
+ }
77
+ }
78
+ return radix
79
+ }
80
+
81
+ Radix.map = function map(radix, cb, opt, pre) {
82
+ if (!pre) pre = []
83
+ var tree = radix[group] || radix
84
+ var keys = Object.keys(tree).sort()
85
+ var u
86
+
87
+ for (let i = 0; i < keys.length; i++) {
88
+ let key = keys[i]
89
+ let found = tree[key]
90
+ let tmp = found[record]
91
+ if (typeof tmp !== "undefined") {
92
+ tmp = cb(tmp, pre.join("") + key, key, pre)
93
+ if (typeof tmp !== "undefined") return tmp
94
+ } else if (opt) {
95
+ cb(u, pre.join(""), key, pre)
96
+ }
97
+ if (found[group]) {
98
+ pre.push(key)
99
+ tmp = map(found[group], cb, opt, pre)
100
+ if (typeof tmp !== "undefined") return tmp
101
+ pre.pop()
102
+ }
103
+ }
104
+ }
105
+
106
+ export default Radix
@@ -0,0 +1,65 @@
1
+ import SafeBuffer from "./buffer.js"
2
+
3
+ const isNode = typeof document === "undefined"
4
+ const crypto = isNode
5
+ ? (await import("node:crypto")).webcrypto
6
+ : globalThis.crypto
7
+ export const subtle = crypto.subtle
8
+
9
+ export const stringify = data => {
10
+ return typeof data === "string" ? data : JSON.stringify(data)
11
+ }
12
+
13
+ export const parse = text => {
14
+ try {
15
+ return JSON.parse(text)
16
+ } catch {
17
+ return text
18
+ }
19
+ }
20
+
21
+ export const random = length => {
22
+ const array = new Uint8Array(SafeBuffer.alloc(length))
23
+ return SafeBuffer.from(crypto.getRandomValues(array))
24
+ }
25
+
26
+ export const jwk = (pub, priv) => {
27
+ const [x, y] = pub.split(".")
28
+ const ops = priv ? ["sign"] : ["verify"]
29
+ return {
30
+ kty: "EC",
31
+ crv: "P-256",
32
+ x: x,
33
+ y: y,
34
+ d: priv,
35
+ ext: true,
36
+ key_ops: ops,
37
+ }
38
+ }
39
+
40
+ export const sha256 = async data => {
41
+ const hash = await subtle.digest(
42
+ {name: "SHA-256"},
43
+ new TextEncoder().encode(stringify(data)),
44
+ )
45
+ return SafeBuffer.from(hash)
46
+ }
47
+
48
+ export const aeskey = async (key, salt) => {
49
+ const combined = key + salt.toString("utf8")
50
+ const hash = SafeBuffer.from(await sha256(combined), "binary")
51
+ const jwk = keyToJwk(hash)
52
+ return await subtle.importKey("jwk", jwk, {name: "AES-GCM"}, false, [
53
+ "encrypt",
54
+ "decrypt",
55
+ ])
56
+ }
57
+
58
+ const keyToJwk = key => {
59
+ const k = key
60
+ .toString("base64")
61
+ .replace(/\+/g, "-")
62
+ .replace(/\//g, "_")
63
+ .replace(/\=/g, "")
64
+ return {kty: "oct", k: k, ext: false, alg: "A256GCM"}
65
+ }