@mblaney/holster 1.0.36 → 2.0.1

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/src/wire.js CHANGED
@@ -4,7 +4,7 @@ import Ham from "./ham.js"
4
4
  import Store from "./store.js"
5
5
  import * as utils from "./utils.js"
6
6
 
7
- const isNode = typeof document === "undefined"
7
+ const isNode = typeof process !== "undefined" && process.versions?.node != null
8
8
 
9
9
  const wsModule = isNode ? await import("ws") : undefined
10
10
 
@@ -169,18 +169,31 @@ const createRetryHandler = (maxRetries = 5) => {
169
169
  // Wire starts a websocket client or server and returns get and put methods
170
170
  // for access to the wire spec and storage.
171
171
  const Wire = opt => {
172
- const dup = Dup(opt.maxAge)
172
+ const dup = Dup()
173
173
  const store = Store(opt)
174
174
  const graph = {}
175
175
  const queue = {}
176
176
  const listen = {}
177
+ // Tracks souls that have had a complete full-node store fetch, so subsequent
178
+ // full-node requests can read directly from the in-memory graph without a
179
+ // redundant store round-trip. Kept in sync with graph eviction via Ham.mix.
180
+ const hasNode = new Set()
177
181
 
178
182
  // Track references we want but don't have yet
179
183
  const pendingReferences = new Set()
180
184
 
185
+ // Debounce client-side wire update checks: skip if the same lex was checked
186
+ // recently. Keyed by "soul" or "soul.property".
187
+ const recentChecks = new Map()
188
+ const recentCheckTTL = 10000 // 10 seconds
189
+
181
190
  // Track pending timeouts that should start when message is sent
182
191
  const pendingTimeouts = new Map()
183
192
 
193
+ // Track offline null-response timeouts so they can be cancelled if the WS
194
+ // connects before they fire. Keyed by the same trackId as pendingTimeouts.
195
+ const offlineTimeouts = new Map()
196
+
184
197
  // Helper to check if we have a soul in memory or storage
185
198
  const hasSoul = async soul => {
186
199
  if (graph[soul]) return true
@@ -206,25 +219,26 @@ const Wire = opt => {
206
219
  const key = utils.userPublicKey
207
220
 
208
221
  for (const soul of Object.keys(data)) {
222
+ const node = data[soul]
223
+ // If the incoming update doesn't include a userPublicKey property at all,
224
+ // allow it - the update is not trying to change the public key.
225
+ if (node[key] === undefined) continue
226
+
209
227
  const msg = await new Promise(res => {
210
- getWithCallback({"#": soul, ".": key}, res, send)
228
+ getWithCallback({"#": soul, ".": key}, res, send, {put: true})
211
229
  })
212
230
  if (msg.err) {
213
231
  if (cb) cb(msg.err)
214
232
  return false
215
233
  }
216
234
 
217
- const node = data[soul]
218
235
  // If there is no current node then the data is ok to write without
219
236
  // matching public keys, as the provided soul also needs a rel on the
220
237
  // 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.
238
+ // need to match for existing data.
224
239
  if (
225
240
  !msg.put ||
226
241
  !msg.put[soul] ||
227
- node[key] === undefined ||
228
242
  msg.put[soul][key] === node[key]
229
243
  ) {
230
244
  continue
@@ -277,7 +291,7 @@ const Wire = opt => {
277
291
 
278
292
  const put = async (msg, send) => {
279
293
  // Store updates returned from Ham.mix and defer updates if required.
280
- const update = await Ham.mix(msg.put, graph, opt.secure, listen)
294
+ const update = await Ham.mix(msg.put, graph, hasNode, opt.secure, listen)
281
295
  if (Object.keys(update.now).length === 0) {
282
296
  // No updates to store, check deferred.
283
297
  if (Object.keys(update.defer).length !== 0) {
@@ -292,6 +306,7 @@ const Wire = opt => {
292
306
  if (!(await check(update.now, send))) return
293
307
 
294
308
  store.put(update.now, err => {
309
+ if (err) console.warn("store.put", err)
295
310
  send(
296
311
  JSON.stringify({
297
312
  "#": dup.track(utils.text.random(9)),
@@ -316,7 +331,10 @@ const Wire = opt => {
316
331
 
317
332
  if (!utils.obj.is(_opt)) _opt = {}
318
333
 
319
- const ack = Get(lex, graph, _opt.fast)
334
+ // For full-node requests on souls that have been fully fetched from store,
335
+ // treat the graph as authoritative — skip the redundant store round-trip.
336
+ const fast = _opt.fast || (!lex["."] && hasNode.has(lex["#"]))
337
+ const ack = Get(lex, graph, fast)
320
338
  const track = utils.text.random(9)
321
339
  const request = JSON.stringify({
322
340
  "#": dup.track(track),
@@ -324,11 +342,30 @@ const Wire = opt => {
324
342
  })
325
343
 
326
344
  if (ack) {
327
- // Also send request on the wire to check for updates.
328
- const sendResult = send(request)
329
- if (sendResult && sendResult.err) {
330
- cb({err: sendResult.err})
331
- return
345
+ if (!isNode) {
346
+ // Client: send request on the wire to check for updates,
347
+ // since it may have missed puts while disconnected.
348
+ // Skip if the same lex was checked recently to avoid flooding.
349
+ if (!lex["."] || typeof lex["."] === "string") {
350
+ const checkKey = lex["#"] + (lex["."] ? "." + lex["."] : "")
351
+ const now = Date.now()
352
+ const lastCheck = recentChecks.get(checkKey)
353
+ if (!lastCheck || now - lastCheck > recentCheckTTL) {
354
+ recentChecks.set(checkKey, now)
355
+ const sendResult = send(request)
356
+ if (sendResult && sendResult.err) {
357
+ cb({err: sendResult.err})
358
+ return
359
+ }
360
+ }
361
+ } else {
362
+ // Don't skip checks for other types of lex queries.
363
+ const sendResult = send(request)
364
+ if (sendResult && sendResult.err) {
365
+ cb({err: sendResult.err})
366
+ return
367
+ }
368
+ }
332
369
  }
333
370
  cb({put: ack})
334
371
  return
@@ -342,27 +379,86 @@ const Wire = opt => {
342
379
  // request, so stale server responses with older state are correctly
343
380
  // rejected by ham.mix.
344
381
  for (const [soul, node] of Object.entries(ack)) {
345
- if (!graph[soul] && node) graph[soul] = {...node}
382
+ if (!node) continue
383
+ if (!graph[soul]) {
384
+ graph[soul] = {...node}
385
+ continue
386
+ }
387
+ // Merge properties absent from the current graph entry. Only add
388
+ // missing keys — existing entries were set by ham.mix and represent
389
+ // the authoritative merged state for those properties.
390
+ for (const [key, value] of Object.entries(node)) {
391
+ if (key === "_") continue
392
+ if (typeof graph[soul][key] !== "undefined") continue
393
+ graph[soul][key] = value
394
+ if (node._ && node._[">"] && typeof node._[">"][key] !== "undefined") {
395
+ const state = node._[">"][key]
396
+ graph[soul]._[">"][key] = state
397
+ if (node._["s"] && node._["s"][state]) {
398
+ if (!graph[soul]._["s"]) graph[soul]._["s"] = {}
399
+ graph[soul]._["s"][state] = node._["s"][state]
400
+ }
401
+ }
402
+ }
346
403
  }
347
- // Also send request on the wire to check for updates.
348
- const sendResult = send(request)
349
- if (sendResult && sendResult.err) {
350
- cb({err: sendResult.err})
404
+ // Full-node fetch: the graph is now complete for this soul.
405
+ if (!lex["."] && ack[lex["#"]]) hasNode.add(lex["#"])
406
+ // Only serve from store if the requested property is present.
407
+ const node = ack[lex["#"]]
408
+ const hasProperty =
409
+ node &&
410
+ Object.keys(node).some(
411
+ key => key !== "_" && utils.match(lex["."], key),
412
+ )
413
+ if (hasProperty) {
414
+ // Public keys are immutable once written — never need a wire check.
415
+ if (!isNode && lex["."] !== utils.userPublicKey) {
416
+ if (!lex["."] || typeof lex["."] === "string") {
417
+ const checkKey = lex["#"] + (lex["."] ? "." + lex["."] : "")
418
+ const now = Date.now()
419
+ const lastCheck = recentChecks.get(checkKey)
420
+ if (!lastCheck || now - lastCheck > recentCheckTTL) {
421
+ recentChecks.set(checkKey, now)
422
+ const sendResult = send(request)
423
+ if (sendResult && sendResult.err) {
424
+ cb({err: sendResult.err})
425
+ return
426
+ }
427
+ }
428
+ } else {
429
+ const sendResult = send(request)
430
+ if (sendResult && sendResult.err) {
431
+ cb({err: sendResult.err})
432
+ return
433
+ }
434
+ }
435
+ }
436
+ cb(err ? {put: ack, err: err} : {put: ack})
351
437
  return
352
438
  }
353
- cb(err ? {put: ack, err: err} : {put: ack})
354
- return
355
439
  }
356
440
 
441
+ // Data wasn't found locally, log errors and call back after a timeout.
357
442
  if (err) console.log(err)
358
443
 
444
+ // Put requests only need a local answer — skip the wire round-trip.
445
+ if (_opt && _opt.put) {
446
+ const id = lex["#"]
447
+ const ack = {[id]: null}
448
+ if (typeof lex["."] === "string") {
449
+ ack[id] = {[lex["."]]: null}
450
+ }
451
+ cb({put: ack})
452
+ return
453
+ }
454
+
359
455
  queue[track] = cb
360
456
 
361
- // Store timeout config to start after message is sent from queue
362
- pendingTimeouts.set(track, {
363
- lex: lex,
364
- wait: _opt.wait || 100,
365
- })
457
+ // Store lex to construct the null ack when the timeout fires
458
+ pendingTimeouts.set(track, lex)
459
+
460
+ // Ensure the requested soul is stored when the response arrives.
461
+ pendingReferences.add(lex["#"])
366
462
 
367
463
  const sendResult = send(request)
368
464
  if (sendResult && sendResult.err) {
@@ -393,7 +489,7 @@ const Wire = opt => {
393
489
  // here using the api. This is ok because correct timestamps should be
394
490
  // used whereas wire spec needs to handle clock skew for updates
395
491
  // across the network.
396
- const update = await Ham.mix(data, graph, opt.secure, listen)
492
+ const update = await Ham.mix(data, graph, hasNode, opt.secure, listen)
397
493
  if (Object.keys(update.now).length === 0) {
398
494
  // No updates, still respond to callback.
399
495
  if (cb) cb(null)
@@ -467,7 +563,7 @@ const Wire = opt => {
467
563
  }
468
564
  }
469
565
 
470
- if (isNode) {
566
+ if (isNode && (opt.wss || opt.server || opt.port != null)) {
471
567
  let wss = opt.wss
472
568
  // Node's websocket server provides clients as an array, whereas
473
569
  // mock-sockets provides clients as a function that returns an array.
@@ -475,7 +571,7 @@ const Wire = opt => {
475
571
  if (!wss) {
476
572
  const config = opt.server
477
573
  ? {server: opt.server}
478
- : {port: opt.port || 8765}
574
+ : {port: opt.port}
479
575
  wss = new wsModule.WebSocketServer(config)
480
576
  clients = () => wss.clients
481
577
  }
@@ -485,22 +581,21 @@ const Wire = opt => {
485
581
  const msg = JSON.parse(data)
486
582
  const trackId = msg["#"]
487
583
  if (trackId && pendingTimeouts.has(trackId)) {
488
- const timeoutConfig = pendingTimeouts.get(trackId)
584
+ const lex = pendingTimeouts.get(trackId)
489
585
  pendingTimeouts.delete(trackId)
490
586
 
491
- // Respond to callback with null if no response.
492
- timeoutConfig.timeoutId = setTimeout(() => {
587
+ setTimeout(() => {
493
588
  const cb = queue[trackId]
494
589
  if (cb) {
495
- const id = timeoutConfig.lex["#"]
590
+ const id = lex["#"]
496
591
  const ack = {[id]: null}
497
- if (typeof timeoutConfig.lex["."] === "string") {
498
- ack[id] = {[timeoutConfig.lex["."]]: null}
592
+ if (typeof lex["."] === "string") {
593
+ ack[id] = {[lex["."]]: null}
499
594
  }
500
595
  cb({put: ack})
501
596
  delete queue[trackId]
502
597
  }
503
- }, timeoutConfig.wait)
598
+ }, isTestEnv ? 100 : 10000)
504
599
  }
505
600
 
506
601
  clients().forEach(client => {
@@ -634,6 +729,24 @@ const Wire = opt => {
634
729
  let queueProcessor = null
635
730
  const maxQueueLength = opt.maxQueueLength || 10000
636
731
 
732
+ const startResponseTimeout = (trackId, lex, delay = isTestEnv ? 100 : 10000) => {
733
+ return setTimeout(() => {
734
+ offlineTimeouts.delete(trackId)
735
+ pendingTimeouts.delete(trackId)
736
+ const cb = queue[trackId]
737
+ // No-op on successful send as callback is removed from the queue.
738
+ if (cb) {
739
+ const id = lex["#"]
740
+ const ack = {[id]: null}
741
+ if (typeof lex["."] === "string") {
742
+ ack[id] = {[lex["."]]: null}
743
+ }
744
+ cb({put: ack})
745
+ delete queue[trackId]
746
+ }
747
+ }, delay)
748
+ }
749
+
637
750
  const processQueue = () => {
638
751
  if (messageQueue.length === 0) {
639
752
  queueProcessor = null
@@ -661,30 +774,27 @@ const Wire = opt => {
661
774
  const data = messageQueue[0]
662
775
  const sent = sendToPeers(data)
663
776
 
664
- if (sent) {
665
- // Only remove from queue if successfully sent
666
- messageQueue.shift()
777
+ const msg = JSON.parse(data)
778
+ const trackId = msg["#"]
667
779
 
668
- // Start timeout for this message now that it's been sent
669
- const msg = JSON.parse(data)
670
- const trackId = msg["#"]
671
- if (trackId && pendingTimeouts.has(trackId)) {
672
- const timeoutConfig = pendingTimeouts.get(trackId)
673
- pendingTimeouts.delete(trackId)
780
+ // Drop GET messages with no pending callback — these were already served
781
+ // from local store so no caller is waiting for the wire response.
782
+ const isStaleCheckMessage = !sent && msg.get && !pendingTimeouts.has(trackId)
674
783
 
675
- // Respond to callback with null if no response.
676
- timeoutConfig.timeoutId = setTimeout(() => {
677
- const cb = queue[trackId]
678
- if (cb) {
679
- const id = timeoutConfig.lex["#"]
680
- const ack = {[id]: null}
681
- if (typeof timeoutConfig.lex["."] === "string") {
682
- ack[id] = {[timeoutConfig.lex["."]]: null}
683
- }
684
- cb({put: ack})
685
- delete queue[trackId]
686
- }
687
- }, timeoutConfig.wait)
784
+ if (sent || isStaleCheckMessage) {
785
+ messageQueue.shift()
786
+ }
787
+
788
+ if (trackId && pendingTimeouts.has(trackId)) {
789
+ const lex = pendingTimeouts.get(trackId)
790
+ if (sent) {
791
+ if (offlineTimeouts.has(trackId)) {
792
+ clearTimeout(offlineTimeouts.get(trackId))
793
+ offlineTimeouts.delete(trackId)
794
+ }
795
+ startResponseTimeout(trackId, lex)
796
+ } else if (!offlineTimeouts.has(trackId)) {
797
+ offlineTimeouts.set(trackId, startResponseTimeout(trackId, lex, 0))
688
798
  }
689
799
  }
690
800
 
@@ -782,39 +892,53 @@ const Wire = opt => {
782
892
  dup.track(msg["#"])
783
893
  if (msg.get) get(msg, send)
784
894
  if (msg.put) {
785
- // Handle selective storage for client WebSocket messages
786
- const filteredPut = {}
895
+ // Pre-pass: expand pendingReferences to include rels of every soul
896
+ // that will be stored in this batch. This handles the case where a
897
+ // server batch contains both a parent soul (e.g. day_soul with item
898
+ // rels) and its child souls (item_souls), in any order — without the
899
+ // pre-pass, a child soul that appears before its parent in the batch
900
+ // would be missed because pendingReferences hasn't been updated yet.
901
+ const hasSoulMap = new Map()
787
902
  for (const [soul, node] of Object.entries(msg.put)) {
788
- let shouldStore = false
789
- // Case 1: We already have this soul - always update
790
- if (await hasSoul(soul)) {
791
- shouldStore = true
792
- // Check if this update adds new references
793
- if (node && typeof node === "object") {
794
- for (const [key, value] of Object.entries(node)) {
795
- const soulId = utils.rel.is(value)
796
- if (soulId) {
797
- // Add referenced soul to pending list
798
- pendingReferences.add(soulId)
799
- }
800
- }
801
- }
903
+ const known = await hasSoul(soul)
904
+ hasSoulMap.set(soul, known)
905
+ if ((!known && !pendingReferences.has(soul)) || !node || typeof node !== "object") continue
906
+ for (const [, value] of Object.entries(node)) {
907
+ const soulId = utils.rel.is(value)
908
+ if (soulId) pendingReferences.add(soulId)
802
909
  }
803
- // Case 2: This soul was referenced by something we have
910
+ }
911
+
912
+ // Main pass: filter using cached hasSoul results and the now-complete
913
+ // pendingReferences set.
914
+ const filteredPut = {}
915
+ const pendingToDelete = []
916
+ for (const [soul, node] of Object.entries(msg.put)) {
917
+ let shouldStore = hasSoulMap.get(soul)
804
918
  if (pendingReferences.has(soul)) {
805
919
  shouldStore = true
806
- pendingReferences.delete(soul) // Got it, remove from pending
807
- }
808
- if (shouldStore) {
809
- filteredPut[soul] = node
920
+ // Don't delete from pendingReferences yet — concurrent onmessage
921
+ // handlers for the same soul may have yielded at await hasSoul()
922
+ // before this pass ran. Deleting here would cause those handlers
923
+ // to see shouldStore=false and skip storing the soul's data.
924
+ // Instead, defer the delete until after put() updates the graph,
925
+ // so any concurrent handler can find the soul via hasSoul(graph).
926
+ pendingToDelete.push(soul)
810
927
  }
928
+ if (shouldStore) filteredPut[soul] = node
811
929
  }
930
+
812
931
  // Store filtered data if any
813
932
  if (Object.keys(filteredPut).length > 0) {
814
933
  // Create filtered message for Ham.mix with original message ID
815
934
  const filteredMsg = {put: filteredPut, "#": msg["#"]}
816
935
  await put(filteredMsg, send)
817
936
  }
937
+
938
+ // Safe to delete now — put() has run Ham.mix which updates graph
939
+ // in-place, so hasSoul() will return true for these souls going
940
+ // forward, making pendingReferences no longer needed for them.
941
+ pendingToDelete.forEach(soul => pendingReferences.delete(soul))
818
942
  }
819
943
 
820
944
  const id = msg["@"]
package/ts/src/dup.ts CHANGED
@@ -1,8 +1,3 @@
1
- /**
2
- * Dup - Duplicate message tracking for wire protocol
3
- * Prevents processing the same message multiple times
4
- */
5
-
6
1
  export interface DupInterface {
7
2
  store: Record<string, number>
8
3
  check: (id: string) => string | false
@@ -10,13 +5,8 @@ export interface DupInterface {
10
5
  expiry: NodeJS.Timeout | null
11
6
  }
12
7
 
13
- /**
14
- * Create a duplicate message tracker
15
- * @param maxAge - Maximum age of tracked messages in milliseconds (default: 9000)
16
- * @returns Dup interface for checking and tracking message IDs
17
- */
18
- const Dup = (maxAge?: number): DupInterface => {
19
- const maxAgeMs = maxAge || 9000
8
+ const Dup = (): DupInterface => {
9
+ const maxAge = 9000
20
10
  const dup: DupInterface = {
21
11
  store: {},
22
12
  expiry: null,
@@ -28,16 +18,16 @@ const Dup = (maxAge?: number): DupInterface => {
28
18
  dup.store[id] = Date.now()
29
19
  if (!dup.expiry) {
30
20
  dup.expiry = setTimeout(() => {
31
- if (!dup.expiry) return
32
-
33
21
  const now = Date.now()
34
22
  Object.keys(dup.store).forEach(id => {
35
- if (now - dup.store[id]! > maxAgeMs) {
23
+ if (now - dup.store[id]! > maxAge) {
36
24
  delete dup.store[id]
37
25
  }
38
26
  })
39
27
  dup.expiry = null
40
- }, maxAgeMs)
28
+ }, maxAge)
29
+ // Don't block process exit — cleanup is best-effort.
30
+ if (dup.expiry.unref) dup.expiry.unref()
41
31
  }
42
32
  return id
43
33
  },
@@ -46,4 +36,3 @@ const Dup = (maxAge?: number): DupInterface => {
46
36
  }
47
37
 
48
38
  export default Dup
49
-
package/ts/src/ham.ts CHANGED
@@ -67,6 +67,7 @@ const Ham = (
67
67
  Ham.mix = async (
68
68
  change: Graph,
69
69
  graph: Graph,
70
+ hasNode: Set<string> | undefined,
70
71
  secure: boolean,
71
72
  listen: ListenMap
72
73
  ): Promise<HamMixResult> => {
@@ -244,7 +245,10 @@ Ham.mix = async (
244
245
  })
245
246
  .sort((a, b) => a.maxState - b.maxState)
246
247
  const remove = soulsByAge.slice(0, souls.length - MAX_GRAPH_SIZE)
247
- remove.forEach(({ soul }) => delete graph[soul])
248
+ remove.forEach(({ soul }) => {
249
+ delete graph[soul]
250
+ if (hasNode) hasNode.delete(soul)
251
+ })
248
252
  }
249
253
 
250
254
  return { now, defer, wait, listeners }