@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/ts/src/wire.ts CHANGED
@@ -10,6 +10,7 @@ import Store from "./store.ts"
10
10
  import * as utils from "./utils.ts"
11
11
  import type {
12
12
  Graph,
13
+ GraphNode,
13
14
  WireMessage,
14
15
  WireOptions,
15
16
  Lex,
@@ -25,7 +26,7 @@ type UnifiedWebSocket = WebSocket & {
25
26
  send: (data: string | Buffer | ArrayBuffer | Uint8Array, options?: unknown, cb?: (err?: Error) => void) => void
26
27
  }
27
28
 
28
- const isNode = typeof document === "undefined"
29
+ const isNode = typeof process !== "undefined" && process.versions?.node != null
29
30
 
30
31
  // Dynamic import for Node.js ws module - won't execute in browser/service worker
31
32
  const wsModule = isNode ? await import("ws") : undefined
@@ -219,17 +220,22 @@ export interface WireAPI {
219
220
 
220
221
  const Wire = (opt: HolsterOptions): WireAPI => {
221
222
  const options = opt || {}
222
- const dup = Dup(options.maxAge)
223
+ const dup = Dup()
223
224
  const store = Store(options as never)
224
225
  const graph: Graph = {}
225
226
  const queue: Record<string, (msg: WireMessage) => void> = {}
226
227
  const listen: ListenMap = {}
227
228
 
228
229
  const pendingReferences = new Set<string>()
229
- const pendingTimeouts = new Map<
230
- string,
231
- { lex: Lex; wait: number; timeoutId?: NodeJS.Timeout }
232
- >()
230
+ const hasNode = new Set<string>()
231
+
232
+ // Debounce client-side wire update checks: skip if the same lex was checked
233
+ // recently. Keyed by "soul" or "soul.property".
234
+ const recentChecks = new Map<string, number>()
235
+ const recentCheckTTL = 10000 // 10 seconds
236
+
237
+ const pendingTimeouts = new Map<string, Lex>()
238
+ const offlineTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
233
239
 
234
240
  const hasSoul = async (soul: string): Promise<boolean> => {
235
241
  if (graph[soul]) return true
@@ -252,19 +258,26 @@ const Wire = (opt: HolsterOptions): WireAPI => {
252
258
  const key = utils.userPublicKey
253
259
 
254
260
  for (const soul of Object.keys(data)) {
261
+ const node = data[soul]!
262
+ // If the incoming update doesn't include a userPublicKey property at all,
263
+ // allow it - the update is not trying to change the public key.
264
+ if (node[key] === undefined) continue
265
+
255
266
  const msg = await new Promise<WireMessage>(res => {
256
- getWithCallback({ "#": soul, ".": key }, res, send)
267
+ getWithCallback({ "#": soul, ".": key }, res, send, {put: true})
257
268
  })
258
269
  if (msg.err) {
259
270
  if (cb) cb(msg.err)
260
271
  return false
261
272
  }
262
273
 
263
- const node = data[soul]!
274
+ // If there is no current node then the data is ok to write without
275
+ // matching public keys, as the provided soul also needs a rel on the
276
+ // parent node which then also requires checking. Otherwise public keys
277
+ // need to match for existing data.
264
278
  if (
265
279
  !msg.put ||
266
280
  !msg.put[soul] ||
267
- node[key] === undefined ||
268
281
  msg.put[soul]![key] === node[key]
269
282
  ) {
270
283
  continue
@@ -311,7 +324,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
311
324
  msg: { put: Graph; "#": string },
312
325
  send: (msg: string) => void
313
326
  ): Promise<void> => {
314
- const update = await Ham.mix(msg.put, graph, options.secure || false, listen)
327
+ const update = await Ham.mix(msg.put, graph, hasNode, options.secure || false, listen)
315
328
  if (Object.keys(update.now).length === 0) {
316
329
  if (Object.keys(update.defer).length !== 0) {
317
330
  setTimeout(() => put({ put: update.defer, "#": msg["#"] }, send), update.wait)
@@ -322,6 +335,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
322
335
  if (!(await check(update.now, send as never))) return
323
336
 
324
337
  store.put(update.now, err => {
338
+ if (err) console.warn("store.put", err)
325
339
  send(
326
340
  JSON.stringify({
327
341
  "#": dup.track(utils.text.random(9)),
@@ -346,7 +360,8 @@ const Wire = (opt: HolsterOptions): WireAPI => {
346
360
  if (!cb) return
347
361
 
348
362
  const opts = _opt || {}
349
- const ack = Get(lex, graph, opts.fast)
363
+ const fast = opts.fast || (!lex["."] && hasNode.has(lex["#"]))
364
+ const ack = Get(lex, graph, fast)
350
365
  const track = utils.text.random(9)
351
366
  const request = JSON.stringify({
352
367
  "#": dup.track(track),
@@ -354,10 +369,30 @@ const Wire = (opt: HolsterOptions): WireAPI => {
354
369
  })
355
370
 
356
371
  if (ack) {
357
- const sendResult = send(request)
358
- if (sendResult && sendResult.err) {
359
- cb({ err: sendResult.err })
360
- return
372
+ if (!isNode) {
373
+ // Client: send request on the wire to check for updates,
374
+ // since it may have missed puts while disconnected.
375
+ // Skip if the same lex was checked recently to avoid flooding.
376
+ if (!lex["."] || typeof lex["."] === "string") {
377
+ const checkKey = lex["#"] + (lex["."] ? "." + lex["."] : "")
378
+ const now = Date.now()
379
+ const lastCheck = recentChecks.get(checkKey)
380
+ if (!lastCheck || now - lastCheck > recentCheckTTL) {
381
+ recentChecks.set(checkKey, now)
382
+ const sendResult = send(request)
383
+ if (sendResult && sendResult.err) {
384
+ cb({ err: sendResult.err })
385
+ return
386
+ }
387
+ }
388
+ } else {
389
+ // Don't skip checks for other types of lex queries.
390
+ const sendResult = send(request)
391
+ if (sendResult && sendResult.err) {
392
+ cb({ err: sendResult.err })
393
+ return
394
+ }
395
+ }
361
396
  }
362
397
  cb({ put: ack })
363
398
  return
@@ -371,25 +406,84 @@ const Wire = (opt: HolsterOptions): WireAPI => {
371
406
  // request, so stale server responses with older state are correctly
372
407
  // rejected by ham.mix.
373
408
  for (const [soul, node] of Object.entries(ack)) {
374
- if (!graph[soul] && node) graph[soul] = {...node} as unknown as Graph[string]
409
+ if (!node) continue
410
+ if (!graph[soul]) {
411
+ graph[soul] = {...node} as unknown as Graph[string]
412
+ continue
413
+ }
414
+ for (const [key, value] of Object.entries(node)) {
415
+ if (key === "_") continue
416
+ const gNode = graph[soul] as unknown as Record<string, unknown>
417
+ if (typeof gNode[key] !== "undefined") continue
418
+ gNode[key] = value
419
+ const meta = (node as unknown as GraphNode)._
420
+ if (meta?.[">"] && typeof meta[">"][key] !== "undefined") {
421
+ const state = meta[">"][key]
422
+ const gMeta = (graph[soul] as unknown as GraphNode)._
423
+ gMeta[">"][key] = state
424
+ if (meta["s"]?.[state]) {
425
+ if (!gMeta["s"]) gMeta["s"] = {}
426
+ gMeta["s"]![state] = meta["s"][state]
427
+ }
428
+ }
429
+ }
375
430
  }
376
- const sendResult = send(request)
377
- if (sendResult && sendResult.err) {
378
- cb({ err: sendResult.err })
431
+ // Full-node fetch: the graph is now complete for this soul.
432
+ if (!lex["."] && ack[lex["#"]]) hasNode.add(lex["#"])
433
+ // Only serve from store if the requested property is present.
434
+ const node = ack[lex["#"]]
435
+ const hasProperty =
436
+ node &&
437
+ Object.keys(node).some(
438
+ key => key !== "_" && utils.match(lex["."], key),
439
+ )
440
+ if (hasProperty) {
441
+ // Public keys are immutable once written — never need a wire check.
442
+ if (!isNode && lex["."] !== utils.userPublicKey) {
443
+ if (!lex["."] || typeof lex["."] === "string") {
444
+ const checkKey = lex["#"] + (lex["."] ? "." + lex["."] : "")
445
+ const now = Date.now()
446
+ const lastCheck = recentChecks.get(checkKey)
447
+ if (!lastCheck || now - lastCheck > recentCheckTTL) {
448
+ recentChecks.set(checkKey, now)
449
+ const sendResult = send(request)
450
+ if (sendResult && sendResult.err) {
451
+ cb({ err: sendResult.err })
452
+ return
453
+ }
454
+ }
455
+ } else {
456
+ const sendResult = send(request)
457
+ if (sendResult && sendResult.err) {
458
+ cb({ err: sendResult.err })
459
+ return
460
+ }
461
+ }
462
+ }
463
+ cb(err ? { put: ack, err: err } : { put: ack })
379
464
  return
380
465
  }
381
- cb(err ? { put: ack, err: err } : { put: ack })
382
- return
383
466
  }
384
467
 
385
468
  if (err) console.log(err)
386
469
 
470
+ // Put requests only need a local answer — skip the wire round-trip.
471
+ if (opts && opts.put) {
472
+ const id = lex["#"]
473
+ const ack: Record<string, null | Record<string, null>> = {[id]: null}
474
+ if (typeof lex["."] === "string") {
475
+ ack[id] = {[lex["."]]: null}
476
+ }
477
+ cb({put: ack as never})
478
+ return
479
+ }
480
+
387
481
  queue[track] = cb
388
482
 
389
- pendingTimeouts.set(track, {
390
- lex: lex,
391
- wait: opts.wait || 100,
392
- })
483
+ pendingTimeouts.set(track, lex)
484
+
485
+ // Ensure the requested soul is stored when the response arrives.
486
+ pendingReferences.add(lex["#"])
393
487
 
394
488
  const sendResult = send(request)
395
489
  if (sendResult && sendResult.err) {
@@ -415,7 +509,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
415
509
  getWithCallback(lex, cb, send, _opt)
416
510
  },
417
511
  put: async (data, cb) => {
418
- const update = await Ham.mix(data, graph, options.secure || false, listen)
512
+ const update = await Ham.mix(data, graph, hasNode, options.secure || false, listen)
419
513
  if (Object.keys(update.now).length === 0) {
420
514
  if (cb) cb(null)
421
515
  return
@@ -482,14 +576,14 @@ const Wire = (opt: HolsterOptions): WireAPI => {
482
576
  }
483
577
  }
484
578
 
485
- if (isNode) {
579
+ if (isNode && (options.wss || options.server || options.port != null)) {
486
580
  let wss = options.wss
487
581
  let clients = (): WebSocket[] =>
488
582
  (wss as { clients?: () => WebSocket[] }).clients?.() || []
489
583
  if (!wss) {
490
584
  const config = options.server
491
585
  ? { server: options.server }
492
- : { port: options.port || 8765 }
586
+ : { port: options.port }
493
587
  wss = new wsModule!.WebSocketServer(config)
494
588
  clients = () => Array.from((wss as { clients: Set<WebSocket> }).clients || [])
495
589
  }
@@ -498,21 +592,21 @@ const Wire = (opt: HolsterOptions): WireAPI => {
498
592
  const msg = JSON.parse(data) as WireMessage
499
593
  const trackId = msg["#"]
500
594
  if (trackId && pendingTimeouts.has(trackId)) {
501
- const timeoutConfig = pendingTimeouts.get(trackId)!
595
+ const lex = pendingTimeouts.get(trackId)!
502
596
  pendingTimeouts.delete(trackId)
503
597
 
504
- timeoutConfig.timeoutId = setTimeout(() => {
598
+ setTimeout(() => {
505
599
  const cb = queue[trackId]
506
600
  if (cb) {
507
- const id = timeoutConfig.lex["#"]
601
+ const id = lex["#"]
508
602
  const ack: Graph = { [id]: null as never }
509
- if (typeof timeoutConfig.lex["."] === "string") {
510
- ack[id] = { [timeoutConfig.lex["."]]: null } as never
603
+ if (typeof lex["."] === "string") {
604
+ ack[id] = { [lex["."]]: null } as never
511
605
  }
512
606
  cb({ "#": trackId, put: ack })
513
607
  delete queue[trackId]
514
608
  }
515
- }, timeoutConfig.wait)
609
+ }, isTestEnv ? 100 : 10000)
516
610
  }
517
611
 
518
612
  clients().forEach(client => {
@@ -643,6 +737,23 @@ const Wire = (opt: HolsterOptions): WireAPI => {
643
737
  let queueProcessor: NodeJS.Timeout | null = null
644
738
  const maxQueueLength = options.maxQueueLength || 10000
645
739
 
740
+ const startResponseTimeout = (trackId: string, lex: Lex, delay = isTestEnv ? 100 : 10000): ReturnType<typeof setTimeout> => {
741
+ return setTimeout(() => {
742
+ offlineTimeouts.delete(trackId)
743
+ pendingTimeouts.delete(trackId)
744
+ const cb = queue[trackId]
745
+ if (cb) {
746
+ const id = lex["#"]
747
+ const ack: Graph = { [id]: null as never }
748
+ if (typeof lex["."] === "string") {
749
+ ack[id] = { [lex["."]]: null } as never
750
+ }
751
+ cb({ "#": trackId, put: ack })
752
+ delete queue[trackId]
753
+ }
754
+ }, delay)
755
+ }
756
+
646
757
  const processQueue = (): void => {
647
758
  if (messageQueue.length === 0) {
648
759
  queueProcessor = null
@@ -664,27 +775,27 @@ const Wire = (opt: HolsterOptions): WireAPI => {
664
775
  const data = messageQueue[0]!
665
776
  const sent = sendToPeers(data)
666
777
 
667
- if (sent) {
668
- messageQueue.shift()
778
+ const msg = JSON.parse(data) as WireMessage
779
+ const trackId = msg["#"]
669
780
 
670
- const msg = JSON.parse(data) as WireMessage
671
- const trackId = msg["#"]
672
- if (trackId && pendingTimeouts.has(trackId)) {
673
- const timeoutConfig = pendingTimeouts.get(trackId)!
674
- pendingTimeouts.delete(trackId)
781
+ // Drop GET messages with no pending callback — these were already served
782
+ // from local store so no caller is waiting for the wire response.
783
+ const isStaleCheckMessage = !sent && !!msg.get && !pendingTimeouts.has(trackId!)
675
784
 
676
- timeoutConfig.timeoutId = setTimeout(() => {
677
- const cb = queue[trackId]
678
- if (cb) {
679
- const id = timeoutConfig.lex["#"]
680
- const ack: Graph = { [id]: null as never }
681
- if (typeof timeoutConfig.lex["."] === "string") {
682
- ack[id] = { [timeoutConfig.lex["."]]: null } as never
683
- }
684
- cb({ "#": trackId, put: ack })
685
- delete queue[trackId]
686
- }
687
- }, timeoutConfig.wait)
785
+ if (sent || isStaleCheckMessage) {
786
+ messageQueue.shift()
787
+ }
788
+
789
+ if (trackId && pendingTimeouts.has(trackId)) {
790
+ const lex = pendingTimeouts.get(trackId)!
791
+ if (sent) {
792
+ if (offlineTimeouts.has(trackId)) {
793
+ clearTimeout(offlineTimeouts.get(trackId)!)
794
+ offlineTimeouts.delete(trackId)
795
+ }
796
+ startResponseTimeout(trackId, lex)
797
+ } else if (!offlineTimeouts.has(trackId)) {
798
+ offlineTimeouts.set(trackId, startResponseTimeout(trackId, lex, 0))
688
799
  }
689
800
  }
690
801
 
@@ -781,32 +892,53 @@ const Wire = (opt: HolsterOptions): WireAPI => {
781
892
  dup.track(msg["#"]!)
782
893
  if (msg.get) get(msg as never, send as never)
783
894
  if (msg.put) {
784
- const filteredPut: Graph = {}
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<string, boolean>()
785
902
  for (const [soul, node] of Object.entries(msg.put)) {
786
- let shouldStore = false
787
- if (await hasSoul(soul)) {
788
- shouldStore = true
789
- if (node && typeof node === "object") {
790
- for (const [_key, value] of Object.entries(node)) {
791
- const soulId = utils.rel.is(value as GraphValue)
792
- if (soulId) {
793
- pendingReferences.add(soulId)
794
- }
795
- }
796
- }
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 as GraphValue)
908
+ if (soulId) pendingReferences.add(soulId)
797
909
  }
910
+ }
911
+
912
+ // Main pass: filter using cached hasSoul results and the now-complete
913
+ // pendingReferences set.
914
+ const filteredPut: Graph = {}
915
+ const pendingToDelete: string[] = []
916
+ for (const [soul, node] of Object.entries(msg.put)) {
917
+ let shouldStore = hasSoulMap.get(soul)
798
918
  if (pendingReferences.has(soul)) {
799
919
  shouldStore = true
800
- pendingReferences.delete(soul)
801
- }
802
- if (shouldStore) {
803
- 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)
804
927
  }
928
+ if (shouldStore) filteredPut[soul] = node
805
929
  }
930
+
931
+ // Store filtered data if any
806
932
  if (Object.keys(filteredPut).length > 0) {
933
+ // Create filtered message for Ham.mix with original message ID
807
934
  const filteredMsg = { put: filteredPut, "#": msg["#"]! }
808
935
  await put(filteredMsg, send as never)
809
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))
810
942
  }
811
943
 
812
944
  const id = msg["@"]