@mblaney/holster 1.0.16 → 1.0.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mblaney/holster",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
4
4
  "description": "Holster is a service that allows synchronising data between devices using Node, Deno, Bun or the browser.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/holster.js CHANGED
@@ -67,12 +67,22 @@ const Holster = opt => {
67
67
  for (const key of Object.keys(msg.put[soul])) {
68
68
  const id = utils.rel.is(msg.put[soul][key])
69
69
  if (id) {
70
- const data = await new Promise(res => {
71
- const _ctxid = utils.text.random()
72
- allctx.set(_ctxid, {chain: [{item: null, soul: id}]})
73
- api(_ctxid).next(null, res, _opt)
74
- })
75
- msg.put[soul][key] = data
70
+ // Due to message queuing, the related node might not be available yet
71
+ // Retry with delays if we get null
72
+ const attemptRead = async (retries = 0) => {
73
+ const data = await new Promise(res => {
74
+ const _ctxid = utils.text.random()
75
+ allctx.set(_ctxid, {chain: [{item: null, soul: id}]})
76
+ api(_ctxid).next(null, res, _opt)
77
+ })
78
+ if (data !== null || retries >= 5) {
79
+ return data
80
+ }
81
+ // Data not ready, retry after delay
82
+ await new Promise(resolve => setTimeout(resolve, 50))
83
+ return attemptRead(retries + 1)
84
+ }
85
+ msg.put[soul][key] = await attemptRead()
76
86
  }
77
87
  }
78
88
  ack(msg.put[soul])
@@ -547,8 +557,57 @@ const Holster = opt => {
547
557
  allctx.set(ctxid, {chain: [{item: item, soul: soul}], on: true})
548
558
  // Map the user's callback because it can also be passed to off,
549
559
  // so need a reference to it to compare them.
550
- map.set(cb, () => api(ctxid).next(null, cb, _opt))
551
- // Check if item is a rel and add event listener for the node.
560
+ // Create a new context for each listener invocation to avoid mutation
561
+ map.set(cb, () => {
562
+ // When listener fires, re-check if the item is now a rel.
563
+ wire.get(
564
+ {"#": soul, ".": item},
565
+ msg => {
566
+ const current = msg.put && msg.put[soul] && msg.put[soul][item]
567
+ const id = utils.rel.is(current)
568
+ if (id) {
569
+ // It's a rel, read the related node. It might not be in the graph
570
+ // yet when the listener fires, so we retry with delays.
571
+ const attemptRead = (retries = 0) => {
572
+ wire.get(
573
+ {"#": id},
574
+ relMsg => {
575
+ if (relMsg.put && relMsg.put[id]) {
576
+ delete relMsg.put[id]._
577
+ delete relMsg.put[id][utils.userPublicKey]
578
+ delete relMsg.put[id][utils.userSignature]
579
+ cb(relMsg.put[id])
580
+ } else if (retries < 5) {
581
+ // Data not ready yet, retry after a delay
582
+ setTimeout(() => attemptRead(retries + 1), 50)
583
+ } else {
584
+ // Give up after retries
585
+ cb(null)
586
+ }
587
+ },
588
+ _opt,
589
+ )
590
+ }
591
+ attemptRead()
592
+ } else {
593
+ // It's a direct property or null
594
+ cb(current !== undefined ? current : null)
595
+ }
596
+ },
597
+ _opt,
598
+ )
599
+ })
600
+
601
+ // Register listener immediately to avoid missing updates due to
602
+ // queueing. Initially register the soul without _get, then update if
603
+ // it's a rel.
604
+ let initialLex
605
+ if (lex) initialLex = utils.obj.put(lex, "#", soul)
606
+ else initialLex = {"#": soul, ".": item}
607
+ wire.on(initialLex, map.get(cb), false, _opt)
608
+
609
+ // Check if item is a rel and update listener if needed.
610
+ // This happens async but the listener is already registered above.
552
611
  wire.get(
553
612
  {"#": soul, ".": item},
554
613
  msg => {
@@ -560,13 +619,18 @@ const Holster = opt => {
560
619
  const current = msg.put && msg.put[soul] && msg.put[soul][item]
561
620
  const id = utils.rel.is(current)
562
621
  if (id) {
563
- if (lex) lex = utils.obj.put(lex, "#", id)
564
- else lex = {"#": id, ".": null}
565
- } else {
566
- if (lex) lex = utils.obj.put(lex, "#", soul)
567
- else lex = {"#": soul, ".": item}
622
+ // It's a rel, need to switch listener to the related node
623
+ // First remove the initial listener
624
+ wire.off(initialLex, map.get(cb))
625
+ // Then add listener on the related node
626
+ let relLex
627
+ if (lex) relLex = utils.obj.put(lex, "#", id)
628
+ else relLex = {"#": id, ".": null}
629
+ wire.on(relLex, map.get(cb), _get, _opt)
630
+ } else if (_get) {
631
+ // Not a rel, but _get was requested, so trigger callback.
632
+ map.get(cb)()
568
633
  }
569
- wire.on(lex, map.get(cb), _get, _opt)
570
634
  },
571
635
  _opt,
572
636
  )
package/src/radisk.js CHANGED
@@ -58,7 +58,7 @@ const Radisk = opt => {
58
58
  // Performance logging
59
59
  const perfLog = (operation, startTime, key, size) => {
60
60
  const duration = Date.now() - startTime
61
- if (duration > 1000) {
61
+ if (duration > 2000) {
62
62
  console.log(
63
63
  `[RADISK-SLOW] ${operation}: ${duration}ms for ${key}${size ? ` (${size} bytes)` : ""}`,
64
64
  )
package/src/wire.js CHANGED
@@ -178,6 +178,9 @@ const Wire = opt => {
178
178
  // Track references we want but don't have yet
179
179
  const pendingReferences = new Set()
180
180
 
181
+ // Track pending timeouts that should start when message is sent
182
+ const pendingTimeouts = new Map()
183
+
181
184
  // Helper to check if we have a soul in memory or storage
182
185
  const hasSoul = async soul => {
183
186
  if (graph[soul]) return true
@@ -318,24 +321,20 @@ const Wire = opt => {
318
321
  if (err) console.log(err)
319
322
 
320
323
  queue[track] = cb
324
+
325
+ // Store timeout config to start after message is sent from queue
326
+ pendingTimeouts.set(track, {
327
+ lex: lex,
328
+ wait: _opt.wait || 100,
329
+ })
330
+
321
331
  const sendResult = send(request)
322
332
  if (sendResult && sendResult.err) {
323
333
  cb({err: sendResult.err})
324
334
  delete queue[track]
335
+ pendingTimeouts.delete(track)
325
336
  return
326
337
  }
327
-
328
- // Respond to callback with null if no response.
329
- setTimeout(() => {
330
- const cb = queue[track]
331
- if (cb) {
332
- const id = lex["#"]
333
- const ack = {[id]: null}
334
- if (typeof lex["."] === "string") ack[id] = {[lex["."]]: null}
335
- cb({put: ack})
336
- delete queue[track]
337
- }
338
- }, _opt.wait || 100)
339
338
  })
340
339
  }
341
340
 
@@ -384,7 +383,7 @@ const Wire = opt => {
384
383
  put: data,
385
384
  }),
386
385
  )
387
- // Handle queue overflow error
386
+ // Handle queue overflow error.
388
387
  if (sendResult && sendResult.err) {
389
388
  if (cb) cb(sendResult.err)
390
389
  return
@@ -432,6 +431,28 @@ const Wire = opt => {
432
431
  }
433
432
 
434
433
  const send = (data, isBinary) => {
434
+ // Start timeout for this message now that it's being sent
435
+ const msg = JSON.parse(data)
436
+ const trackId = msg["#"]
437
+ if (trackId && pendingTimeouts.has(trackId)) {
438
+ const timeoutConfig = pendingTimeouts.get(trackId)
439
+ pendingTimeouts.delete(trackId)
440
+
441
+ // Respond to callback with null if no response.
442
+ timeoutConfig.timeoutId = setTimeout(() => {
443
+ const cb = queue[trackId]
444
+ if (cb) {
445
+ const id = timeoutConfig.lex["#"]
446
+ const ack = {[id]: null}
447
+ if (typeof timeoutConfig.lex["."] === "string") {
448
+ ack[id] = {[timeoutConfig.lex["."]]: null}
449
+ }
450
+ cb({put: ack})
451
+ delete queue[trackId]
452
+ }
453
+ }, timeoutConfig.wait)
454
+ }
455
+
435
456
  clients().forEach(client => {
436
457
  if (client && client.readyState === WebSocket.OPEN) {
437
458
  client.send(data, {binary: isBinary})
@@ -590,6 +611,28 @@ const Wire = opt => {
590
611
  const data = messageQueue.shift()
591
612
  sendToPeers(data)
592
613
 
614
+ // Start timeout for this message now that it's been sent
615
+ const msg = JSON.parse(data)
616
+ const trackId = msg["#"]
617
+ if (trackId && pendingTimeouts.has(trackId)) {
618
+ const timeoutConfig = pendingTimeouts.get(trackId)
619
+ pendingTimeouts.delete(trackId)
620
+
621
+ // Respond to callback with null if no response.
622
+ timeoutConfig.timeoutId = setTimeout(() => {
623
+ const cb = queue[trackId]
624
+ if (cb) {
625
+ const id = timeoutConfig.lex["#"]
626
+ const ack = {[id]: null}
627
+ if (typeof timeoutConfig.lex["."] === "string") {
628
+ ack[id] = {[timeoutConfig.lex["."]]: null}
629
+ }
630
+ cb({put: ack})
631
+ delete queue[trackId]
632
+ }
633
+ }, timeoutConfig.wait)
634
+ }
635
+
593
636
  // Schedule processing of next message if queue not empty
594
637
  if (messageQueue.length > 0) {
595
638
  // Add delay between messages to respect rate limits (300 req/min = 200ms)
@@ -35,11 +35,12 @@ describe("holster.user.on", () => {
35
35
  user.get("key").put("value", err => {
36
36
  assert.equal(err, null)
37
37
 
38
- user.get("key").on(data => {
38
+ const callback = data => {
39
39
  assert.equal(data, "update")
40
- user.get("key").off()
40
+ user.get("key").off(callback)
41
41
  done()
42
- })
42
+ }
43
+ user.get("key").on(callback)
43
44
 
44
45
  user.get("key").put("update", err => {
45
46
  assert.equal(err, null)
@@ -64,15 +65,16 @@ describe("holster.user.on", () => {
64
65
  assert.equal(err, null)
65
66
 
66
67
  let first = true
67
- user.get("key3").on(data => {
68
+ const callback = data => {
68
69
  if (first) {
69
70
  assert.equal(data, "update1")
70
71
  } else {
71
72
  assert.equal(data, "update2")
72
- user.get("key3").off()
73
+ user.get("key3").off(callback)
73
74
  done()
74
75
  }
75
- })
76
+ }
77
+ user.get("key3").on(callback)
76
78
 
77
79
  user.get("key3").put("update1", err => {
78
80
  assert.equal(err, null)
@@ -103,11 +105,13 @@ describe("holster.user.on", () => {
103
105
  setTimeout(async () => {
104
106
  let count = 0
105
107
  for (let i = 0; i < 5; i++) {
106
- user.get("for" + i).on(data => {
107
- assert.equal(data, "update" + i)
108
- user.get("for" + i).off()
108
+ const idx = i // Capture value
109
+ const callback = data => {
110
+ assert.equal(data, "update" + idx)
111
+ user.get("for" + idx).off(callback)
109
112
  count++
110
- })
113
+ }
114
+ user.get("for" + idx).on(callback)
111
115
  }
112
116
 
113
117
  setTimeout(async () => {
@@ -130,11 +134,12 @@ describe("holster.user.on", () => {
130
134
  assert.equal(err, null)
131
135
 
132
136
  setTimeout(() => {
133
- user.get("key4").on(data => {
137
+ const callback = data => {
134
138
  assert.equal(data, "value4")
135
- user.get("key4").off()
139
+ user.get("key4").off(callback)
136
140
  done()
137
- }, true)
141
+ }
142
+ user.get("key4").on(callback, true)
138
143
  }, 100)
139
144
  })
140
145
  })
@@ -147,14 +152,12 @@ describe("holster.user.on", () => {
147
152
  .put("ok", err => {
148
153
  assert.equal(err, null)
149
154
 
150
- user
151
- .get("hello")
152
- .next("world!")
153
- .on(data => {
154
- assert.equal(data, "update")
155
- user.get("hello").next("world!").off()
156
- done()
157
- })
155
+ const callback = data => {
156
+ assert.equal(data, "update")
157
+ user.get("hello").next("world!").off(callback)
158
+ done()
159
+ }
160
+ user.get("hello").next("world!").on(callback)
158
161
 
159
162
  setTimeout(() => {
160
163
  user
@@ -181,10 +184,12 @@ describe("holster.user.on", () => {
181
184
  number: 42,
182
185
  }
183
186
 
184
- user.get("plain").on(data => {
187
+ const callback = data => {
185
188
  assert.deepEqual(data, update)
189
+ user.get("plain").off(callback)
186
190
  done()
187
- })
191
+ }
192
+ user.get("plain").on(callback)
188
193
 
189
194
  setTimeout(() => {
190
195
  user.get("plain").put(update, err => {
@@ -212,17 +217,18 @@ describe("holster.user.on", () => {
212
217
  },
213
218
  }
214
219
 
215
- user.get("nested").on(data => {
220
+ const parentCallback = data => {
216
221
  assert.deepEqual(data, update)
217
- })
222
+ }
223
+ user.get("nested").on(parentCallback)
218
224
 
219
- user
220
- .get("nested")
221
- .next("child")
222
- .on(data => {
223
- assert.deepEqual(data, update.child)
224
- done()
225
- })
225
+ const childCallback = data => {
226
+ assert.deepEqual(data, update.child)
227
+ user.get("nested").off(parentCallback)
228
+ user.get("nested").next("child").off(childCallback)
229
+ done()
230
+ }
231
+ user.get("nested").next("child").on(childCallback)
226
232
 
227
233
  setTimeout(() => {
228
234
  user.get("nested").put(update, err => {
@@ -0,0 +1,29 @@
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("system - basic setup", () => {
8
+ const wss = new Server("ws://localhost:9001")
9
+ const holster = Holster({file: "test/system/basic", wss: wss})
10
+
11
+ test("put and get data", (t, done) => {
12
+ holster.get("test").put({message: "hello"}, err => {
13
+ assert.equal(err, null)
14
+
15
+ holster.get("test", data => {
16
+ assert.notEqual(data, null)
17
+ assert.equal(data.message, "hello")
18
+ done()
19
+ })
20
+ })
21
+ })
22
+
23
+ test("cleanup", (t, done) => {
24
+ fs.rm("test/system/basic", {recursive: true, force: true}, err => {
25
+ assert.equal(err, null)
26
+ done()
27
+ })
28
+ })
29
+ })
@@ -0,0 +1,41 @@
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("system - chained get with on", () => {
8
+ const wss = new Server("ws://localhost:9002")
9
+ const holster = Holster({file: "test/system/chained-on", wss: wss})
10
+
11
+ test("on listener fires with null when no data exists", (t, done) => {
12
+ holster.get("key").on(data => {
13
+ // This is the actual behavior - on fires with null for non-existent keys
14
+ assert.equal(data, null)
15
+ done()
16
+ }, true)
17
+ })
18
+
19
+ test("put then on listener receives data", (t, done) => {
20
+ // Put data first
21
+ holster.get("key2").put({value: "test"}, err => {
22
+ assert.equal(err, null)
23
+
24
+ // Then set up listener - should get the existing data
25
+ setTimeout(() => {
26
+ holster.get("key2").on(data => {
27
+ assert.notEqual(data, null)
28
+ assert.equal(data.value, "test")
29
+ done()
30
+ }, true)
31
+ }, 50)
32
+ })
33
+ })
34
+
35
+ test("cleanup", (t, done) => {
36
+ fs.rm("test/system/chained-on", {recursive: true, force: true}, err => {
37
+ assert.equal(err, null)
38
+ done()
39
+ })
40
+ })
41
+ })
@@ -0,0 +1,124 @@
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("system - concurrent listeners", () => {
8
+ const wss = new Server("ws://localhost:9005")
9
+ const holster = Holster({file: "test/system/concurrent-listeners", wss: wss})
10
+
11
+ test("multiple listeners on same path receive data", (t, done) => {
12
+ let listener1Called = false
13
+ let listener2Called = false
14
+
15
+ holster.get("key").put({value: "test"}, err => {
16
+ assert.equal(err, null)
17
+
18
+ // Set up two listeners on the same path
19
+ holster.get("key").on(data => {
20
+ listener1Called = true
21
+ assert.notEqual(data, null)
22
+ assert.equal(data.value, "test")
23
+ checkComplete()
24
+ }, true)
25
+
26
+ holster.get("key").on(data => {
27
+ listener2Called = true
28
+ assert.notEqual(data, null)
29
+ assert.equal(data.value, "test")
30
+ checkComplete()
31
+ }, true)
32
+
33
+ function checkComplete() {
34
+ if (listener1Called && listener2Called) {
35
+ done()
36
+ }
37
+ }
38
+ })
39
+ })
40
+
41
+ test("nested listeners on parent and child paths", (t, done) => {
42
+ let parentCalled = false
43
+ let childCalled = false
44
+
45
+ holster.get("parent").put({value: "parent-value"}, err => {
46
+ assert.equal(err, null)
47
+
48
+ holster
49
+ .get("parent")
50
+ .next("child")
51
+ .put({value: "child-value"}, err => {
52
+ assert.equal(err, null)
53
+
54
+ // Listen on parent
55
+ holster.get("parent").on(data => {
56
+ parentCalled = true
57
+ assert.notEqual(data, null)
58
+ assert.equal(data.value, "parent-value")
59
+ checkComplete()
60
+ }, true)
61
+
62
+ // Listen on child
63
+ holster
64
+ .get("parent")
65
+ .next("child")
66
+ .on(data => {
67
+ childCalled = true
68
+ assert.notEqual(data, null)
69
+ assert.equal(data.value, "child-value")
70
+ checkComplete()
71
+ }, true)
72
+
73
+ function checkComplete() {
74
+ if (parentCalled && childCalled) {
75
+ done()
76
+ }
77
+ }
78
+ })
79
+ })
80
+ })
81
+
82
+ test("listener race condition - data before and after listener setup", (t, done) => {
83
+ const results = []
84
+
85
+ // Put data first
86
+ holster.get("race").put({value: "first"}, err => {
87
+ assert.equal(err, null)
88
+
89
+ // Set up listener with once=true to read existing data
90
+ holster.get("race").on(data => {
91
+ results.push(data?.value)
92
+ }, true)
93
+
94
+ // Immediately put new data
95
+ setTimeout(() => {
96
+ holster.get("race").put({value: "second"}, err => {
97
+ assert.equal(err, null)
98
+
99
+ setTimeout(() => {
100
+ // Should have received both values
101
+ assert.equal(
102
+ results.length >= 1,
103
+ true,
104
+ "Should receive at least initial value",
105
+ )
106
+ assert.equal(results[0], "first", "First value should be 'first'")
107
+ done()
108
+ }, 100)
109
+ })
110
+ }, 50)
111
+ })
112
+ })
113
+
114
+ test("cleanup", (t, done) => {
115
+ fs.rm(
116
+ "test/system/concurrent-listeners",
117
+ {recursive: true, force: true},
118
+ err => {
119
+ assert.equal(err, null)
120
+ done()
121
+ },
122
+ )
123
+ })
124
+ })
@@ -0,0 +1,30 @@
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("system - immediate read after write", () => {
8
+ const wss = new Server("ws://localhost:9008")
9
+ const holster = Holster({file: "test/system/immediate-read", wss: wss})
10
+
11
+ test("get immediately after put returns data despite queue delays", (t, done) => {
12
+ holster.get("testkey").put({value: "test"}, err => {
13
+ assert.equal(err, null)
14
+
15
+ // Immediate get after put - validates retry logic handles queued writes
16
+ holster.get("testkey", data => {
17
+ assert.notEqual(data, null)
18
+ assert.equal(data.value, "test")
19
+ done()
20
+ })
21
+ })
22
+ })
23
+
24
+ test("cleanup", (t, done) => {
25
+ fs.rm("test/system/immediate-read", {recursive: true, force: true}, err => {
26
+ assert.equal(err, null)
27
+ done()
28
+ })
29
+ })
30
+ })
@@ -0,0 +1,50 @@
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("system - listener before data", () => {
8
+ const wss = new Server("ws://localhost:9007")
9
+ const holster = Holster({file: "test/system/listener-before-data", wss: wss})
10
+
11
+ test("listener set up before data exists receives data when populated", (t, done) => {
12
+ const results = []
13
+
14
+ // Set up listener first - this is the browser Display.js pattern
15
+ holster.get("testkey").on(data => {
16
+ results.push(data)
17
+ })
18
+
19
+ // Give listener time to set up
20
+ setTimeout(() => {
21
+ holster.get("testkey").put({value: "test"}, err => {
22
+ assert.equal(err, null)
23
+
24
+ // Give listener time to fire
25
+ setTimeout(() => {
26
+ // Should have received null (initial) and the test value
27
+ const nonNull = results.filter(r => r !== null)
28
+ assert.equal(
29
+ nonNull.length >= 1,
30
+ true,
31
+ "Should have at least one non-null result",
32
+ )
33
+ assert.equal(nonNull[0].value, "test")
34
+ done()
35
+ }, 200)
36
+ })
37
+ }, 100)
38
+ })
39
+
40
+ test("cleanup", (t, done) => {
41
+ fs.rm(
42
+ "test/system/listener-before-data",
43
+ {recursive: true, force: true},
44
+ err => {
45
+ assert.equal(err, null)
46
+ done()
47
+ },
48
+ )
49
+ })
50
+ })