@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/build/holster.js +3 -3
- package/build/holster.js.map +3 -3
- package/dist/holster.js +3 -3
- package/dist/holster.js.map +3 -3
- package/package.json +1 -1
- package/src/holster.js +121 -109
- package/src/wire.js +13 -0
- package/ts/src/holster.ts +135 -105
- package/ts/src/wire.ts +11 -0
- package/utils/user-storage.js +8 -7
package/package.json
CHANGED
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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.`,
|