@mblaney/holster 2.2.0 → 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@mblaney/holster",
3
- "version": "2.2.0",
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",
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])
@@ -713,10 +721,19 @@ const Holster = opt => {
713
721
  // Map the user's callback because it can also be passed to off,
714
722
  // so need a reference to it to compare them.
715
723
  // Shared retry state for this on() subscription.
716
- let retryCount = 0
717
724
  const maxRetries = 5
718
725
  const retryDelay = 1000 // Start with 1 second
719
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
720
737
 
721
738
  map.set(cb, () => {
722
739
  // Bail out if off() has already cleaned up this context — the mapped
@@ -728,84 +745,75 @@ const Holster = opt => {
728
745
  // have an update and should not call cb twice.
729
746
  clearTimeout(retryTimer)
730
747
  retryTimer = null
731
- // 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.
732
812
  wire.get(
733
813
  {"#": soul, ".": item},
734
814
  msg => {
735
815
  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
- }
816
+ resolveValue(0, node, node && node[item])
809
817
  },
810
818
  {..._opt, secure: !!(ctx.user || opt.secure)},
811
819
  )
@@ -840,11 +848,14 @@ const Holster = opt => {
840
848
  const current = msg.put && msg.put[soul] && msg.put[soul][item]
841
849
  const id = utils.rel.is(current)
842
850
  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)
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
+ }
848
859
  if (_get) map.get(cb)()
849
860
  } else if (_get) {
850
861
  // Not a rel, but _get was requested, so trigger callback.
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])
@@ -918,10 +931,19 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
918
931
  })
919
932
 
920
933
  // Shared retry state for this on() subscription.
921
- let retryCount = 0
922
934
  const maxRetries = 5
923
935
  const retryDelay = 1000
924
936
  let retryTimer: ReturnType<typeof setTimeout> | null = null
937
+ // Which soul the persistent wire listener is currently attached to
938
+ // - starts on the parent (soul/item), and moves to a rel's target
939
+ // soul once item turns out to be one, since further updates land
940
+ // on the target's own soul, not the parent's. Tracked (rather than
941
+ // switching unconditionally) so the two places that can discover a
942
+ // rel - the initial check below and a later resolveValue retry -
943
+ // don't both re-register the same listener. Scoped to this one
944
+ // on() call's closure, so concurrent subscriptions (even on the
945
+ // same key) each track their own independently.
946
+ let listenedSoul = soul
925
947
 
926
948
  map.set(callback, () => {
927
949
  // Bail out if off() has already cleaned up this context — the mapped
@@ -933,89 +955,89 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
933
955
  // have an update and should not call callback twice.
934
956
  clearTimeout(retryTimer!)
935
957
  retryTimer = null
958
+
959
+ // Resolves {soul, item} to a genuine value, following a rel to its
960
+ // target if the property turns out to be one - checked fresh on
961
+ // every attempt, not just the first, since a property can still
962
+ // be a plain undefined/null on an early check and only become a
963
+ // rel once a later retry catches up (e.g. a nested rel-creating
964
+ // put both adds "item" as a rel on this node AND populates its
965
+ // target, and either half can still be mid-flight when the
966
+ // listener first fires). wire.get() can't reliably tell
967
+ // "genuinely null" apart from "hasn't landed yet" either - its
968
+ // own internal timeout fallback explicitly answers null when
969
+ // nothing comes back in time - so a null/missing value is
970
+ // retried a few times before being trusted as final, the same
971
+ // way the rel branch below already retries on a null target
972
+ // read.
973
+ const resolveValue = (
974
+ retries: number,
975
+ node: Record<string, GraphValue> | null | undefined,
976
+ value: unknown,
977
+ ): void => {
978
+ const id = utils.rel.is(value as GraphValue)
979
+ if (id) {
980
+ if (listenedSoul !== id) {
981
+ wire.off({"#": listenedSoul} as never, map.get(callback)!)
982
+ wire.on(
983
+ {"#": id, ".": null} as never,
984
+ map.get(callback)!,
985
+ false,
986
+ opts,
987
+ )
988
+ listenedSoul = id
989
+ }
990
+ readRelTarget(id, ctx ? ctx.user : null, opts).then(data => {
991
+ retryTimer = null
992
+ callback(data)
993
+ })
994
+ return
995
+ }
996
+
997
+ if (value !== undefined && value !== null) {
998
+ retryTimer = null
999
+ callback(value)
1000
+ return
1001
+ }
1002
+ if (retries >= maxRetries) {
1003
+ retryTimer = null
1004
+ callback(value !== undefined ? value : null)
1005
+ return
1006
+ }
1007
+ // Fast once the parent node already exists (a value is likely
1008
+ // imminent); slow/exponential when it doesn't, since there's
1009
+ // no signal anything is coming soon and fast polling would
1010
+ // just waste requests.
1011
+ const delay = node
1012
+ ? 50
1013
+ : Math.min(retryDelay * Math.pow(2, retries), 30000)
1014
+ retryTimer = setTimeout(() => {
1015
+ wire.get(
1016
+ {"#": soul, ".": item!},
1017
+ retryMsg => {
1018
+ const retryNode = retryMsg.put && retryMsg.put[soul]
1019
+ resolveValue(
1020
+ retries + 1,
1021
+ retryNode,
1022
+ retryNode && retryNode[item!],
1023
+ )
1024
+ },
1025
+ {
1026
+ ...opts,
1027
+ secure: ctx
1028
+ ? (typeof ctx.user === "boolean" ? ctx.user : !!ctx.user) ||
1029
+ options.secure
1030
+ : options.secure,
1031
+ },
1032
+ )
1033
+ }, delay)
1034
+ }
1035
+
936
1036
  wire.get(
937
1037
  {"#": soul, ".": item!},
938
1038
  msg => {
939
1039
  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
- }
1040
+ resolveValue(0, node, node && node[item!])
1019
1041
  },
1020
1042
  {
1021
1043
  ...opts,
@@ -1049,13 +1071,19 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
1049
1071
  const current = msg.put && msg.put[soul] && msg.put[soul]![item!]
1050
1072
  const id = utils.rel.is(current as GraphValue)
1051
1073
  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
- )
1074
+ // It's a rel, need to switch listener to the related node -
1075
+ // unless resolveValue (triggered by the initial listener
1076
+ // firing) already beat this check to it.
1077
+ if (listenedSoul !== id) {
1078
+ wire.off({"#": listenedSoul} as never, map.get(callback)!)
1079
+ wire.on(
1080
+ {"#": id, ".": null} as never,
1081
+ map.get(callback)!,
1082
+ false,
1083
+ opts,
1084
+ )
1085
+ listenedSoul = id
1086
+ }
1059
1087
  if (_get) map.get(callback)!()
1060
1088
  } else if (_get) {
1061
1089
  // 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.`,
@@ -39,8 +39,6 @@ const formatBytes = bytes => {
39
39
  return `${(bytes / 1073741824).toFixed(1)} GB`
40
40
  }
41
41
 
42
- const DEFAULT_STORAGE_LIMIT = 1 // MB
43
-
44
42
  const userStorage = readJSON(".user_storage.json")
45
43
  const userLimit = readJSON(".user_limit.json")
46
44
 
@@ -77,10 +75,13 @@ if (page.length > 0) {
77
75
 
78
76
  const data = userLimit.data || {}
79
77
  for (const {pub, total} of page) {
80
- const pubLimit =
81
- typeof data[pub] === "number" ? data[pub] : DEFAULT_STORAGE_LIMIT
78
+ const pubLimit = data[pub]
82
79
  const limitStr =
83
- pubLimit === 0 ? "0 B (blocked)" : formatBytes(pubLimit * 1048576)
80
+ typeof pubLimit !== "number"
81
+ ? "no override (see server logs for default)"
82
+ : pubLimit === 0
83
+ ? "0 B (blocked)"
84
+ : formatBytes(pubLimit * 1048576)
84
85
  console.log(` ${pub}`)
85
86
  console.log(` used: ${formatBytes(total)} / limit: ${limitStr}`)
86
87
  }