@mblaney/holster 2.2.0 → 2.2.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.2.0",
3
+ "version": "2.2.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
@@ -65,6 +65,25 @@ const Holster = opt => {
65
65
  return `error ${error} cannot be converted to a graph`
66
66
  }
67
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
+
68
87
  const api = ctxid => {
69
88
  const get = (lex, soul, ack, _opt) => {
70
89
  wire.get(
@@ -80,24 +99,13 @@ const Holster = opt => {
80
99
  Object.keys(msg.put[soul]).map(async key => {
81
100
  const id = utils.rel.is(msg.put[soul][key])
82
101
  if (!id) return
83
- const attemptRead = async (retries = 0) => {
84
- const data = await new Promise(res => {
85
- const _ctxid = utils.text.random()
86
- const ctx = allctx.get(ctxid)
87
- allctx.set(_ctxid, {
88
- chain: [{item: null, soul: id}],
89
- user: ctx ? ctx.user : null,
90
- })
91
- api(_ctxid).next(null, res, _opt)
92
- })
93
- if (data !== null || retries >= 5) {
94
- return data
95
- }
96
- // Data not ready, retry after delay
97
- await new Promise(resolve => setTimeout(resolve, 50))
98
- return attemptRead(retries + 1)
99
- }
100
- 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
+ )
101
109
  }),
102
110
  )
103
111
  ack(msg.put[soul])
@@ -159,7 +167,7 @@ const Holster = opt => {
159
167
  const node = msg.put && msg.put[soul]
160
168
  const id = utils.rel.is(node && node[item])
161
169
  if (!id) {
162
- // If it's a plain value (not a rel) and this is a get, deliver it.
170
+ // If it's a plain value (not a rel) and using get, deliver it.
163
171
  // node._ guards against timeout null-acks which lack metadata.
164
172
  if (!on && node && node._ && typeof node[item] !== "undefined") {
165
173
  if (timer) clearTimeout(timer)
@@ -181,7 +189,9 @@ const Holster = opt => {
181
189
  {...request._opt, secure: !!(ctx.user || opt.secure)},
182
190
  )
183
191
  }
184
- wire.on({"#": soul, ".": item}, handler, false, request._opt)
192
+ // The get parameter is set to true to call handler immediately for
193
+ // listeners set on nested properties.
194
+ wire.on({"#": soul, ".": item}, handler, true, request._opt)
185
195
  // Time out after the same total duration as the on() retry loop
186
196
  // (1+2+4+8+16 = 31s) so callers are not blocked forever if the node
187
197
  // genuinely doesn't exist.
@@ -317,7 +327,7 @@ const Holster = opt => {
317
327
  api(ctxid).put(request.put, cb)
318
328
  })
319
329
  } else if (on) {
320
- // Item is not a rel yet — watch the parent soul for when it
330
+ // Item is not a rel yet - watch the parent soul for when it
321
331
  // becomes one, then retry chain resolution.
322
332
  watchForRel(soul, item, ctx, i, request, cb)
323
333
  if (request._get) cb(null)
@@ -338,7 +348,7 @@ const Holster = opt => {
338
348
  })
339
349
  } else {
340
350
  if (on) {
341
- // Node doesn't exist yet — watch for it to appear.
351
+ // Node doesn't exist yet - watch for it to appear.
342
352
  watchForRel(soul, item, ctx, i, request, cb)
343
353
  if (request._get) cb(null)
344
354
  } else if (get && node) {
@@ -348,14 +358,14 @@ const Holster = opt => {
348
358
  Object.keys(sv).length > 0 &&
349
359
  typeof sv[item] === "undefined"
350
360
  ) {
351
- // State vector has other properties but not this one — never written.
361
+ // State vector has other properties but not this one.
352
362
  if (cb) cb(null)
353
363
  } else {
354
- // Empty state vector or item is tracked — may arrive via push.
364
+ // Empty state vector or item is tracked, may arrive via push.
355
365
  watchForRel(soul, item, ctx, i, request, cb, false)
356
366
  }
357
367
  } else {
358
- // Soul doesn't exist or no get — return null.
368
+ // Soul doesn't exist or no get - return null.
359
369
  if (cb) cb(null)
360
370
  }
361
371
  }
@@ -690,7 +700,7 @@ const Holster = opt => {
690
700
  if (_opt && _opt.nested) {
691
701
  // attachNested calls this once for the top level and again for
692
702
  // every discovered child, each needing its own independent
693
- // context - a single shared _ctxid would accumulate .next()
703
+ // context. A single shared _ctxid would accumulate .next()
694
704
  // calls onto the same path instead of giving sibling paths.
695
705
  const factory = () => {
696
706
  const _ctxid = utils.text.random()
@@ -713,99 +723,98 @@ const Holster = opt => {
713
723
  // Map the user's callback because it can also be passed to off,
714
724
  // so need a reference to it to compare them.
715
725
  // Shared retry state for this on() subscription.
716
- let retryCount = 0
717
726
  const maxRetries = 5
718
727
  const retryDelay = 1000 // Start with 1 second
719
728
  let retryTimer = null
729
+ // Which soul the persistent wire listener is currently attached to.
730
+ // Starts on the parent (soul/item), and moves to a rel's target soul
731
+ // once item is created, since further updates land on the target's own
732
+ // soul, not the parent's. Tracked rather than switching unconditionally
733
+ // so that the two places that can discover a rel - the initial check
734
+ // below and a later resolveValue retry - don't both re-register the
735
+ // same listener. Scoped to this one on() call's closure, so concurrent
736
+ // subscriptions (even on the same key) can each track their own.
737
+ let listenedSoul = soul
720
738
 
721
739
  map.set(cb, () => {
722
- // Bail out if off() has already cleaned up this context — the mapped
740
+ // Bail out if off() has already cleaned up this context. The mapped
723
741
  // callback can fire twice (once from wire.on and once from the _get
724
742
  // immediate-read path) and the second call after async context
725
743
  // cleanup would crash in resolve(). Matches the guard in watchForRel.
726
744
  if (!allctx.has(ctxid)) return
727
- // Cancel any pending retry — the persistent listener firing means we
745
+
746
+ // Cancel any pending retry. The persistent listener firing means we
728
747
  // have an update and should not call cb twice.
729
748
  clearTimeout(retryTimer)
730
749
  retryTimer = null
731
- // When listener fires, re-check if the item is now a rel.
750
+
751
+ // Resolves {soul, item} to a genuine value, following a rel to its
752
+ // target if the property turns out to be one. This is checked on
753
+ // every attempt, since a property can still be a plain undefined/null
754
+ // on an early check and only become a rel once a later retry catches
755
+ // up (e.g. a nested rel-creating put both adds "item" as a rel on
756
+ // this node AND populates its target, and either half can still be
757
+ // mid-flight when the listener first fires). Also wire.get() can't
758
+ // reliably tell "genuinely null" apart from "hasn't landed yet", its
759
+ // own internal timeout fallback explicitly answers null when
760
+ // nothing comes back in time - so a null/missing value is
761
+ // retried a few times before being trusted as final, the same
762
+ // way the rel branch below already retries on a null target
763
+ // read.
764
+ const resolveValue = (retries, node, value) => {
765
+ const id = utils.rel.is(value)
766
+ if (id) {
767
+ if (listenedSoul !== id) {
768
+ wire.off({"#": listenedSoul}, map.get(cb))
769
+ wire.on({"#": id, ".": null}, map.get(cb), false, _opt)
770
+ listenedSoul = id
771
+ }
772
+ readRelTarget(id, ctx ? ctx.user : null, _opt).then(data => {
773
+ retryTimer = null
774
+ cb(data)
775
+ })
776
+ return
777
+ }
778
+
779
+ if (value !== undefined && value !== null) {
780
+ retryTimer = null
781
+ cb(value)
782
+ return
783
+ }
784
+ if (retries >= maxRetries) {
785
+ retryTimer = null
786
+ cb(value !== undefined ? value : null)
787
+ return
788
+ }
789
+ // Fast once the parent node already exists (a value is likely
790
+ // imminent); slow/exponential when it doesn't, since there's
791
+ // no signal anything is coming soon and fast polling would
792
+ // just waste requests.
793
+ const delay = node
794
+ ? 50
795
+ : Math.min(retryDelay * Math.pow(2, retries), 30000)
796
+ retryTimer = setTimeout(() => {
797
+ wire.get(
798
+ {"#": soul, ".": item},
799
+ msg => {
800
+ const retryNode = msg.put && msg.put[soul]
801
+ resolveValue(
802
+ retries + 1,
803
+ retryNode,
804
+ retryNode && retryNode[item],
805
+ )
806
+ },
807
+ {..._opt, secure: !!(ctx.user || opt.secure)},
808
+ )
809
+ }, delay)
810
+ }
811
+
812
+ // When listener fires, re-check the item's current state.
732
813
  wire.get(
733
814
  {"#": soul, ".": item},
734
815
  msg => {
735
816
  const node = msg.put && msg.put[soul]
736
- const current = node && node[item]
737
- const id = utils.rel.is(current)
738
- if (id) {
739
- // It's a rel, read the related node. It might not be in the
740
- // graph yet when the listener fires, so we retry with delays.
741
- const attemptRead = async (retries = 0) => {
742
- const data = await new Promise(res => {
743
- const _ctxid = utils.text.random()
744
- allctx.set(_ctxid, {
745
- chain: [{item: null, soul: id}],
746
- user: ctx ? ctx.user : null,
747
- })
748
- api(_ctxid).next(
749
- null,
750
- res,
751
- retries === 0 ? utils.obj.put(_opt, "fast", true) : _opt,
752
- )
753
- })
754
- if (data !== null || retries >= 5) {
755
- retryCount = 0 // Reset retry counter on success
756
- cb(data)
757
- } else {
758
- // Data not ready, retry after delay
759
- await new Promise(resolve => setTimeout(resolve, 50))
760
- attemptRead(retries + 1)
761
- }
762
- }
763
- attemptRead()
764
- } else {
765
- // It's a direct property or null.
766
- const nodeExists = !!node
767
-
768
- if (nodeExists) {
769
- // Node exists — return the value and reset retry state.
770
- retryCount = 0
771
- cb(current !== undefined ? current : null)
772
- } else if (retryCount < maxRetries) {
773
- // No data yet — keep polling until we get a response or
774
- // exhaust retries. on() with _get can return null on a cache
775
- // miss and never fire again if the data doesn't change while
776
- // the listener is attached, so we must poll for initial load.
777
- const retry = () => {
778
- const delay = Math.min(
779
- retryDelay * Math.pow(2, retryCount),
780
- 30000,
781
- )
782
- retryCount++
783
- retryTimer = setTimeout(() => {
784
- wire.get(
785
- {"#": soul, ".": item},
786
- msg => {
787
- const retryNode = msg.put && msg.put[soul]
788
- if (retryNode) {
789
- retryCount = 0
790
- retryTimer = null
791
- const retryValue = retryNode[item]
792
- cb(retryValue !== undefined ? retryValue : null)
793
- } else if (retryCount < maxRetries) {
794
- retry()
795
- } else {
796
- retryTimer = null
797
- cb(null)
798
- }
799
- },
800
- {..._opt, secure: !!(ctx.user || opt.secure)},
801
- )
802
- }, delay)
803
- }
804
- retry()
805
- } else {
806
- cb(null)
807
- }
808
- }
817
+ resolveValue(0, node, node && node[item])
809
818
  },
810
819
  {..._opt, secure: !!(ctx.user || opt.secure)},
811
820
  )
@@ -840,11 +849,14 @@ const Holster = opt => {
840
849
  const current = msg.put && msg.put[soul] && msg.put[soul][item]
841
850
  const id = utils.rel.is(current)
842
851
  if (id) {
843
- // It's a rel, need to switch listener to the related node
844
- // First remove the initial listener
845
- wire.off(initialLex, map.get(cb))
846
- // Then add listener on the related node
847
- wire.on({"#": id, ".": null}, map.get(cb), false, _opt)
852
+ // It's a rel, need to switch listener to the related node,
853
+ // unless resolveValue (triggered by the initial listener
854
+ // firing) already beat this check to it.
855
+ if (listenedSoul !== id) {
856
+ wire.off({"#": listenedSoul}, map.get(cb))
857
+ wire.on({"#": id, ".": null}, map.get(cb), false, _opt)
858
+ listenedSoul = id
859
+ }
848
860
  if (_get) map.get(cb)()
849
861
  } else if (_get) {
850
862
  // Not a rel, but _get was requested, so trigger callback.
@@ -867,7 +879,7 @@ const Holster = opt => {
867
879
  return
868
880
  }
869
881
 
870
- // Resolve the current context before removing event listener.
882
+ // Resolve the current context before removing the event listener.
871
883
  const {item, soul} = resolve({off: true}, cb)
872
884
  if (!soul) return
873
885
 
package/src/wire.js CHANGED
@@ -1058,6 +1058,19 @@ const Wire = opt => {
1058
1058
  }
1059
1059
 
1060
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
+ }
1061
1074
  if (messageQueue.length >= maxQueueLength) {
1062
1075
  return {
1063
1076
  err: `Message queue exceeded maximum length (${maxQueueLength}). Update query logic to request less data.`,
package/ts/src/holster.ts CHANGED
@@ -132,6 +132,30 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
132
132
  return `error ${error} cannot be converted to a graph`
133
133
  }
134
134
 
135
+ // Reads the current data at a rel's target soul, retrying briefly since a
136
+ // freshly created rel's target can still be mid-flight when first read.
137
+ // Shared by a one-off get() resolving rels found on the returned node,
138
+ // and by on()'s live retry when a watched property turns out to be a rel.
139
+ const readRelTarget = async (
140
+ id: string,
141
+ ctxUser: UserIdentity | null | undefined,
142
+ opt: WireOptions | undefined,
143
+ retries = 0,
144
+ ): Promise<unknown> => {
145
+ const data = await new Promise<unknown>(res => {
146
+ const _ctxid = utils.text.random()
147
+ allctx.set(_ctxid, {chain: [{item: null, soul: id}], user: ctxUser})
148
+ api(_ctxid).next(
149
+ null as never,
150
+ res as never,
151
+ (retries === 0 ? utils.obj.put(opt, "fast", true) : opt) as never,
152
+ )
153
+ })
154
+ if (data !== null || retries >= 5) return data
155
+ await new Promise(resolve => setTimeout(resolve, 50))
156
+ return readRelTarget(id, ctxUser, opt, retries + 1)
157
+ }
158
+
135
159
  const api = (initCtxid?: string): HolsterAPI => {
136
160
  let ctxid = initCtxid
137
161
  const get = (
@@ -157,23 +181,12 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
157
181
  Object.keys(node).map(async key => {
158
182
  const id = utils.rel.is(node[key] as GraphValue)
159
183
  if (!id) return
160
- const attemptRead = async (retries = 0): Promise<unknown> => {
161
- const data = await new Promise<unknown>(res => {
162
- const _ctxid = utils.text.random()
163
- const ctx = allctx.get(ctxid!)
164
- allctx.set(_ctxid, {
165
- chain: [{item: null, soul: id}],
166
- user: ctx ? ctx.user : null,
167
- })
168
- api(_ctxid).next(null as never, res as never, _opt)
169
- })
170
- if (data !== null || retries >= 5) {
171
- return data
172
- }
173
- await new Promise(resolve => setTimeout(resolve, 50))
174
- return attemptRead(retries + 1)
175
- }
176
- node[key] = (await attemptRead()) as GraphValue
184
+ const ctx = allctx.get(ctxid!)
185
+ node[key] = (await readRelTarget(
186
+ id,
187
+ ctx ? ctx.user : null,
188
+ _opt,
189
+ )) as GraphValue
177
190
  }),
178
191
  )
179
192
  ack(msg.put[soul])
@@ -278,7 +291,9 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
278
291
  },
279
292
  )
280
293
  }
281
- wire.on({"#": soul, ".": item}, handler, false, request._opt)
294
+ // The get parameter is set to true to call handler immediately for
295
+ // listeners set on nested properties.
296
+ wire.on({"#": soul, ".": item}, handler, true, request._opt)
282
297
  // Time out after the same total duration as the on() retry loop
283
298
  // (1+2+4+8+16 = 31s) so callers are not blocked forever if the node
284
299
  // genuinely doesn't exist.
@@ -918,10 +933,19 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
918
933
  })
919
934
 
920
935
  // Shared retry state for this on() subscription.
921
- let retryCount = 0
922
936
  const maxRetries = 5
923
937
  const retryDelay = 1000
924
938
  let retryTimer: ReturnType<typeof setTimeout> | null = null
939
+ // Which soul the persistent wire listener is currently attached to
940
+ // - starts on the parent (soul/item), and moves to a rel's target
941
+ // soul once item turns out to be one, since further updates land
942
+ // on the target's own soul, not the parent's. Tracked (rather than
943
+ // switching unconditionally) so the two places that can discover a
944
+ // rel - the initial check below and a later resolveValue retry -
945
+ // don't both re-register the same listener. Scoped to this one
946
+ // on() call's closure, so concurrent subscriptions (even on the
947
+ // same key) each track their own independently.
948
+ let listenedSoul = soul
925
949
 
926
950
  map.set(callback, () => {
927
951
  // Bail out if off() has already cleaned up this context — the mapped
@@ -933,89 +957,89 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
933
957
  // have an update and should not call callback twice.
934
958
  clearTimeout(retryTimer!)
935
959
  retryTimer = null
960
+
961
+ // Resolves {soul, item} to a genuine value, following a rel to its
962
+ // target if the property turns out to be one - checked fresh on
963
+ // every attempt, not just the first, since a property can still
964
+ // be a plain undefined/null on an early check and only become a
965
+ // rel once a later retry catches up (e.g. a nested rel-creating
966
+ // put both adds "item" as a rel on this node AND populates its
967
+ // target, and either half can still be mid-flight when the
968
+ // listener first fires). wire.get() can't reliably tell
969
+ // "genuinely null" apart from "hasn't landed yet" either - its
970
+ // own internal timeout fallback explicitly answers null when
971
+ // nothing comes back in time - so a null/missing value is
972
+ // retried a few times before being trusted as final, the same
973
+ // way the rel branch below already retries on a null target
974
+ // read.
975
+ const resolveValue = (
976
+ retries: number,
977
+ node: Record<string, GraphValue> | null | undefined,
978
+ value: unknown,
979
+ ): void => {
980
+ const id = utils.rel.is(value as GraphValue)
981
+ if (id) {
982
+ if (listenedSoul !== id) {
983
+ wire.off({"#": listenedSoul} as never, map.get(callback)!)
984
+ wire.on(
985
+ {"#": id, ".": null} as never,
986
+ map.get(callback)!,
987
+ false,
988
+ opts,
989
+ )
990
+ listenedSoul = id
991
+ }
992
+ readRelTarget(id, ctx ? ctx.user : null, opts).then(data => {
993
+ retryTimer = null
994
+ callback(data)
995
+ })
996
+ return
997
+ }
998
+
999
+ if (value !== undefined && value !== null) {
1000
+ retryTimer = null
1001
+ callback(value)
1002
+ return
1003
+ }
1004
+ if (retries >= maxRetries) {
1005
+ retryTimer = null
1006
+ callback(value !== undefined ? value : null)
1007
+ return
1008
+ }
1009
+ // Fast once the parent node already exists (a value is likely
1010
+ // imminent); slow/exponential when it doesn't, since there's
1011
+ // no signal anything is coming soon and fast polling would
1012
+ // just waste requests.
1013
+ const delay = node
1014
+ ? 50
1015
+ : Math.min(retryDelay * Math.pow(2, retries), 30000)
1016
+ retryTimer = setTimeout(() => {
1017
+ wire.get(
1018
+ {"#": soul, ".": item!},
1019
+ retryMsg => {
1020
+ const retryNode = retryMsg.put && retryMsg.put[soul]
1021
+ resolveValue(
1022
+ retries + 1,
1023
+ retryNode,
1024
+ retryNode && retryNode[item!],
1025
+ )
1026
+ },
1027
+ {
1028
+ ...opts,
1029
+ secure: ctx
1030
+ ? (typeof ctx.user === "boolean" ? ctx.user : !!ctx.user) ||
1031
+ options.secure
1032
+ : options.secure,
1033
+ },
1034
+ )
1035
+ }, delay)
1036
+ }
1037
+
936
1038
  wire.get(
937
1039
  {"#": soul, ".": item!},
938
1040
  msg => {
939
1041
  const node = msg.put && msg.put[soul]
940
- const current = node && node[item!]
941
- const id = utils.rel.is(current as GraphValue)
942
- if (id) {
943
- const attemptRead = async (retries = 0): Promise<void> => {
944
- const data = await new Promise<unknown>(res => {
945
- const _ctxid = utils.text.random()
946
- allctx.set(_ctxid, {
947
- chain: [{item: null, soul: id}],
948
- user: ctx ? ctx.user : null,
949
- })
950
- api(_ctxid).next(
951
- null as never,
952
- res as never,
953
- (retries === 0
954
- ? utils.obj.put(opts, "fast", true)
955
- : opts) as never,
956
- )
957
- })
958
- if (data !== null || retries >= 5) {
959
- retryCount = 0
960
- callback(data)
961
- } else {
962
- await new Promise(resolve => setTimeout(resolve, 50))
963
- attemptRead(retries + 1)
964
- }
965
- }
966
- attemptRead()
967
- } else {
968
- const nodeExists = !!node
969
- if (nodeExists) {
970
- retryCount = 0
971
- callback(current !== undefined ? current : null)
972
- } else if (retryCount < maxRetries) {
973
- // No data yet — keep polling until we get a response or
974
- // exhaust retries. on() with _get can return null on a cache
975
- // miss and never fire again if the data doesn't change while
976
- // the listener is attached, so we must poll for initial load.
977
- const secureOpt = {
978
- ...opts,
979
- secure: ctx
980
- ? (typeof ctx.user === "boolean"
981
- ? ctx.user
982
- : !!ctx.user) || options.secure
983
- : options.secure,
984
- }
985
- const retry = (): void => {
986
- const delay = Math.min(
987
- retryDelay * Math.pow(2, retryCount),
988
- 30000,
989
- )
990
- retryCount++
991
- retryTimer = setTimeout(() => {
992
- wire.get(
993
- {"#": soul, ".": item!},
994
- retryMsg => {
995
- const retryNode = retryMsg.put && retryMsg.put[soul]
996
- if (retryNode) {
997
- retryCount = 0
998
- retryTimer = null
999
- const retryValue = retryNode[item!]
1000
- callback(
1001
- retryValue !== undefined ? retryValue : null,
1002
- )
1003
- } else if (retryCount < maxRetries) {
1004
- retry()
1005
- } else {
1006
- retryTimer = null
1007
- callback(null)
1008
- }
1009
- },
1010
- secureOpt,
1011
- )
1012
- }, delay)
1013
- }
1014
- retry()
1015
- } else {
1016
- callback(null)
1017
- }
1018
- }
1042
+ resolveValue(0, node, node && node[item!])
1019
1043
  },
1020
1044
  {
1021
1045
  ...opts,
@@ -1049,13 +1073,19 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
1049
1073
  const current = msg.put && msg.put[soul] && msg.put[soul]![item!]
1050
1074
  const id = utils.rel.is(current as GraphValue)
1051
1075
  if (id) {
1052
- wire.off(initialLex as never, map.get(callback)!)
1053
- wire.on(
1054
- {"#": id, ".": null} as never,
1055
- map.get(callback)!,
1056
- false,
1057
- opts,
1058
- )
1076
+ // It's a rel, need to switch listener to the related node -
1077
+ // unless resolveValue (triggered by the initial listener
1078
+ // firing) already beat this check to it.
1079
+ if (listenedSoul !== id) {
1080
+ wire.off({"#": listenedSoul} as never, map.get(callback)!)
1081
+ wire.on(
1082
+ {"#": id, ".": null} as never,
1083
+ map.get(callback)!,
1084
+ false,
1085
+ opts,
1086
+ )
1087
+ listenedSoul = id
1088
+ }
1059
1089
  if (_get) map.get(callback)!()
1060
1090
  } else if (_get) {
1061
1091
  // Not a rel, but _get was requested, so trigger callback.
package/ts/src/wire.ts CHANGED
@@ -1092,6 +1092,17 @@ const Wire = (opt: HolsterOptions): WireAPI => {
1092
1092
  }
1093
1093
 
1094
1094
  const send = (data: string): {err?: string} | void => {
1095
+ // Reject client-side, before ever attempting to send, rather than
1096
+ // silently succeeding locally while the receiving peer's own
1097
+ // validateMessage (wire.ts's server-side inbound check) drops it and
1098
+ // replies with an error nothing here is listening for - so a save
1099
+ // that's too big to ever sync would otherwise look like it worked.
1100
+ const maxMessageSize = options.maxMessageSize || 1024 * 1024
1101
+ if (data.length > maxMessageSize) {
1102
+ return {
1103
+ err: `Message too large (${data.length} > ${maxMessageSize} bytes). Reduce the size of the data being saved.`,
1104
+ }
1105
+ }
1095
1106
  if (messageQueue.length >= maxQueueLength) {
1096
1107
  return {
1097
1108
  err: `Message queue exceeded maximum length (${maxQueueLength}). Update query logic to request less data.`,