@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/build/holster.js +3 -3
- package/build/holster.js.map +4 -4
- package/dist/holster.js +3 -3
- package/dist/holster.js.map +4 -4
- package/package.json +2 -2
- package/src/holster.js +139 -98
- package/src/nested.js +89 -0
- package/src/wire.js +35 -12
- package/ts/src/holster.ts +172 -111
- package/ts/src/nested.ts +101 -0
- package/ts/src/schemas.ts +1 -0
- package/ts/src/wire.ts +32 -11
- package/utils/user-storage.js +6 -5
package/ts/src/holster.ts
CHANGED
|
@@ -7,6 +7,7 @@ import * as utils from "./utils.ts"
|
|
|
7
7
|
import Wire, {type WireAPI} from "./wire.ts"
|
|
8
8
|
import User, {type UserInterface} from "./user.ts"
|
|
9
9
|
import SEA from "./sea.ts"
|
|
10
|
+
import {attachNested} from "./nested.ts"
|
|
10
11
|
import type {
|
|
11
12
|
HolsterOptions,
|
|
12
13
|
ChainItem,
|
|
@@ -55,12 +56,15 @@ export interface HolsterAPI {
|
|
|
55
56
|
set?: boolean | ((data?: string | null) => void),
|
|
56
57
|
cb?: (data?: string | null) => void,
|
|
57
58
|
) => HolsterAPI | void
|
|
58
|
-
on:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
on: {
|
|
60
|
+
(
|
|
61
|
+
lex: LexWithDot,
|
|
62
|
+
cb: (data: unknown) => void,
|
|
63
|
+
_get?: boolean,
|
|
64
|
+
_opt?: WireOptions,
|
|
65
|
+
): void
|
|
66
|
+
(cb: (data: unknown) => void, _get?: boolean, _opt?: WireOptions): void
|
|
67
|
+
}
|
|
64
68
|
off: (cb?: (data: unknown) => void) => HolsterAPI | void
|
|
65
69
|
user: () => UserInterface & HolsterAPI
|
|
66
70
|
wire: WireAPI
|
|
@@ -78,6 +82,12 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
|
|
|
78
82
|
const user = User(options, wire as never)
|
|
79
83
|
const map = new Map<(data: unknown) => void, () => void>()
|
|
80
84
|
const allctx = new Map<string, ApiContext>()
|
|
85
|
+
// Tracks on(..., {nested: true}) subscriptions, keyed by the caller's
|
|
86
|
+
// own callback (the same reference off() will be called with) - see
|
|
87
|
+
// attachNested in nested.ts. The actual top-level/child listeners it
|
|
88
|
+
// creates use their own, separate contexts, so off() just needs to
|
|
89
|
+
// call the detach function this stores to tear the whole thing down.
|
|
90
|
+
const nested = new Map<(data: unknown) => void, () => void>()
|
|
81
91
|
// Serializes concurrent creation of a missing rel for the same
|
|
82
92
|
// soul+item, so only the first caller creates it and the rest reuse
|
|
83
93
|
// its result rather than each minting a competing soul. Map<soul,
|
|
@@ -122,6 +132,30 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
|
|
|
122
132
|
return `error ${error} cannot be converted to a graph`
|
|
123
133
|
}
|
|
124
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
|
+
|
|
125
159
|
const api = (initCtxid?: string): HolsterAPI => {
|
|
126
160
|
let ctxid = initCtxid
|
|
127
161
|
const get = (
|
|
@@ -147,23 +181,12 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
|
|
|
147
181
|
Object.keys(node).map(async key => {
|
|
148
182
|
const id = utils.rel.is(node[key] as GraphValue)
|
|
149
183
|
if (!id) return
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
user: ctx ? ctx.user : null,
|
|
157
|
-
})
|
|
158
|
-
api(_ctxid).next(null as never, res as never, _opt)
|
|
159
|
-
})
|
|
160
|
-
if (data !== null || retries >= 5) {
|
|
161
|
-
return data
|
|
162
|
-
}
|
|
163
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
164
|
-
return attemptRead(retries + 1)
|
|
165
|
-
}
|
|
166
|
-
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
|
|
167
190
|
}),
|
|
168
191
|
)
|
|
169
192
|
ack(msg.put[soul])
|
|
@@ -884,6 +907,23 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
|
|
|
884
907
|
|
|
885
908
|
const ctx = allctx.get(ctxid)
|
|
886
909
|
|
|
910
|
+
if (opts && opts.nested) {
|
|
911
|
+
// attachNested calls this once for the top level and again for
|
|
912
|
+
// every discovered child, each needing its own independent
|
|
913
|
+
// context - a single shared _ctxid would accumulate .next()
|
|
914
|
+
// calls onto the same path instead of giving sibling paths.
|
|
915
|
+
const factory = (): HolsterAPI => {
|
|
916
|
+
const _ctxid = utils.text.random()
|
|
917
|
+
allctx.set(_ctxid, {
|
|
918
|
+
chain: [{item: item, soul: soul}],
|
|
919
|
+
user: ctx ? ctx.user : null,
|
|
920
|
+
})
|
|
921
|
+
return api(_ctxid)
|
|
922
|
+
}
|
|
923
|
+
nested.set(callback, attachNested(factory, callback, _get, opts))
|
|
924
|
+
return
|
|
925
|
+
}
|
|
926
|
+
|
|
887
927
|
allctx.set(ctxid, {
|
|
888
928
|
chain: [{item: item, soul: soul}],
|
|
889
929
|
on: true,
|
|
@@ -891,10 +931,19 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
|
|
|
891
931
|
})
|
|
892
932
|
|
|
893
933
|
// Shared retry state for this on() subscription.
|
|
894
|
-
let retryCount = 0
|
|
895
934
|
const maxRetries = 5
|
|
896
935
|
const retryDelay = 1000
|
|
897
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
|
|
898
947
|
|
|
899
948
|
map.set(callback, () => {
|
|
900
949
|
// Bail out if off() has already cleaned up this context — the mapped
|
|
@@ -906,89 +955,89 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
|
|
|
906
955
|
// have an update and should not call callback twice.
|
|
907
956
|
clearTimeout(retryTimer!)
|
|
908
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
|
+
|
|
909
1036
|
wire.get(
|
|
910
1037
|
{"#": soul, ".": item!},
|
|
911
1038
|
msg => {
|
|
912
1039
|
const node = msg.put && msg.put[soul]
|
|
913
|
-
|
|
914
|
-
const id = utils.rel.is(current as GraphValue)
|
|
915
|
-
if (id) {
|
|
916
|
-
const attemptRead = async (retries = 0): Promise<void> => {
|
|
917
|
-
const data = await new Promise<unknown>(res => {
|
|
918
|
-
const _ctxid = utils.text.random()
|
|
919
|
-
allctx.set(_ctxid, {
|
|
920
|
-
chain: [{item: null, soul: id}],
|
|
921
|
-
user: ctx ? ctx.user : null,
|
|
922
|
-
})
|
|
923
|
-
api(_ctxid).next(
|
|
924
|
-
null as never,
|
|
925
|
-
res as never,
|
|
926
|
-
(retries === 0
|
|
927
|
-
? utils.obj.put(opts, "fast", true)
|
|
928
|
-
: opts) as never,
|
|
929
|
-
)
|
|
930
|
-
})
|
|
931
|
-
if (data !== null || retries >= 5) {
|
|
932
|
-
retryCount = 0
|
|
933
|
-
callback(data)
|
|
934
|
-
} else {
|
|
935
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
936
|
-
attemptRead(retries + 1)
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
attemptRead()
|
|
940
|
-
} else {
|
|
941
|
-
const nodeExists = !!node
|
|
942
|
-
if (nodeExists) {
|
|
943
|
-
retryCount = 0
|
|
944
|
-
callback(current !== undefined ? current : null)
|
|
945
|
-
} else if (retryCount < maxRetries) {
|
|
946
|
-
// No data yet — keep polling until we get a response or
|
|
947
|
-
// exhaust retries. on() with _get can return null on a cache
|
|
948
|
-
// miss and never fire again if the data doesn't change while
|
|
949
|
-
// the listener is attached, so we must poll for initial load.
|
|
950
|
-
const secureOpt = {
|
|
951
|
-
...opts,
|
|
952
|
-
secure: ctx
|
|
953
|
-
? (typeof ctx.user === "boolean"
|
|
954
|
-
? ctx.user
|
|
955
|
-
: !!ctx.user) || options.secure
|
|
956
|
-
: options.secure,
|
|
957
|
-
}
|
|
958
|
-
const retry = (): void => {
|
|
959
|
-
const delay = Math.min(
|
|
960
|
-
retryDelay * Math.pow(2, retryCount),
|
|
961
|
-
30000,
|
|
962
|
-
)
|
|
963
|
-
retryCount++
|
|
964
|
-
retryTimer = setTimeout(() => {
|
|
965
|
-
wire.get(
|
|
966
|
-
{"#": soul, ".": item!},
|
|
967
|
-
retryMsg => {
|
|
968
|
-
const retryNode = retryMsg.put && retryMsg.put[soul]
|
|
969
|
-
if (retryNode) {
|
|
970
|
-
retryCount = 0
|
|
971
|
-
retryTimer = null
|
|
972
|
-
const retryValue = retryNode[item!]
|
|
973
|
-
callback(
|
|
974
|
-
retryValue !== undefined ? retryValue : null,
|
|
975
|
-
)
|
|
976
|
-
} else if (retryCount < maxRetries) {
|
|
977
|
-
retry()
|
|
978
|
-
} else {
|
|
979
|
-
retryTimer = null
|
|
980
|
-
callback(null)
|
|
981
|
-
}
|
|
982
|
-
},
|
|
983
|
-
secureOpt,
|
|
984
|
-
)
|
|
985
|
-
}, delay)
|
|
986
|
-
}
|
|
987
|
-
retry()
|
|
988
|
-
} else {
|
|
989
|
-
callback(null)
|
|
990
|
-
}
|
|
991
|
-
}
|
|
1040
|
+
resolveValue(0, node, node && node[item!])
|
|
992
1041
|
},
|
|
993
1042
|
{
|
|
994
1043
|
...opts,
|
|
@@ -1022,13 +1071,19 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
|
|
|
1022
1071
|
const current = msg.put && msg.put[soul] && msg.put[soul]![item!]
|
|
1023
1072
|
const id = utils.rel.is(current as GraphValue)
|
|
1024
1073
|
if (id) {
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
+
}
|
|
1032
1087
|
if (_get) map.get(callback)!()
|
|
1033
1088
|
} else if (_get) {
|
|
1034
1089
|
// Not a rel, but _get was requested, so trigger callback.
|
|
@@ -1043,7 +1098,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
|
|
|
1043
1098
|
: options.secure,
|
|
1044
1099
|
},
|
|
1045
1100
|
)
|
|
1046
|
-
},
|
|
1101
|
+
} as HolsterAPI["on"],
|
|
1047
1102
|
|
|
1048
1103
|
off: function (cb?: (data: unknown) => void): HolsterAPI | void {
|
|
1049
1104
|
if (!ctxid) {
|
|
@@ -1052,6 +1107,12 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
|
|
|
1052
1107
|
return
|
|
1053
1108
|
}
|
|
1054
1109
|
|
|
1110
|
+
if (cb && nested.has(cb)) {
|
|
1111
|
+
nested.get(cb)!()
|
|
1112
|
+
nested.delete(cb)
|
|
1113
|
+
return
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1055
1116
|
const resolved = resolve({off: true}, cb)
|
|
1056
1117
|
if (!resolved) return
|
|
1057
1118
|
|
package/ts/src/nested.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type {HolsterAPI} from "./holster.ts"
|
|
2
|
+
import type {WireOptions} from "./schemas.ts"
|
|
3
|
+
|
|
4
|
+
// Backs on(cb, _get, {nested: true}) - see holster.ts. A plain on() only
|
|
5
|
+
// resolves the node it's given; it never re-checks a child whose own
|
|
6
|
+
// value is an object (which always has its own soul, since only scalars
|
|
7
|
+
// live directly on a node) once that child's content changes in place -
|
|
8
|
+
// nothing on the parent's own soul changes when that happens, so a
|
|
9
|
+
// listener on the parent alone never sees it. get()'s own read-resolution
|
|
10
|
+
// (holster.ts) already recursively resolves every such child into plain
|
|
11
|
+
// data for a one-time read - this doesn't repeat that; it adds a live
|
|
12
|
+
// subscription for each child that resolution already exposed, so a
|
|
13
|
+
// later change to it reaches the caller too.
|
|
14
|
+
//
|
|
15
|
+
// Pure and holster-internals-free: chain is a factory - () =>
|
|
16
|
+
// user.get([host, "shared"]).next("domains").next(code) - not a
|
|
17
|
+
// pre-built chained object, since a fresh, independent instance is
|
|
18
|
+
// needed per discovered child (reusing one chained object across
|
|
19
|
+
// multiple .next() calls would push them onto the same path instead of
|
|
20
|
+
// giving sibling paths).
|
|
21
|
+
export function attachNested(
|
|
22
|
+
chain: () => HolsterAPI,
|
|
23
|
+
cb: (data: unknown) => void,
|
|
24
|
+
_get?: boolean,
|
|
25
|
+
_opt?: WireOptions,
|
|
26
|
+
): () => void {
|
|
27
|
+
// Stripped before being used on any .on() call this function makes
|
|
28
|
+
// itself (top-level or child) - otherwise a caller-provided {nested:
|
|
29
|
+
// true} would re-trigger holster.ts's own nested check on re-entry,
|
|
30
|
+
// before the normal rel-resolution logic that actually finds the right
|
|
31
|
+
// soul ever runs.
|
|
32
|
+
const opt: WireOptions = {..._opt}
|
|
33
|
+
delete opt.nested
|
|
34
|
+
|
|
35
|
+
const childListeners = new Map<
|
|
36
|
+
string,
|
|
37
|
+
{api: HolsterAPI; handler: (data: unknown) => void}
|
|
38
|
+
>()
|
|
39
|
+
let latest: Record<string, unknown> | null = null
|
|
40
|
+
|
|
41
|
+
const notify = () => cb(latest === null ? null : {...latest})
|
|
42
|
+
|
|
43
|
+
const topLevelHandler = (data: unknown) => {
|
|
44
|
+
if (data === null || typeof data !== "object") {
|
|
45
|
+
latest = data as Record<string, unknown> | null
|
|
46
|
+
notify()
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!latest || typeof latest !== "object") latest = {}
|
|
51
|
+
const node = data as Record<string, unknown>
|
|
52
|
+
for (const [key, value] of Object.entries(node)) {
|
|
53
|
+
if (key === "_") continue
|
|
54
|
+
latest[key] = value
|
|
55
|
+
|
|
56
|
+
if (
|
|
57
|
+
value !== null &&
|
|
58
|
+
typeof value === "object" &&
|
|
59
|
+
!childListeners.has(key)
|
|
60
|
+
) {
|
|
61
|
+
const childApi = chain().next(key)
|
|
62
|
+
const handler = (childData: unknown) => {
|
|
63
|
+
if (childData === null) {
|
|
64
|
+
delete latest![key]
|
|
65
|
+
childListeners.delete(key)
|
|
66
|
+
} else {
|
|
67
|
+
latest![key] = childData
|
|
68
|
+
}
|
|
69
|
+
notify()
|
|
70
|
+
}
|
|
71
|
+
childListeners.set(key, {api: childApi, handler})
|
|
72
|
+
// false, not true - the current value is already in data above,
|
|
73
|
+
// so this only needs to watch for future changes, not trigger a
|
|
74
|
+
// redundant immediate re-read.
|
|
75
|
+
childApi.on(handler, false, opt)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const key of [...childListeners.keys()]) {
|
|
80
|
+
if (!(key in node)) {
|
|
81
|
+
const entry = childListeners.get(key)!
|
|
82
|
+
entry.api.off(entry.handler)
|
|
83
|
+
childListeners.delete(key)
|
|
84
|
+
delete latest[key]
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
notify()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const topLevelApi = chain()
|
|
92
|
+
topLevelApi.on(topLevelHandler, _get, opt)
|
|
93
|
+
|
|
94
|
+
return () => {
|
|
95
|
+
topLevelApi.off(topLevelHandler)
|
|
96
|
+
for (const {api: childApi, handler} of childListeners.values()) {
|
|
97
|
+
childApi.off(handler)
|
|
98
|
+
}
|
|
99
|
+
childListeners.clear()
|
|
100
|
+
}
|
|
101
|
+
}
|
package/ts/src/schemas.ts
CHANGED
|
@@ -185,6 +185,7 @@ export const WireOptionsSchema = z
|
|
|
185
185
|
secure: z.boolean().optional(),
|
|
186
186
|
fast: z.boolean().optional(),
|
|
187
187
|
put: z.boolean().optional(),
|
|
188
|
+
nested: z.boolean().optional(),
|
|
188
189
|
})
|
|
189
190
|
.optional()
|
|
190
191
|
export type WireOptions = z.infer<typeof WireOptionsSchema>
|
package/ts/src/wire.ts
CHANGED
|
@@ -509,14 +509,18 @@ const Wire = (opt: HolsterOptions): WireAPI => {
|
|
|
509
509
|
continue
|
|
510
510
|
}
|
|
511
511
|
const limit = userLimits.get(pub) ?? defaultLimit
|
|
512
|
-
|
|
513
|
-
toStore[soul] = node
|
|
512
|
+
const current = userStorage.get(pub) ?? 0
|
|
514
513
|
const oldBytes = oldGraphBytes[soul] ?? 0
|
|
515
514
|
const newBytes = graph[soul] ? JSON.stringify(graph[soul]).length : 0
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
515
|
+
const updated = Math.max(0, current - oldBytes + newBytes)
|
|
516
|
+
// A write that doesn't grow this user's total usage (e.g. a
|
|
517
|
+
// delete) is always let through, even past the limit - otherwise
|
|
518
|
+
// a user already over quota could never delete anything to get
|
|
519
|
+
// back under it. Only a write that both grows usage and pushes
|
|
520
|
+
// the total past the limit is rejected.
|
|
521
|
+
if (updated > current && updated > limit) continue
|
|
522
|
+
toStore[soul] = node
|
|
523
|
+
userStorage.set(pub, updated)
|
|
520
524
|
saveUserStorage()
|
|
521
525
|
}
|
|
522
526
|
if (Object.keys(toStore).length > 0) {
|
|
@@ -730,15 +734,21 @@ const Wire = (opt: HolsterOptions): WireAPI => {
|
|
|
730
734
|
if (!pub) continue
|
|
731
735
|
const limit = userLimits.get(pub) ?? defaultLimit
|
|
732
736
|
const current = userStorage.get(pub) ?? 0
|
|
733
|
-
if (current >= limit) {
|
|
734
|
-
if (cb) cb("storage limit exceeded")
|
|
735
|
-
return
|
|
736
|
-
}
|
|
737
737
|
const oldBytes = oldGraphBytes[soul] ?? 0
|
|
738
738
|
const newBytes = graph[soul]
|
|
739
739
|
? JSON.stringify(graph[soul]).length
|
|
740
740
|
: 0
|
|
741
|
-
|
|
741
|
+
const updated = Math.max(0, current - oldBytes + newBytes)
|
|
742
|
+
// A write that doesn't grow this user's total usage (e.g. a
|
|
743
|
+
// delete) is always let through, even past the limit -
|
|
744
|
+
// otherwise a user already over quota could never delete
|
|
745
|
+
// anything to get back under it. Only a write that both grows
|
|
746
|
+
// usage and pushes the total past the limit is rejected.
|
|
747
|
+
if (updated > current && updated > limit) {
|
|
748
|
+
if (cb) cb("storage limit exceeded")
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
userStorage.set(pub, updated)
|
|
742
752
|
saveUserStorage()
|
|
743
753
|
}
|
|
744
754
|
}
|
|
@@ -1082,6 +1092,17 @@ const Wire = (opt: HolsterOptions): WireAPI => {
|
|
|
1082
1092
|
}
|
|
1083
1093
|
|
|
1084
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
|
+
}
|
|
1085
1106
|
if (messageQueue.length >= maxQueueLength) {
|
|
1086
1107
|
return {
|
|
1087
1108
|
err: `Message queue exceeded maximum length (${maxQueueLength}). Update query logic to request less data.`,
|
package/utils/user-storage.js
CHANGED
|
@@ -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
|
|
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
|
}
|