@mblaney/holster 2.1.4 → 2.2.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/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@mblaney/holster",
3
- "version": "2.1.4",
3
+ "version": "2.2.1",
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",
7
7
  "scripts": {
8
- "test": "node --test",
8
+ "test": "node --test test/*.test.js test/system/*.test.js",
9
9
  "test:ts": "node --test --import tsx/esm ts/test/*.test.ts ts/test/system/*.test.ts",
10
10
  "build": "esbuild src/holster.js --bundle --format=esm --sourcemap \"--external:node:*\" \"--external:ws\" --minify --outdir=build",
11
11
  "build:ts": "esbuild ts/src/holster.ts --bundle --format=esm --sourcemap \"--external:node:*\" \"--external:ws\" --minify --outdir=dist",
package/src/holster.js CHANGED
@@ -2,6 +2,7 @@ import * as utils from "./utils.js"
2
2
  import Wire from "./wire.js"
3
3
  import User from "./user.js"
4
4
  import SEA from "./sea.js"
5
+ import {attachNested} from "./nested.js"
5
6
 
6
7
  const Holster = opt => {
7
8
  if (typeof opt === "string") opt = {peers: [opt]}
@@ -14,6 +15,12 @@ const Holster = opt => {
14
15
  const map = new Map()
15
16
  // Allow concurrent calls to the api by storing each context.
16
17
  const allctx = new Map()
18
+ // Tracks on(..., {nested: true}) subscriptions, keyed by the caller's
19
+ // own callback (the same reference off() will be called with) - see
20
+ // attachNested in nested.js. The actual top-level/child listeners it
21
+ // creates use their own, separate contexts, so off() just needs to
22
+ // call the detach function this stores to tear the whole thing down.
23
+ const nested = new Map()
17
24
  // Serializes concurrent creation of a missing rel for the same
18
25
  // soul+item, so only the first caller creates it and the rest reuse
19
26
  // its result rather than each minting a competing soul. Map<soul,
@@ -58,6 +65,25 @@ const Holster = opt => {
58
65
  return `error ${error} cannot be converted to a graph`
59
66
  }
60
67
 
68
+ // Reads the current data at a rel's target soul, retrying briefly since a
69
+ // freshly created rel's target can still be mid-flight when first read.
70
+ // Shared by a one-off get() resolving rels found on the returned node,
71
+ // and by on()'s live retry when a watched property turns out to be a rel.
72
+ const readRelTarget = async (id, ctxUser, _opt, retries = 0) => {
73
+ const data = await new Promise(res => {
74
+ const _ctxid = utils.text.random()
75
+ allctx.set(_ctxid, {chain: [{item: null, soul: id}], user: ctxUser})
76
+ api(_ctxid).next(
77
+ null,
78
+ res,
79
+ retries === 0 ? utils.obj.put(_opt, "fast", true) : _opt,
80
+ )
81
+ })
82
+ if (data !== null || retries >= 5) return data
83
+ await new Promise(resolve => setTimeout(resolve, 50))
84
+ return readRelTarget(id, ctxUser, _opt, retries + 1)
85
+ }
86
+
61
87
  const api = ctxid => {
62
88
  const get = (lex, soul, ack, _opt) => {
63
89
  wire.get(
@@ -73,24 +99,13 @@ const Holster = opt => {
73
99
  Object.keys(msg.put[soul]).map(async key => {
74
100
  const id = utils.rel.is(msg.put[soul][key])
75
101
  if (!id) return
76
- const attemptRead = async (retries = 0) => {
77
- const data = await new Promise(res => {
78
- const _ctxid = utils.text.random()
79
- const ctx = allctx.get(ctxid)
80
- allctx.set(_ctxid, {
81
- chain: [{item: null, soul: id}],
82
- user: ctx ? ctx.user : null,
83
- })
84
- api(_ctxid).next(null, res, _opt)
85
- })
86
- if (data !== null || retries >= 5) {
87
- return data
88
- }
89
- // Data not ready, retry after delay
90
- await new Promise(resolve => setTimeout(resolve, 50))
91
- return attemptRead(retries + 1)
92
- }
93
- msg.put[soul][key] = await attemptRead()
102
+
103
+ const ctx = allctx.get(ctxid)
104
+ msg.put[soul][key] = await readRelTarget(
105
+ id,
106
+ ctx ? ctx.user : null,
107
+ _opt,
108
+ )
94
109
  }),
95
110
  )
96
111
  ack(msg.put[soul])
@@ -680,6 +695,23 @@ const Holster = opt => {
680
695
  // Get the context to check if it has user info
681
696
  const ctx = allctx.get(ctxid)
682
697
 
698
+ if (_opt && _opt.nested) {
699
+ // attachNested calls this once for the top level and again for
700
+ // every discovered child, each needing its own independent
701
+ // context - a single shared _ctxid would accumulate .next()
702
+ // calls onto the same path instead of giving sibling paths.
703
+ const factory = () => {
704
+ const _ctxid = utils.text.random()
705
+ allctx.set(_ctxid, {
706
+ chain: [{item: item, soul: soul}],
707
+ user: ctx ? ctx.user : null,
708
+ })
709
+ return api(_ctxid)
710
+ }
711
+ nested.set(cb, attachNested(factory, cb, _get, _opt))
712
+ return
713
+ }
714
+
683
715
  // Flag that this context is set from on and shouldn't be removed.
684
716
  allctx.set(ctxid, {
685
717
  chain: [{item: item, soul: soul}],
@@ -689,10 +721,19 @@ const Holster = opt => {
689
721
  // Map the user's callback because it can also be passed to off,
690
722
  // so need a reference to it to compare them.
691
723
  // Shared retry state for this on() subscription.
692
- let retryCount = 0
693
724
  const maxRetries = 5
694
725
  const retryDelay = 1000 // Start with 1 second
695
726
  let retryTimer = null
727
+ // Which soul the persistent wire listener is currently attached to
728
+ // - starts on the parent (soul/item), and moves to a rel's target
729
+ // soul once item turns out to be one, since further updates land
730
+ // on the target's own soul, not the parent's. Tracked (rather than
731
+ // switching unconditionally) so the two places that can discover a
732
+ // rel - the initial check below and a later resolveValue retry -
733
+ // don't both re-register the same listener. Scoped to this one
734
+ // on() call's closure, so concurrent subscriptions (even on the
735
+ // same key) each track their own independently.
736
+ let listenedSoul = soul
696
737
 
697
738
  map.set(cb, () => {
698
739
  // Bail out if off() has already cleaned up this context — the mapped
@@ -704,84 +745,75 @@ const Holster = opt => {
704
745
  // have an update and should not call cb twice.
705
746
  clearTimeout(retryTimer)
706
747
  retryTimer = null
707
- // When listener fires, re-check if the item is now a rel.
748
+
749
+ // Resolves {soul, item} to a genuine value, following a rel to its
750
+ // target if the property turns out to be one - checked fresh on
751
+ // every attempt, not just the first, since a property can still
752
+ // be a plain undefined/null on an early check and only become a
753
+ // rel once a later retry catches up (e.g. a nested rel-creating
754
+ // put both adds "item" as a rel on this node AND populates its
755
+ // target, and either half can still be mid-flight when the
756
+ // listener first fires). wire.get() can't reliably tell
757
+ // "genuinely null" apart from "hasn't landed yet" either - its
758
+ // own internal timeout fallback explicitly answers null when
759
+ // nothing comes back in time - so a null/missing value is
760
+ // retried a few times before being trusted as final, the same
761
+ // way the rel branch below already retries on a null target
762
+ // read.
763
+ const resolveValue = (retries, node, value) => {
764
+ const id = utils.rel.is(value)
765
+ if (id) {
766
+ if (listenedSoul !== id) {
767
+ wire.off({"#": listenedSoul}, map.get(cb))
768
+ wire.on({"#": id, ".": null}, map.get(cb), false, _opt)
769
+ listenedSoul = id
770
+ }
771
+ readRelTarget(id, ctx ? ctx.user : null, _opt).then(data => {
772
+ retryTimer = null
773
+ cb(data)
774
+ })
775
+ return
776
+ }
777
+
778
+ if (value !== undefined && value !== null) {
779
+ retryTimer = null
780
+ cb(value)
781
+ return
782
+ }
783
+ if (retries >= maxRetries) {
784
+ retryTimer = null
785
+ cb(value !== undefined ? value : null)
786
+ return
787
+ }
788
+ // Fast once the parent node already exists (a value is likely
789
+ // imminent); slow/exponential when it doesn't, since there's
790
+ // no signal anything is coming soon and fast polling would
791
+ // just waste requests.
792
+ const delay = node
793
+ ? 50
794
+ : Math.min(retryDelay * Math.pow(2, retries), 30000)
795
+ retryTimer = setTimeout(() => {
796
+ wire.get(
797
+ {"#": soul, ".": item},
798
+ msg => {
799
+ const retryNode = msg.put && msg.put[soul]
800
+ resolveValue(
801
+ retries + 1,
802
+ retryNode,
803
+ retryNode && retryNode[item],
804
+ )
805
+ },
806
+ {..._opt, secure: !!(ctx.user || opt.secure)},
807
+ )
808
+ }, delay)
809
+ }
810
+
811
+ // When listener fires, re-check the item's current state.
708
812
  wire.get(
709
813
  {"#": soul, ".": item},
710
814
  msg => {
711
815
  const node = msg.put && msg.put[soul]
712
- const current = node && node[item]
713
- const id = utils.rel.is(current)
714
- if (id) {
715
- // It's a rel, read the related node. It might not be in the
716
- // graph yet when the listener fires, so we retry with delays.
717
- const attemptRead = async (retries = 0) => {
718
- const data = await new Promise(res => {
719
- const _ctxid = utils.text.random()
720
- allctx.set(_ctxid, {
721
- chain: [{item: null, soul: id}],
722
- user: ctx ? ctx.user : null,
723
- })
724
- api(_ctxid).next(
725
- null,
726
- res,
727
- retries === 0 ? utils.obj.put(_opt, "fast", true) : _opt,
728
- )
729
- })
730
- if (data !== null || retries >= 5) {
731
- retryCount = 0 // Reset retry counter on success
732
- cb(data)
733
- } else {
734
- // Data not ready, retry after delay
735
- await new Promise(resolve => setTimeout(resolve, 50))
736
- attemptRead(retries + 1)
737
- }
738
- }
739
- attemptRead()
740
- } else {
741
- // It's a direct property or null.
742
- const nodeExists = !!node
743
-
744
- if (nodeExists) {
745
- // Node exists — return the value and reset retry state.
746
- retryCount = 0
747
- cb(current !== undefined ? current : null)
748
- } else if (retryCount < maxRetries) {
749
- // No data yet — keep polling until we get a response or
750
- // exhaust retries. on() with _get can return null on a cache
751
- // miss and never fire again if the data doesn't change while
752
- // the listener is attached, so we must poll for initial load.
753
- const retry = () => {
754
- const delay = Math.min(
755
- retryDelay * Math.pow(2, retryCount),
756
- 30000,
757
- )
758
- retryCount++
759
- retryTimer = setTimeout(() => {
760
- wire.get(
761
- {"#": soul, ".": item},
762
- msg => {
763
- const retryNode = msg.put && msg.put[soul]
764
- if (retryNode) {
765
- retryCount = 0
766
- retryTimer = null
767
- const retryValue = retryNode[item]
768
- cb(retryValue !== undefined ? retryValue : null)
769
- } else if (retryCount < maxRetries) {
770
- retry()
771
- } else {
772
- retryTimer = null
773
- cb(null)
774
- }
775
- },
776
- {..._opt, secure: !!(ctx.user || opt.secure)},
777
- )
778
- }, delay)
779
- }
780
- retry()
781
- } else {
782
- cb(null)
783
- }
784
- }
816
+ resolveValue(0, node, node && node[item])
785
817
  },
786
818
  {..._opt, secure: !!(ctx.user || opt.secure)},
787
819
  )
@@ -816,11 +848,14 @@ const Holster = opt => {
816
848
  const current = msg.put && msg.put[soul] && msg.put[soul][item]
817
849
  const id = utils.rel.is(current)
818
850
  if (id) {
819
- // It's a rel, need to switch listener to the related node
820
- // First remove the initial listener
821
- wire.off(initialLex, map.get(cb))
822
- // Then add listener on the related node
823
- wire.on({"#": id, ".": null}, map.get(cb), false, _opt)
851
+ // It's a rel, need to switch listener to the related node -
852
+ // unless resolveValue (triggered by the initial listener
853
+ // firing) already beat this check to it.
854
+ if (listenedSoul !== id) {
855
+ wire.off({"#": listenedSoul}, map.get(cb))
856
+ wire.on({"#": id, ".": null}, map.get(cb), false, _opt)
857
+ listenedSoul = id
858
+ }
824
859
  if (_get) map.get(cb)()
825
860
  } else if (_get) {
826
861
  // Not a rel, but _get was requested, so trigger callback.
@@ -837,6 +872,12 @@ const Holster = opt => {
837
872
  return
838
873
  }
839
874
 
875
+ if (nested.has(cb)) {
876
+ nested.get(cb)()
877
+ nested.delete(cb)
878
+ return
879
+ }
880
+
840
881
  // Resolve the current context before removing event listener.
841
882
  const {item, soul} = resolve({off: true}, cb)
842
883
  if (!soul) return
package/src/nested.js ADDED
@@ -0,0 +1,89 @@
1
+ // Backs on(cb, _get, {nested: true}) - see holster.js. A plain on() only
2
+ // resolves the node it's given; it never re-checks a child whose own
3
+ // value is an object (which always has its own soul, since only scalars
4
+ // live directly on a node) once that child's content changes in place -
5
+ // nothing on the parent's own soul changes when that happens, so a
6
+ // listener on the parent alone never sees it. get()'s own read-resolution
7
+ // (holster.js) already recursively resolves every such child into plain
8
+ // data for a one-time read - this doesn't repeat that; it adds a live
9
+ // subscription for each child that resolution already exposed, so a
10
+ // later change to it reaches the caller too.
11
+ //
12
+ // Pure and holster-internals-free: chain is a factory - () =>
13
+ // user.get([host, "shared"]).next("domains").next(code) - not a
14
+ // pre-built chained object, since a fresh, independent instance is
15
+ // needed per discovered child (reusing one chained object across
16
+ // multiple .next() calls would push them onto the same path instead of
17
+ // giving sibling paths).
18
+ export function attachNested(chain, cb, _get, _opt) {
19
+ // Stripped before being used on any .on() call this function makes
20
+ // itself (top-level or child) - otherwise a caller-provided {nested:
21
+ // true} would re-trigger holster.js's own nested check on re-entry,
22
+ // before the normal rel-resolution logic that actually finds the right
23
+ // soul ever runs.
24
+ const opt = {..._opt}
25
+ delete opt.nested
26
+
27
+ const childListeners = new Map() // key -> {api, handler}
28
+ let latest = null
29
+
30
+ const notify = () => cb(latest === null ? null : {...latest})
31
+
32
+ const topLevelHandler = data => {
33
+ if (data === null || typeof data !== "object") {
34
+ latest = data
35
+ notify()
36
+ return
37
+ }
38
+
39
+ if (!latest || typeof latest !== "object") latest = {}
40
+ for (const [key, value] of Object.entries(data)) {
41
+ if (key === "_") continue
42
+ latest[key] = value
43
+
44
+ if (
45
+ value !== null &&
46
+ typeof value === "object" &&
47
+ !childListeners.has(key)
48
+ ) {
49
+ const childApi = chain().next(key)
50
+ const handler = childData => {
51
+ if (childData === null) {
52
+ delete latest[key]
53
+ childListeners.delete(key)
54
+ } else {
55
+ latest[key] = childData
56
+ }
57
+ notify()
58
+ }
59
+ childListeners.set(key, {api: childApi, handler})
60
+ // false, not true - the current value is already in data above,
61
+ // so this only needs to watch for future changes, not trigger a
62
+ // redundant immediate re-read.
63
+ childApi.on(handler, false, opt)
64
+ }
65
+ }
66
+
67
+ for (const key of [...childListeners.keys()]) {
68
+ if (!(key in data)) {
69
+ const {api: childApi, handler} = childListeners.get(key)
70
+ childApi.off(handler)
71
+ childListeners.delete(key)
72
+ delete latest[key]
73
+ }
74
+ }
75
+
76
+ notify()
77
+ }
78
+
79
+ const topLevelApi = chain()
80
+ topLevelApi.on(topLevelHandler, _get, opt)
81
+
82
+ return () => {
83
+ topLevelApi.off(topLevelHandler)
84
+ for (const {api: childApi, handler} of childListeners.values()) {
85
+ childApi.off(handler)
86
+ }
87
+ childListeners.clear()
88
+ }
89
+ }
package/src/wire.js CHANGED
@@ -452,14 +452,18 @@ const Wire = opt => {
452
452
  continue
453
453
  }
454
454
  const limit = userLimits.get(pub) ?? defaultLimit
455
- if ((userStorage.get(pub) ?? 0) >= limit) continue
456
- toStore[soul] = node
455
+ const current = userStorage.get(pub) ?? 0
457
456
  const oldBytes = oldGraphBytes[soul] ?? 0
458
457
  const newBytes = graph[soul] ? JSON.stringify(graph[soul]).length : 0
459
- userStorage.set(
460
- pub,
461
- Math.max(0, (userStorage.get(pub) ?? 0) - oldBytes + newBytes),
462
- )
458
+ const updated = Math.max(0, current - oldBytes + newBytes)
459
+ // A write that doesn't grow this user's total usage (e.g. a
460
+ // delete) is always let through, even past the limit - otherwise
461
+ // a user already over quota could never delete anything to get
462
+ // back under it. Only a write that both grows usage and pushes
463
+ // the total past the limit is rejected.
464
+ if (updated > current && updated > limit) continue
465
+ toStore[soul] = node
466
+ userStorage.set(pub, updated)
463
467
  saveUserStorage()
464
468
  }
465
469
  if (Object.keys(toStore).length > 0) {
@@ -683,16 +687,22 @@ const Wire = opt => {
683
687
  if (!pub) continue
684
688
  const limit = userLimits.get(pub) ?? defaultLimit
685
689
  const current = userStorage.get(pub) ?? 0
686
- if (current >= limit) {
687
- if (cb) cb("storage limit exceeded")
688
- return
689
- }
690
-
691
690
  const oldBytes = oldGraphBytes[soul] ?? 0
692
691
  const newBytes = graph[soul]
693
692
  ? JSON.stringify(graph[soul]).length
694
693
  : 0
695
- userStorage.set(pub, Math.max(0, current - oldBytes + newBytes))
694
+ const updated = Math.max(0, current - oldBytes + newBytes)
695
+ // A write that doesn't grow this user's total usage (e.g. a
696
+ // delete) is always let through, even past the limit - otherwise
697
+ // a user already over quota could never delete anything to get
698
+ // back under it. Only a write that both grows usage and pushes
699
+ // the total past the limit is rejected.
700
+ if (updated > current && updated > limit) {
701
+ if (cb) cb("storage limit exceeded")
702
+ return
703
+ }
704
+
705
+ userStorage.set(pub, updated)
696
706
  saveUserStorage()
697
707
  }
698
708
  }
@@ -1048,6 +1058,19 @@ const Wire = opt => {
1048
1058
  }
1049
1059
 
1050
1060
  const send = data => {
1061
+ // Reject client-side, before ever attempting to send, rather than
1062
+ // silently succeeding locally while the receiving peer's own
1063
+ // validateMessage (wire.js's server-side inbound check) drops it and
1064
+ // replies with an error nothing here is listening for - so a save
1065
+ // that's too big to ever sync would otherwise look like it worked.
1066
+ const maxMessageSize = opt.maxMessageSize || 1024 * 1024
1067
+ if (data.length > maxMessageSize) {
1068
+ return {
1069
+ err: `Message too large (${data.length} > ${maxMessageSize} bytes). Reduce the size of the data being saved.`,
1070
+ size: data.length,
1071
+ maxMessageSize,
1072
+ }
1073
+ }
1051
1074
  if (messageQueue.length >= maxQueueLength) {
1052
1075
  return {
1053
1076
  err: `Message queue exceeded maximum length (${maxQueueLength}). Update query logic to request less data.`,