@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/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
- lex: LexWithDot | ((data: unknown) => void),
60
- cb?: ((data: unknown) => void) | boolean,
61
- _get?: boolean,
62
- _opt?: WireOptions,
63
- ) => void
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 attemptRead = async (retries = 0): Promise<unknown> => {
151
- const data = await new Promise<unknown>(res => {
152
- const _ctxid = utils.text.random()
153
- const ctx = allctx.get(ctxid!)
154
- allctx.set(_ctxid, {
155
- chain: [{item: null, soul: id}],
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
- const current = node && node[item!]
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
- wire.off(initialLex as never, map.get(callback)!)
1026
- wire.on(
1027
- {"#": id, ".": null} as never,
1028
- map.get(callback)!,
1029
- false,
1030
- opts,
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
 
@@ -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
- if ((userStorage.get(pub) ?? 0) >= limit) continue
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
- userStorage.set(
517
- pub,
518
- Math.max(0, (userStorage.get(pub) ?? 0) - oldBytes + newBytes),
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
- userStorage.set(pub, Math.max(0, current - oldBytes + newBytes))
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.`,
@@ -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
  }