@mblaney/holster 2.0.0 → 2.0.2

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": "2.0.0",
3
+ "version": "2.0.2",
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
@@ -420,9 +420,7 @@ const Holster = opt => {
420
420
  // ctx already removed by another chained callback is ok?
421
421
  if (!ctx) return
422
422
 
423
- if (!ctx.cb) {
424
- if (!cb) return
425
-
423
+ if (!ctx.cb && cb) {
426
424
  // This (and ack) allows nested objects to set their own callbacks.
427
425
  ctx.cb = cb
428
426
  cb = null
@@ -762,10 +760,7 @@ const Holster = opt => {
762
760
  // First remove the initial listener
763
761
  wire.off(initialLex, map.get(cb))
764
762
  // Then add listener on the related node
765
- let relLex
766
- if (lex) relLex = utils.obj.put(initialLex, "#", id)
767
- else relLex = {"#": id, ".": null}
768
- wire.on(relLex, map.get(cb), false, _opt)
763
+ wire.on({"#": id, ".": null}, map.get(cb), false, _opt)
769
764
  if (_get) map.get(cb)()
770
765
  } else if (_get) {
771
766
  // Not a rel, but _get was requested, so trigger callback.
package/src/radisk.js CHANGED
@@ -55,16 +55,6 @@ const Radisk = opt => {
55
55
  return
56
56
  }
57
57
 
58
- // Performance logging
59
- const perfLog = (operation, startTime, key, size) => {
60
- const duration = Date.now() - startTime
61
- if (duration > 2000) {
62
- console.log(
63
- `[RADISK-SLOW] ${operation}: ${duration}ms for ${key}${size ? ` (${size} bytes)` : ""}`,
64
- )
65
- }
66
- }
67
-
68
58
  // Memory monitoring and cache cleanup
69
59
  const checkMemoryUsage = () => {
70
60
  if (typeof process === "undefined" || !process.memoryUsage) return
@@ -129,7 +119,6 @@ const Radisk = opt => {
129
119
  }
130
120
 
131
121
  pendingReads.set(key, [cb])
132
- const readStart = Date.now()
133
122
  // If radisk.read genuinely hangs (fd leak, stuck I/O), recover
134
123
  // callbacks as a cache miss so callers can fall through to the wire path.
135
124
  // 30s is long enough to distinguish a hang from a slow-but-healthy read.
@@ -142,7 +131,6 @@ const Radisk = opt => {
142
131
  }, 30000)
143
132
  return radisk.read(key, (err, result) => {
144
133
  clearTimeout(timeout)
145
- perfLog("read", readStart, key)
146
134
  const callbacks = pendingReads.get(key) || []
147
135
  pendingReads.delete(key)
148
136
  callbacks.forEach(callback => callback(err, result))
@@ -175,7 +163,6 @@ const Radisk = opt => {
175
163
  return (radisk.thrash.more = true)
176
164
  }
177
165
 
178
- const thrashStart = Date.now()
179
166
  clearTimeout(radisk.batch.timeout)
180
167
  radisk.thrash.more = false
181
168
  radisk.thrash.ing = true
@@ -191,7 +178,6 @@ const Radisk = opt => {
191
178
  // file needs to be split.
192
179
  if (++i > 1) return
193
180
 
194
- perfLog("thrash", thrashStart, `batch-${batch.ed}`)
195
181
  if (err) opt.log(err)
196
182
  batch.acks.forEach(cb => cb(err))
197
183
  radisk.thrash.at = null
@@ -350,7 +336,6 @@ const Radisk = opt => {
350
336
  Radix.map(rad, write.each, true)
351
337
  // There is always accumulated write.text to store once write.each has
352
338
  // finished.
353
- const writeStart = Date.now()
354
339
  opt.store.put(file, write.text, err => {
355
340
  // If a split occurred the cached radix tree still contains entries that
356
341
  // were moved to the sub-file. Invalidate so the next save.mix re-reads
@@ -359,7 +344,6 @@ const Radisk = opt => {
359
344
  cache.delete(file)
360
345
  fileListCache = null
361
346
  }
362
- perfLog("file-write", writeStart, file, write.text.length)
363
347
  cb(err)
364
348
  })
365
349
  }
package/src/store.js CHANGED
@@ -197,7 +197,7 @@ const Store = opt => {
197
197
  const radisk = Radisk(opt)
198
198
 
199
199
  return {
200
- get: (lex, cb, _opt) => {
200
+ get: (lex, cb) => {
201
201
  if (!lex || !utils.obj.is(lex)) {
202
202
  cb("lex required")
203
203
  return
@@ -207,41 +207,54 @@ const Store = opt => {
207
207
  return
208
208
  }
209
209
 
210
- if (!_opt) _opt = {}
211
-
212
210
  var soul = lex["#"]
213
- var key = !_opt.secure && typeof lex["."] === "string" ? lex["."] : ""
211
+ var key = typeof lex["."] === "string" ? lex["."] : ""
214
212
  var node
215
213
  var signatures = {}
216
- const each = (value, key) => {
217
- // Always include userPublicKey for verification, regardless of filter
218
- if (key !== utils.userPublicKey && !utils.match(lex["."], key)) return
214
+ const each = (value, k) => {
215
+ if (k !== utils.userPublicKey && !utils.match(lex["."], k)) return
219
216
 
220
217
  if (!node) node = {_: {"#": soul, ">": {}}}
221
- node[key] = value[0]
222
- node._[">"][key] = value[1]
218
+ node[k] = value[0]
219
+ node._[">"][k] = value[1]
223
220
  // If signature is present, store it in _["s"]
224
221
  if (value.length === 3 && value[2]) {
225
222
  signatures[value[1]] = value[2]
226
223
  }
227
224
  }
228
225
 
226
+ // When doing a targeted property lookup, also fetch userPublicKey in
227
+ // parallel so ham.js can verify signatures regardless of secure mode.
228
+ // Full-soul scans (key="") already surface it via Radix.map.
229
+ const needsPubKey = key !== "" && key !== utils.userPublicKey
230
+ let pending = needsPubKey ? 2 : 1
231
+ let cbErr = null
232
+ const finish = err => {
233
+ if (err && !cbErr) cbErr = err
234
+ if (--pending > 0) return
235
+ const graph = {[soul]: node}
236
+ if (node && Object.keys(signatures).length > 0) {
237
+ node._["s"] = signatures
238
+ }
239
+ cb(cbErr, graph)
240
+ }
241
+
229
242
  radisk(soul + enq + key, (err, value) => {
230
- let graph
231
243
  if (utils.obj.is(value)) {
232
244
  Radix.map(value, each)
233
245
  if (!node) each(value, key)
234
- graph = {[soul]: node}
235
246
  } else if (value) {
236
247
  each(value, key)
237
- graph = {[soul]: node}
238
248
  }
239
- // Only add _["s"] if we found any signatures
240
- if (graph && graph[soul] && Object.keys(signatures).length > 0) {
241
- graph[soul]._["s"] = signatures
242
- }
243
- cb(err, graph)
249
+ finish(err)
244
250
  })
251
+
252
+ if (needsPubKey) {
253
+ radisk(soul + enq + utils.userPublicKey, (err, value) => {
254
+ if (value && !utils.obj.is(value)) each(value, utils.userPublicKey)
255
+ finish(err)
256
+ })
257
+ }
245
258
  },
246
259
  put: (graph, cb) => {
247
260
  if (!graph) {
package/src/wire.js CHANGED
@@ -206,7 +206,7 @@ const Wire = opt => {
206
206
 
207
207
  // Initialize rate limiting and connection management
208
208
  // Check if test environment (mock-socket usage)
209
- const isTestEnv = opt.wss && opt.wss.constructor.name === "Server"
209
+ const isTestEnv = isNode && globalThis.WebSocket !== wsModule?.WebSocket
210
210
  const rateLimiter = createRateLimiter(isTestEnv)
211
211
  const connectionManager = createConnectionManager(opt.maxConnections || 1000)
212
212
 
@@ -270,22 +270,16 @@ const Wire = opt => {
270
270
  }),
271
271
  )
272
272
  } else {
273
- // Always pass secure to ensure public key is fetched. User data may be
274
- // requested that requires the public key for verification.
275
- store.get(
276
- msg.get,
277
- (err, ack) => {
278
- send(
279
- JSON.stringify({
280
- "#": dup.track(utils.text.random(9)),
281
- "@": msg["#"],
282
- put: ack,
283
- err: err,
284
- }),
285
- )
286
- },
287
- {secure: true},
288
- )
273
+ store.get(msg.get, (err, ack) => {
274
+ send(
275
+ JSON.stringify({
276
+ "#": dup.track(utils.text.random(9)),
277
+ "@": msg["#"],
278
+ put: ack,
279
+ err: err,
280
+ }),
281
+ )
282
+ })
289
283
  }
290
284
  }
291
285
 
@@ -467,9 +461,7 @@ const Wire = opt => {
467
461
  pendingTimeouts.delete(track)
468
462
  return
469
463
  }
470
- },
471
- _opt,
472
- )
464
+ })
473
465
  }
474
466
 
475
467
  const api = send => {
@@ -726,6 +718,7 @@ const Wire = opt => {
726
718
  let clientThrottled = false
727
719
  let throttleUntil = 0
728
720
  let messageQueue = []
721
+ let offlinePuts = []
729
722
  let queueProcessor = null
730
723
  const maxQueueLength = opt.maxQueueLength || 10000
731
724
 
@@ -783,6 +776,11 @@ const Wire = opt => {
783
776
 
784
777
  if (sent || isStaleCheckMessage) {
785
778
  messageQueue.shift()
779
+ } else if (!sent && msg.put) {
780
+ // Hold offline PUTs separately so they don't block GET messages behind
781
+ // them. They'll be flushed back to the front of the queue on reconnect.
782
+ messageQueue.shift()
783
+ offlinePuts.push(data)
786
784
  }
787
785
 
788
786
  if (trackId && pendingTimeouts.has(trackId)) {
@@ -864,14 +862,22 @@ const Wire = opt => {
864
862
  ws = null
865
863
 
866
864
  if (retryHandler.shouldRetry()) {
867
- const delay = retryHandler.getDelay()
865
+ const delay = isTestEnv ? 50 : retryHandler.getDelay()
868
866
  retryHandler.increment()
869
- setTimeout(start, delay)
867
+ const t = setTimeout(start, delay)
868
+ if (t.unref) t.unref()
870
869
  }
871
870
  }
872
871
 
873
872
  ws.onopen = () => {
874
873
  retryHandler.reset()
874
+ if (offlinePuts.length > 0) {
875
+ messageQueue = offlinePuts.concat(messageQueue)
876
+ offlinePuts = []
877
+ if (!queueProcessor) {
878
+ queueProcessor = setTimeout(processQueue, 50)
879
+ }
880
+ }
875
881
  }
876
882
 
877
883
  ws.onerror = e => {
package/ts/src/holster.ts CHANGED
@@ -521,8 +521,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
521
521
  const ctx = allctx.get(ctxid!)
522
522
  if (!ctx) return
523
523
 
524
- if (!ctx.cb) {
525
- if (!callback) return
524
+ if (!ctx.cb && callback) {
526
525
  ctx.cb = callback as never
527
526
  callback = undefined
528
527
  }
@@ -845,10 +844,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
845
844
  const id = utils.rel.is(current as GraphValue)
846
845
  if (id) {
847
846
  wire.off(initialLex as never, map.get(callback)!)
848
- const relLex = (lexFilter
849
- ? utils.obj.put(initialLex as never, "#", id)
850
- : { "#": id, ".": null }) as Lex
851
- wire.on(relLex, map.get(callback)!, false, opts)
847
+ wire.on({ "#": id, ".": null } as never, map.get(callback)!, false, opts)
852
848
  if (_get) map.get(callback)!()
853
849
  } else if (_get) {
854
850
  // Not a rel, but _get was requested, so trigger callback.
package/ts/src/radisk.ts CHANGED
@@ -82,14 +82,6 @@ const Radisk = (opt: RadiskOptions) => {
82
82
  // After validation, store is guaranteed to be defined
83
83
  const store = options.store
84
84
 
85
- const perfLog = (operation: string, startTime: number, key: string, size?: number): void => {
86
- const duration = Date.now() - startTime
87
- if (duration > 2000) {
88
- console.log(
89
- `[RADISK-SLOW] ${operation}: ${duration}ms for ${key}${size ? ` (${size} bytes)` : ""}`
90
- )
91
- }
92
- }
93
85
 
94
86
  const checkMemoryUsage = (): void => {
95
87
  if (typeof process === "undefined" || !process.memoryUsage) return
@@ -143,7 +135,6 @@ const Radisk = (opt: RadiskOptions) => {
143
135
  }
144
136
 
145
137
  pendingReads.set(key, [callback])
146
- const readStart = Date.now()
147
138
  const timeout = setTimeout(() => {
148
139
  const callbacks = pendingReads.get(key)
149
140
  if (!callbacks) return
@@ -153,7 +144,6 @@ const Radisk = (opt: RadiskOptions) => {
153
144
  }, 30000)
154
145
  return radisk.read(key, (err, result) => {
155
146
  clearTimeout(timeout)
156
- perfLog("read", readStart, key)
157
147
  const callbacks = pendingReads.get(key) || []
158
148
  pendingReads.delete(key)
159
149
  callbacks.forEach(callback => callback(err, result))
@@ -184,7 +174,6 @@ const Radisk = (opt: RadiskOptions) => {
184
174
  return
185
175
  }
186
176
 
187
- const thrashStart = Date.now()
188
177
  clearTimeout(radisk.batch.timeout)
189
178
  radisk.thrash.more = false
190
179
  radisk.thrash.ing = true
@@ -196,7 +185,6 @@ const Radisk = (opt: RadiskOptions) => {
196
185
  radisk.save(batch, err => {
197
186
  if (++i > 1) return
198
187
 
199
- perfLog("thrash", thrashStart, `batch-${batch.ed}`)
200
188
  if (err) options.log(err)
201
189
  batch.acks.forEach(cb => cb(err))
202
190
  radisk.thrash.at = undefined
@@ -337,7 +325,6 @@ const Radisk = (opt: RadiskOptions) => {
337
325
  },
338
326
  }
339
327
  Radix.map(rad, write.each, true)
340
- const writeStart = Date.now()
341
328
  store.put(file, write.text, err => {
342
329
  // If a split occurred the cached radix tree still contains entries that
343
330
  // were moved to the sub-file. Invalidate so the next save.mix re-reads
@@ -346,7 +333,6 @@ const Radisk = (opt: RadiskOptions) => {
346
333
  cache.delete(file)
347
334
  fileListCache = null
348
335
  }
349
- perfLog("file-write", writeStart, file, write.text.length)
350
336
  cb(err)
351
337
  })
352
338
  }
package/ts/src/store.ts CHANGED
@@ -212,7 +212,6 @@ export interface StoreInterface {
212
212
  get: (
213
213
  lex: Lex,
214
214
  cb: (err?: string | null, graph?: Graph) => void,
215
- _opt?: { secure?: boolean }
216
215
  ) => void
217
216
  put: (graph: Graph, cb: (err?: string | null) => void) => void
218
217
  }
@@ -227,7 +226,7 @@ const Store = (opt?: StoreOptions): StoreInterface => {
227
226
  const radisk = Radisk(options as never)
228
227
 
229
228
  return {
230
- get: (lex, cb, _opt) => {
229
+ get: (lex, cb) => {
231
230
  if (!lex || !utils.obj.is(lex)) {
232
231
  cb("lex required")
233
232
  return
@@ -237,42 +236,55 @@ const Store = (opt?: StoreOptions): StoreInterface => {
237
236
  return
238
237
  }
239
238
 
240
- const opts = _opt || {}
241
239
  const soul = lex["#"]
242
- const key =
243
- !opts.secure && typeof lex["."] === "string" ? lex["."] : ""
240
+ const key = typeof lex["."] === "string" ? lex["."] : ""
244
241
  let node: GraphNode | undefined
245
242
  const signatures: Record<string, string> = {}
246
243
 
247
- const each = (value: EncodedValue, key: string): void => {
248
- // Always include userPublicKey for verification, regardless of filter
249
- if (key !== utils.userPublicKey && !utils.match(lex["."], key)) return
244
+ const each = (value: EncodedValue, k: string): void => {
245
+ if (k !== utils.userPublicKey && !utils.match(lex["."], k)) return
250
246
 
251
247
  if (!node) node = { _: { "#": soul, ">": {} } }
252
- node[key] = value[0]
253
- node._[">"][key] = value[1]
248
+ node[k] = value[0]
249
+ node._[">"][k] = value[1]
254
250
  // If signature is present, store it in _["s"]
255
251
  if (value.length === 3 && value[2]) {
256
252
  signatures[value[1].toString()] = value[2]
257
253
  }
258
254
  }
259
255
 
256
+ // When doing a targeted property lookup, also fetch userPublicKey in
257
+ // parallel so ham.js can verify signatures regardless of secure mode.
258
+ // Full-soul scans (key="") already surface it via Radix.map.
259
+ const needsPubKey = key !== "" && key !== utils.userPublicKey
260
+ let pending = needsPubKey ? 2 : 1
261
+ let cbErr: string | null = null
262
+ const finish = (err: string | null | undefined): void => {
263
+ if (err && !cbErr) cbErr = err
264
+ if (--pending > 0) return
265
+ const graph: Graph = { [soul]: node! }
266
+ if (node && Object.keys(signatures).length > 0) {
267
+ node._["s"] = signatures
268
+ }
269
+ cb(cbErr, graph)
270
+ }
271
+
260
272
  radisk(soul + enq + key, (err, value) => {
261
- let graph: Graph | undefined
262
273
  if (utils.obj.is(value)) {
263
274
  Radix.map(value as never, each)
264
275
  if (!node) each(value as never, key)
265
- graph = { [soul]: node! }
266
276
  } else if (value) {
267
277
  each(value as EncodedValue, key)
268
- graph = { [soul]: node! }
269
- }
270
- // Only add _["s"] if we found any signatures
271
- if (graph && graph[soul] && Object.keys(signatures).length > 0) {
272
- graph[soul]._["s"] = signatures
273
278
  }
274
- cb(err, graph)
279
+ finish(err)
275
280
  })
281
+
282
+ if (needsPubKey) {
283
+ radisk(soul + enq + utils.userPublicKey, (err, value) => {
284
+ if (value && !utils.obj.is(value)) each(value as EncodedValue, utils.userPublicKey)
285
+ finish(err)
286
+ })
287
+ }
276
288
  },
277
289
  put: (graph, cb) => {
278
290
  if (!graph) {
package/ts/src/wire.ts CHANGED
@@ -246,7 +246,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
246
246
  })
247
247
  }
248
248
 
249
- const isTestEnv = options.wss && (options.wss as { constructor?: { name?: string } }).constructor?.name === "Server"
249
+ const isTestEnv = isNode && (globalThis.WebSocket as unknown) !== wsModule?.WebSocket
250
250
  const rateLimiter = createRateLimiter(isTestEnv)
251
251
  const connectionManager = createConnectionManager(options.maxConnections || 1000)
252
252
 
@@ -303,20 +303,16 @@ const Wire = (opt: HolsterOptions): WireAPI => {
303
303
  })
304
304
  )
305
305
  } else {
306
- store.get(
307
- msg.get,
308
- (err, ack) => {
309
- send(
310
- JSON.stringify({
311
- "#": dup.track(utils.text.random(9)),
312
- "@": msg["#"],
313
- put: ack,
314
- err: err,
315
- })
316
- )
317
- },
318
- { secure: true }
319
- )
306
+ store.get(msg.get, (err, ack) => {
307
+ send(
308
+ JSON.stringify({
309
+ "#": dup.track(utils.text.random(9)),
310
+ "@": msg["#"],
311
+ put: ack,
312
+ err: err,
313
+ })
314
+ )
315
+ })
320
316
  }
321
317
  }
322
318
 
@@ -492,9 +488,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
492
488
  pendingTimeouts.delete(track)
493
489
  return
494
490
  }
495
- },
496
- opts
497
- )
491
+ })
498
492
  }
499
493
 
500
494
  const api = (send: (msg: string) => { err?: string } | void): WireAPI => {
@@ -603,7 +597,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
603
597
  if (typeof lex["."] === "string") {
604
598
  ack[id] = { [lex["."]]: null } as never
605
599
  }
606
- cb({ "#": trackId, put: ack })
600
+ cb({ put: ack })
607
601
  delete queue[trackId]
608
602
  }
609
603
  }, isTestEnv ? 100 : 10000)
@@ -734,6 +728,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
734
728
  let clientThrottled = false
735
729
  let throttleUntil = 0
736
730
  let messageQueue: string[] = []
731
+ let offlinePuts: string[] = []
737
732
  let queueProcessor: NodeJS.Timeout | null = null
738
733
  const maxQueueLength = options.maxQueueLength || 10000
739
734
 
@@ -748,7 +743,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
748
743
  if (typeof lex["."] === "string") {
749
744
  ack[id] = { [lex["."]]: null } as never
750
745
  }
751
- cb({ "#": trackId, put: ack })
746
+ cb({ put: ack })
752
747
  delete queue[trackId]
753
748
  }
754
749
  }, delay)
@@ -784,6 +779,11 @@ const Wire = (opt: HolsterOptions): WireAPI => {
784
779
 
785
780
  if (sent || isStaleCheckMessage) {
786
781
  messageQueue.shift()
782
+ } else if (!sent && msg.put) {
783
+ // Hold offline PUTs separately so they don't block GET messages behind
784
+ // them. They'll be flushed back to the front of the queue on reconnect.
785
+ messageQueue.shift()
786
+ offlinePuts.push(data)
787
787
  }
788
788
 
789
789
  if (trackId && pendingTimeouts.has(trackId)) {
@@ -863,14 +863,22 @@ const Wire = (opt: HolsterOptions): WireAPI => {
863
863
  ws = null
864
864
 
865
865
  if (retryHandler.shouldRetry()) {
866
- const delay = retryHandler.getDelay()
866
+ const delay = isTestEnv ? 50 : retryHandler.getDelay()
867
867
  retryHandler.increment()
868
- setTimeout(start, delay)
868
+ const t = setTimeout(start, delay)
869
+ if ((t as unknown as { unref?: () => void }).unref) (t as unknown as { unref: () => void }).unref()
869
870
  }
870
871
  }
871
872
 
872
873
  ws.onopen = () => {
873
874
  retryHandler.reset()
875
+ if (offlinePuts.length > 0) {
876
+ messageQueue = offlinePuts.concat(messageQueue)
877
+ offlinePuts = []
878
+ if (!queueProcessor) {
879
+ queueProcessor = setTimeout(processQueue, 50)
880
+ }
881
+ }
874
882
  }
875
883
 
876
884
  ws.onerror = (e: Event) => {