@mblaney/holster 1.0.16 → 1.0.18

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.18",
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,51 @@ 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 = async (retries = 0) => {
572
+ const data = await new Promise(res => {
573
+ const _ctxid = utils.text.random()
574
+ allctx.set(_ctxid, {chain: [{item: null, soul: id}]})
575
+ api(_ctxid).next(null, res, _opt)
576
+ })
577
+ if (data !== null || retries >= 5) {
578
+ cb(data)
579
+ } else {
580
+ // Data not ready, retry after delay
581
+ await new Promise(resolve => setTimeout(resolve, 50))
582
+ attemptRead(retries + 1)
583
+ }
584
+ }
585
+ attemptRead()
586
+ } else {
587
+ // It's a direct property or null
588
+ cb(current !== undefined ? current : null)
589
+ }
590
+ },
591
+ _opt,
592
+ )
593
+ })
594
+
595
+ // Register listener immediately to avoid missing updates due to
596
+ // queueing. Initially register the soul without _get, then update if
597
+ // it's a rel.
598
+ let initialLex
599
+ if (lex) initialLex = utils.obj.put(lex, "#", soul)
600
+ else initialLex = {"#": soul, ".": item}
601
+ wire.on(initialLex, map.get(cb), false, _opt)
602
+
603
+ // Check if item is a rel and update listener if needed.
604
+ // This happens async but the listener is already registered above.
552
605
  wire.get(
553
606
  {"#": soul, ".": item},
554
607
  msg => {
@@ -560,13 +613,18 @@ const Holster = opt => {
560
613
  const current = msg.put && msg.put[soul] && msg.put[soul][item]
561
614
  const id = utils.rel.is(current)
562
615
  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}
616
+ // It's a rel, need to switch listener to the related node
617
+ // First remove the initial listener
618
+ wire.off(initialLex, map.get(cb))
619
+ // Then add listener on the related node
620
+ let relLex
621
+ if (lex) relLex = utils.obj.put(lex, "#", id)
622
+ else relLex = {"#": id, ".": null}
623
+ wire.on(relLex, map.get(cb), _get, _opt)
624
+ } else if (_get) {
625
+ // Not a rel, but _get was requested, so trigger callback.
626
+ map.get(cb)()
568
627
  }
569
- wire.on(lex, map.get(cb), _get, _opt)
570
628
  },
571
629
  _opt,
572
630
  )
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
 
@@ -354,37 +353,37 @@ const Wire = opt => {
354
353
  // used whereas wire spec needs to handle clock skew for updates
355
354
  // across the network.
356
355
  const update = await Ham.mix(data, graph, opt.secure, listen)
357
- const none = Object.keys(update.now).length === 0
358
- if (none) {
359
- // Nothing to do.
360
- cb(null)
361
- return
362
- }
363
356
 
364
- if (!(await check(update.now, send, cb))) return
365
-
366
- // Seed pendingReferences with any new references from API calls
367
- for (const [soul, node] of Object.entries(update.now)) {
368
- if (node && typeof node === "object") {
369
- for (const [key, value] of Object.entries(node)) {
370
- const soulId = utils.rel.is(value)
371
- if (soulId) {
372
- // Add referenced soul to pending list
373
- pendingReferences.add(soulId)
357
+ if (Object.keys(update.now).length !== 0) {
358
+ if (!(await check(update.now, send, cb))) return
359
+
360
+ // Seed pendingReferences with any new references from API calls
361
+ for (const [soul, node] of Object.entries(update.now)) {
362
+ if (node && typeof node === "object") {
363
+ for (const [key, value] of Object.entries(node)) {
364
+ const soulId = utils.rel.is(value)
365
+ if (soulId) {
366
+ // Add referenced soul to pending list
367
+ pendingReferences.add(soulId)
368
+ }
374
369
  }
375
370
  }
376
371
  }
372
+
373
+ store.put(update.now, cb)
374
+ } else {
375
+ // No local update needed, but still callback
376
+ if (cb) cb(null)
377
377
  }
378
378
 
379
- store.put(update.now, cb)
380
- // Also put data on the wire spec.
379
+ // Always put data on the wire spec even if no local update needed
381
380
  const sendResult = send(
382
381
  JSON.stringify({
383
382
  "#": dup.track(utils.text.random(9)),
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})
@@ -587,8 +608,35 @@ const Wire = opt => {
587
608
  }
588
609
 
589
610
  // Process next message in queue
590
- const data = messageQueue.shift()
591
- sendToPeers(data)
611
+ const data = messageQueue[0]
612
+ const sent = sendToPeers(data)
613
+
614
+ if (sent) {
615
+ // Only remove from queue if successfully sent
616
+ messageQueue.shift()
617
+
618
+ // Start timeout for this message now that it's been sent
619
+ const msg = JSON.parse(data)
620
+ const trackId = msg["#"]
621
+ if (trackId && pendingTimeouts.has(trackId)) {
622
+ const timeoutConfig = pendingTimeouts.get(trackId)
623
+ pendingTimeouts.delete(trackId)
624
+
625
+ // Respond to callback with null if no response.
626
+ timeoutConfig.timeoutId = setTimeout(() => {
627
+ const cb = queue[trackId]
628
+ if (cb) {
629
+ const id = timeoutConfig.lex["#"]
630
+ const ack = {[id]: null}
631
+ if (typeof timeoutConfig.lex["."] === "string") {
632
+ ack[id] = {[timeoutConfig.lex["."]]: null}
633
+ }
634
+ cb({put: ack})
635
+ delete queue[trackId]
636
+ }
637
+ }, timeoutConfig.wait)
638
+ }
639
+ }
592
640
 
593
641
  // Schedule processing of next message if queue not empty
594
642
  if (messageQueue.length > 0) {
@@ -600,9 +648,11 @@ const Wire = opt => {
600
648
  }
601
649
 
602
650
  const sendToPeers = data => {
651
+ let sentToAtLeastOne = false
603
652
  peers.forEach(peer => {
604
653
  if (peer && peer.readyState === WebSocket.OPEN) {
605
654
  peer.send(data)
655
+ sentToAtLeastOne = true
606
656
  } else {
607
657
  const retryHandler = peer._retryHandler || createRetryHandler()
608
658
  peer._retryHandler = retryHandler
@@ -620,6 +670,7 @@ const Wire = opt => {
620
670
  }
621
671
  }
622
672
  })
673
+ return sentToAtLeastOne
623
674
  }
624
675
 
625
676
  const send = data => {
@@ -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
+ })