@mblaney/holster 1.0.29 → 1.0.31

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.29",
3
+ "version": "1.0.31",
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/buffer.js CHANGED
@@ -161,6 +161,14 @@ const processArrayLikeInput = input => {
161
161
  }
162
162
  }
163
163
 
164
+ bytes = new Uint8Array(input)
165
+ } else if (
166
+ typeof input === "object" &&
167
+ input !== null &&
168
+ typeof input.byteLength === "number"
169
+ ) {
170
+ // Handle ArrayBuffer from different contexts (e.g. jsdom)
171
+ validateBufferSize(input.byteLength, "ArrayBuffer-like processing")
164
172
  bytes = new Uint8Array(input)
165
173
  } else if (
166
174
  typeof input === "object" &&
@@ -184,7 +192,6 @@ const processArrayLikeInput = input => {
184
192
  )
185
193
  }
186
194
  }
187
-
188
195
  bytes = new Uint8Array(arr)
189
196
  } else {
190
197
  throw new TypeError("Unsupported input type: " + typeof input)
package/src/ham.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import * as utils from "./utils.js"
2
2
  import SEA from "./sea.js"
3
3
 
4
- const LISTENER_DELAY = 100
5
4
  // Maximum number of souls to keep in memory
6
5
  const MAX_GRAPH_SIZE = 10000
7
6
 
@@ -51,6 +50,7 @@ Ham.mix = async (change, graph, secure, listen) => {
51
50
  const machine = Date.now()
52
51
  const now = {}
53
52
  const defer = {}
53
+ const listeners = [] // Collect listeners to fire after store.put
54
54
  const validProperties = new Map() // Track valid properties per soul
55
55
  const validTimestamps = new Map() // Track valid timestamps per soul
56
56
  let wait = 0
@@ -187,30 +187,23 @@ Ham.mix = async (change, graph, secure, listen) => {
187
187
  graph[soul]._["s"][key] = now[soul]._["s"][key] = node._["s"][key]
188
188
  }
189
189
  }
190
- // Call event listeners for update on key, mix is called before
191
- // put has finished so wait for what could be multiple nested
192
- // updates on a node.
190
+ // Collect event listeners to fire after store.put completes.
191
+ // This ensures listeners always fire with data that's been stored.
193
192
  if (listen[soul]) {
194
- setTimeout(() => {
195
- if (listen[soul]) {
196
- listen[soul]
197
- .filter(l => utils.match(l["."], key))
198
- .forEach(l => l.cb())
199
- }
200
- }, LISTENER_DELAY)
193
+ listen[soul]
194
+ .filter(l => utils.match(l["."], key))
195
+ .forEach(l => listeners.push(l.cb))
201
196
  }
202
197
  updated = true
203
198
  }
204
199
  }
205
200
  }
206
201
 
207
- // Call event listeners for update on soul.
202
+ // Collect event listeners for update on soul.
208
203
  if (updated && listen[soul]) {
209
- setTimeout(() => {
210
- if (listen[soul]) {
211
- listen[soul].filter(l => utils.match(l["."])).forEach(l => l.cb())
212
- }
213
- }, LISTENER_DELAY)
204
+ listen[soul]
205
+ .filter(l => utils.match(l["."]))
206
+ .forEach(l => listeners.push(l.cb))
214
207
  }
215
208
  }
216
209
 
@@ -228,7 +221,7 @@ Ham.mix = async (change, graph, secure, listen) => {
228
221
  remove.forEach(({soul}) => delete graph[soul])
229
222
  }
230
223
 
231
- return {now: now, defer: defer, wait: wait}
224
+ return {now: now, defer: defer, wait: wait, listeners: listeners}
232
225
  }
233
226
 
234
227
  export default Ham
package/src/holster.js CHANGED
@@ -673,17 +673,23 @@ const Holster = opt => {
673
673
  const {item, soul} = resolve({off: true}, cb)
674
674
  if (!soul) return
675
675
 
676
- // Check if item is a rel and remove event listener for the node.
676
+ // Remove listener immediately from the original soul to avoid race condition.
677
+ // The listener may be on a related soul if item is a rel, but we remove
678
+ // from the original soul first since that's where it's initially registered.
679
+ wire.off({"#": soul}, map.get(cb))
680
+
681
+ // Check if item is a rel and remove from the related node as well.
677
682
  wire.get({"#": soul, ".": item}, msg => {
678
683
  if (msg.err) {
679
- console.log(`error getting ${soul}.${item}: ${msg.err}`)
684
+ // Even if get fails, we've already removed from original soul
685
+ map.delete(cb)
686
+ allctx.delete(ctxid)
680
687
  return
681
688
  }
682
689
 
683
690
  const current = msg.put && msg.put[soul] && msg.put[soul][item]
684
691
  const id = utils.rel.is(current)
685
692
  if (id) wire.off({"#": id}, map.get(cb))
686
- else wire.off({"#": soul}, map.get(cb))
687
693
  map.delete(cb)
688
694
  allctx.delete(ctxid)
689
695
  })
package/src/user.js CHANGED
@@ -29,7 +29,7 @@ const User = (opt, wire) => {
29
29
 
30
30
  const pub = pubs.shift()
31
31
  wire.get(
32
- {"#": pub},
32
+ {"#": pub, ".": ["auth", "pub", "epub"]},
33
33
  async msg => {
34
34
  if (msg.err) {
35
35
  done(`error getting ${pub}: ${msg.err}`)
@@ -73,46 +73,7 @@ const User = (opt, wire) => {
73
73
  return
74
74
  }
75
75
 
76
- // Migration: broadcast signed data on login if enabled
77
- // Can be enabled by setting localStorage.setItem("holster_migrate_signatures", "true")
78
- const migrationEnabled =
79
- typeof globalThis.localStorage !== "undefined" &&
80
- globalThis.localStorage.getItem("holster_migrate_signatures") ===
81
- "true"
82
-
83
- if (migrationEnabled) {
84
- const migrationData = {}
85
- for (const key of Object.keys(data)) {
86
- if (
87
- key !== "_" &&
88
- key !== userPublicKey &&
89
- key !== userSignature
90
- ) {
91
- migrationData[key] = data[key]
92
- }
93
- }
94
- // Sign the timestamp for migration
95
- const timestamp = Date.now()
96
- const sig = await SEA.signTimestamp(timestamp, user.is)
97
- const graph = utils.graph(
98
- pub,
99
- migrationData,
100
- sig,
101
- data.pub,
102
- timestamp,
103
- )
104
- wire.put(graph, err => {
105
- if (err) {
106
- // Log migration error but don't fail authentication
107
- console.log(
108
- `warning: failed to migrate signatures on login: ${err}`,
109
- )
110
- }
111
- done(null)
112
- })
113
- } else {
114
- done(null)
115
- }
76
+ done(null)
116
77
  },
117
78
  {wait: 5000},
118
79
  )
package/src/utils.js CHANGED
@@ -118,6 +118,9 @@ export const match = (lex, key) => {
118
118
 
119
119
  if (typeof lex === "string") return lex === key
120
120
 
121
+ // Array support: check if key is in the array
122
+ if (Array.isArray(lex)) return lex.includes(key)
123
+
121
124
  if (!obj.is(lex) || !key) return false
122
125
 
123
126
  const prefix = lex["*"]
package/src/wire.js CHANGED
@@ -197,6 +197,54 @@ const Wire = opt => {
197
197
  const rateLimiter = createRateLimiter(isTestEnv)
198
198
  const connectionManager = createConnectionManager(opt.maxConnections || 1000)
199
199
 
200
+ // The check function is required because user data must provide a public key
201
+ // so that it can be verified. The public key might verify the provided
202
+ // signature but not actually match the user under which the data is being
203
+ // stored. To avoid this, the current data on a soul needs to be checked to
204
+ // make sure the stored public key matches the one provided with the update.
205
+ const check = async (data, send, cb) => {
206
+ const key = utils.userPublicKey
207
+
208
+ for (const soul of Object.keys(data)) {
209
+ const msg = await new Promise(res => {
210
+ getWithCallback({"#": soul, ".": key}, res, send)
211
+ })
212
+ if (msg.err) {
213
+ if (cb) cb(msg.err)
214
+ return false
215
+ }
216
+
217
+ const node = data[soul]
218
+ // If there is no current node then the data is ok to write without
219
+ // matching public keys, as the provided soul also needs a rel on the
220
+ // parent node which then also requires checking. Otherwise public keys
221
+ // need to match for existing data. If the incoming update doesn't
222
+ // include a userPublicKey property at all, allow it - the update is
223
+ // not trying to change the public key.
224
+ if (
225
+ !msg.put ||
226
+ !msg.put[soul] ||
227
+ node[key] === undefined ||
228
+ msg.put[soul][key] === node[key]
229
+ ) {
230
+ continue
231
+ }
232
+
233
+ // If a soul exists but does not have a public key, then one should not be
234
+ // added because the node is not user data. The above check fails in this
235
+ // case if a public key is provided. Note that this is only an error case
236
+ // if called via the API, which is when a callback is provided here.
237
+ // (The wire spec can fetch and put data on the wire without a signature
238
+ // or public key and this can be ignored.)
239
+ if (cb) {
240
+ cb(`error in wire check public key does not match for soul: ${soul}`)
241
+ }
242
+ return false
243
+ }
244
+
245
+ return true
246
+ }
247
+
200
248
  const get = (msg, send) => {
201
249
  const ack = Get(msg.get, graph)
202
250
  if (ack) {
@@ -241,6 +289,8 @@ const Wire = opt => {
241
289
  return
242
290
  }
243
291
 
292
+ if (!(await check(update.now, send))) return
293
+
244
294
  store.put(update.now, err => {
245
295
  send(
246
296
  JSON.stringify({
@@ -249,6 +299,8 @@ const Wire = opt => {
249
299
  err: err,
250
300
  }),
251
301
  )
302
+ // Fire listeners after data is stored
303
+ update.listeners.forEach(cb => cb())
252
304
  })
253
305
 
254
306
  if (Object.keys(update.defer).length !== 0) {
@@ -342,6 +394,10 @@ const Wire = opt => {
342
394
  return
343
395
  }
344
396
 
397
+ if (!(await check(update.now, send, cb))) {
398
+ return
399
+ }
400
+
345
401
  // Seed pendingReferences with any new references from API calls
346
402
  for (const [soul, node] of Object.entries(update.now)) {
347
403
  if (node && typeof node === "object") {
@@ -355,7 +411,11 @@ const Wire = opt => {
355
411
  }
356
412
  }
357
413
 
358
- store.put(update.now, cb)
414
+ store.put(update.now, err => {
415
+ if (cb) cb(err)
416
+ // Fire listeners after data is stored
417
+ update.listeners.forEach(l => l())
418
+ })
359
419
 
360
420
  // Always put data on the wire spec even if no local update needed
361
421
  const sendResult = send(
@@ -672,7 +732,7 @@ const Wire = opt => {
672
732
  queueProcessor = setTimeout(processQueue, 50)
673
733
  }
674
734
  }
675
- if (!(opt.peers instanceof Array)) {
735
+ if (!(opt.peers instanceof Array) || opt.peers.length === 0) {
676
736
  opt.peers = [`ws://localhost:${opt.port || 8765}`]
677
737
  }
678
738
  opt.peers.forEach(peer => {
@@ -16,13 +16,17 @@ describe("holster.on", () => {
16
16
  })
17
17
 
18
18
  test("on for property on root then update - event", (t, done) => {
19
- // Listener doesn't get called for initial put.
20
- const callback = data => {
21
- assert.equal(data, "update")
22
- holster.get("key").off(callback)
23
- done()
24
- }
25
- holster.get("key").on(callback)
19
+ // Listener is now called for initial put and updates
20
+ let callCount = 0
21
+ holster.get("key").on(data => {
22
+ callCount++
23
+ if (callCount === 1) {
24
+ assert.equal(data, "value")
25
+ } else if (callCount === 2) {
26
+ assert.equal(data, "update")
27
+ done()
28
+ }
29
+ })
26
30
 
27
31
  holster.get("key").put("value", err => {
28
32
  assert.equal(err, null)
@@ -37,19 +41,27 @@ describe("holster.on", () => {
37
41
  let done1 = false
38
42
  let done2 = false
39
43
 
44
+ let callback1Count = 0
40
45
  const callback1 = data => {
41
- assert.equal(data, "update")
42
- holster.get("two").off(callback1)
43
- if (done2) done()
44
- else done1 = true
46
+ callback1Count++
47
+ if (callback1Count === 2) {
48
+ assert.equal(data, "update")
49
+ holster.get("two").off(callback1)
50
+ if (done2) done()
51
+ else done1 = true
52
+ }
45
53
  }
46
54
  holster.get("two").on(callback1)
47
55
 
56
+ let callback2Count = 0
48
57
  const callback2 = data => {
49
- assert.equal(data, "update")
50
- holster.get("two").off(callback2)
51
- if (done1) done()
52
- else done2 = true
58
+ callback2Count++
59
+ if (callback2Count === 2) {
60
+ assert.equal(data, "update")
61
+ holster.get("two").off(callback2)
62
+ if (done1) done()
63
+ else done2 = true
64
+ }
53
65
  }
54
66
  holster.get("two").on(callback2)
55
67
 
@@ -80,11 +92,12 @@ describe("holster.on", () => {
80
92
  holster.get("key3").put("value3", err => {
81
93
  assert.equal(err, null)
82
94
 
83
- let first = true
95
+ let callCount = 0
84
96
  holster.get("key3").on(data => {
85
- if (first) {
97
+ callCount++
98
+ if (callCount === 1) {
86
99
  assert.equal(data, "update1")
87
- } else {
100
+ } else if (callCount === 2) {
88
101
  assert.equal(data, "update2")
89
102
  done()
90
103
  }
@@ -95,7 +108,6 @@ describe("holster.on", () => {
95
108
  })
96
109
 
97
110
  setTimeout(() => {
98
- first = false
99
111
  holster.get("key3").put("update2", err => {
100
112
  assert.equal(err, null)
101
113
  })
@@ -112,10 +124,14 @@ describe("holster.on", () => {
112
124
 
113
125
  // Need to wait until after all initial puts are done to set on listeners.
114
126
  setTimeout(() => {
127
+ let completed = 0
115
128
  for (let i = 0; i < 5; i++) {
116
- holster.get("for" + i).on(data => {
117
- assert.equal(data, "update" + i)
118
- if (i === 4) done()
129
+ const idx = i // Capture value
130
+ holster.get("for" + idx).on(data => {
131
+ // Listener fires for updates (initial puts already completed before listener setup)
132
+ assert.equal(data, "update" + idx)
133
+ completed++
134
+ if (completed === 5) done()
119
135
  })
120
136
  }
121
137
 
@@ -154,6 +170,7 @@ describe("holster.on", () => {
154
170
  .get("hello")
155
171
  .next("world!")
156
172
  .on(data => {
173
+ // Listener fires for updates (initial put already completed before listener setup)
157
174
  assert.equal(data, "update")
158
175
  holster.get("hello").next("world!").off()
159
176
  done()
@@ -183,6 +200,7 @@ describe("holster.on", () => {
183
200
  number: 42,
184
201
  }
185
202
  holster.get("plain").on(data => {
203
+ // Listener fires for updates (initial put already completed before listener setup)
186
204
  assert.deepEqual(data, update)
187
205
  done()
188
206
  })
@@ -211,14 +229,20 @@ describe("holster.on", () => {
211
229
  has: "child update",
212
230
  },
213
231
  }
232
+ let parentFired = false
214
233
  holster.get("nested").on(data => {
215
- assert.deepEqual(data, {key: nested.key, ...update})
234
+ // Listener fires for updates (initial put already completed before listener setup)
235
+ if (!parentFired) {
236
+ parentFired = true
237
+ assert.deepEqual(data, {key: nested.key, ...update})
238
+ }
216
239
  })
217
240
 
218
241
  holster
219
242
  .get("nested")
220
243
  .next("child")
221
244
  .on(data => {
245
+ // Listener fires for updates (initial put already completed before listener setup)
222
246
  assert.deepEqual(data, update.child)
223
247
  done()
224
248
  })
@@ -31,11 +31,17 @@ describe("holster.user.on", () => {
31
31
  })
32
32
 
33
33
  test("on for property then update - event", (t, done) => {
34
- // Listener doesn't get called for initial put.
34
+ // Listener is now called for initial put and updates
35
+ let callCount = 0
35
36
  const callback = data => {
36
- assert.equal(data, "update")
37
- user.get("key").off(callback)
38
- done()
37
+ callCount++
38
+ if (callCount === 1) {
39
+ assert.equal(data, "value")
40
+ } else if (callCount === 2) {
41
+ assert.equal(data, "update")
42
+ user.get("key").off(callback)
43
+ done()
44
+ }
39
45
  }
40
46
  user.get("key").on(callback)
41
47
 
@@ -52,19 +58,27 @@ describe("holster.user.on", () => {
52
58
  let done1 = false
53
59
  let done2 = false
54
60
 
61
+ let callback1Count = 0
55
62
  const callback1 = data => {
56
- assert.equal(data, "update")
57
- user.get("two").off(callback1)
58
- if (done2) done()
59
- else done1 = true
63
+ callback1Count++
64
+ if (callback1Count === 2) {
65
+ assert.equal(data, "update")
66
+ user.get("two").off(callback1)
67
+ if (done2) done()
68
+ else done1 = true
69
+ }
60
70
  }
61
71
  user.get("two").on(callback1)
62
72
 
73
+ let callback2Count = 0
63
74
  const callback2 = data => {
64
- assert.equal(data, "update")
65
- user.get("two").off(callback2)
66
- if (done1) done()
67
- else done2 = true
75
+ callback2Count++
76
+ if (callback2Count === 2) {
77
+ assert.equal(data, "update")
78
+ user.get("two").off(callback2)
79
+ if (done1) done()
80
+ else done2 = true
81
+ }
68
82
  }
69
83
  user.get("two").on(callback2)
70
84
 
@@ -92,11 +106,12 @@ describe("holster.user.on", () => {
92
106
  user.get("key3").put("value3", err => {
93
107
  assert.equal(err, null)
94
108
 
95
- let first = true
109
+ let callCount = 0
96
110
  const callback = data => {
97
- if (first) {
111
+ callCount++
112
+ if (callCount === 1) {
98
113
  assert.equal(data, "update1")
99
- } else {
114
+ } else if (callCount === 2) {
100
115
  assert.equal(data, "update2")
101
116
  user.get("key3").off(callback)
102
117
  done()
@@ -109,7 +124,6 @@ describe("holster.user.on", () => {
109
124
  })
110
125
 
111
126
  setTimeout(() => {
112
- first = false
113
127
  user.get("key3").put("update2", err => {
114
128
  assert.equal(err, null)
115
129
  })
@@ -132,6 +146,7 @@ describe("holster.user.on", () => {
132
146
  for (let i = 0; i < 5; i++) {
133
147
  const idx = i // Capture value
134
148
  const callback = data => {
149
+ // Listener fires for updates (initial puts already completed before listener setup)
135
150
  assert.equal(data, "update" + idx)
136
151
  user.get("for" + idx).off(callback)
137
152
  count++
@@ -176,6 +191,7 @@ describe("holster.user.on", () => {
176
191
  assert.equal(err, null)
177
192
 
178
193
  const callback = data => {
194
+ // Listener fires for updates (initial put already completed before listener setup)
179
195
  assert.equal(data, "update")
180
196
  user.get("hello").next("world!").off(callback)
181
197
  done()
@@ -208,6 +224,7 @@ describe("holster.user.on", () => {
208
224
  }
209
225
 
210
226
  const callback = data => {
227
+ // Listener fires for updates (initial put already completed before listener setup)
211
228
  assert.deepEqual(data, update)
212
229
  user.get("plain").off(callback)
213
230
  done()
@@ -240,12 +257,18 @@ describe("holster.user.on", () => {
240
257
  },
241
258
  }
242
259
 
260
+ let parentFired = false
243
261
  const parentCallback = data => {
244
- assert.deepEqual(data, update)
262
+ // Listener fires for updates (initial put already completed before listener setup)
263
+ if (!parentFired) {
264
+ parentFired = true
265
+ assert.deepEqual(data, update)
266
+ }
245
267
  }
246
268
  user.get("nested").on(parentCallback)
247
269
 
248
270
  const childCallback = data => {
271
+ // Listener fires for updates (initial put already completed before listener setup)
249
272
  assert.deepEqual(data, update.child)
250
273
  user.get("nested").off(parentCallback)
251
274
  user.get("nested").next("child").off(childCallback)
@@ -86,22 +86,23 @@ describe("system - concurrent listeners", () => {
86
86
  holster.get("race").put({value: "first"}, err => {
87
87
  assert.equal(err, null)
88
88
 
89
- // Set up listener with once=true to read existing data
89
+ // Set up listener with _get=true to read existing data
90
+ // The listener will be called with the existing data via the get request
90
91
  holster.get("race").on(data => {
91
92
  results.push(data?.value)
92
93
  }, true)
93
94
 
94
- // Immediately put new data
95
+ // Immediately put new data - listener will fire for this put as well
95
96
  setTimeout(() => {
96
97
  holster.get("race").put({value: "second"}, err => {
97
98
  assert.equal(err, null)
98
99
 
99
100
  setTimeout(() => {
100
- // Should have received both values
101
+ // Should have received both values - one from get, one from put
101
102
  assert.equal(
102
103
  results.length >= 1,
103
104
  true,
104
- "Should receive at least initial value",
105
+ "Should receive at least initial value from get",
105
106
  )
106
107
  assert.equal(results[0], "first", "First value should be 'first'")
107
108
  done()
@@ -23,7 +23,8 @@ describe("system - listener before data", () => {
23
23
 
24
24
  // Give listener time to fire
25
25
  setTimeout(() => {
26
- // Should have received null (initial) and the test value
26
+ // With the new behavior, listener fires after data is stored
27
+ // Should have received the test value from the put
27
28
  const nonNull = results.filter(r => r !== null)
28
29
  assert.equal(
29
30
  nonNull.length >= 1,
@@ -103,21 +103,22 @@ describe("system - user concurrent listeners", () => {
103
103
  assert.equal(err, null)
104
104
 
105
105
  // Set up listener with _get=true to read existing data
106
+ // The listener will be called with the existing data via the get request
106
107
  user.get("race").on(data => {
107
108
  results.push(data?.value)
108
109
  }, true)
109
110
 
110
- // Immediately put new data
111
+ // Immediately put new data - listener will fire for this put as well
111
112
  setTimeout(() => {
112
113
  user.get("race").put({value: "second"}, err => {
113
114
  assert.equal(err, null)
114
115
 
115
116
  setTimeout(() => {
116
- // Should have received both values
117
+ // Should have received both values - one from get, one from put
117
118
  assert.equal(
118
119
  results.length >= 1,
119
120
  true,
120
- "Should receive at least initial value",
121
+ "Should receive at least initial value from get",
121
122
  )
122
123
  assert.equal(results[0], "first", "First value should be 'first'")
123
124
  done()
@@ -37,7 +37,8 @@ describe("system - user listener before data", () => {
37
37
 
38
38
  // Give listener time to fire
39
39
  setTimeout(() => {
40
- // Should have received the test value
40
+ // With the new behavior, listener fires after data is stored
41
+ // Should have received the test value from the put
41
42
  const nonNull = results.filter(r => r !== null)
42
43
  assert.equal(
43
44
  nonNull.length >= 1,