@mblaney/holster 2.1.4 → 2.2.0

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,11 +1,11 @@
1
1
  {
2
2
  "name": "@mblaney/holster",
3
- "version": "2.1.4",
3
+ "version": "2.2.0",
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",
7
7
  "scripts": {
8
- "test": "node --test",
8
+ "test": "node --test test/*.test.js test/system/*.test.js",
9
9
  "test:ts": "node --test --import tsx/esm ts/test/*.test.ts ts/test/system/*.test.ts",
10
10
  "build": "esbuild src/holster.js --bundle --format=esm --sourcemap \"--external:node:*\" \"--external:ws\" --minify --outdir=build",
11
11
  "build:ts": "esbuild ts/src/holster.ts --bundle --format=esm --sourcemap \"--external:node:*\" \"--external:ws\" --minify --outdir=dist",
package/src/holster.js CHANGED
@@ -2,6 +2,7 @@ import * as utils from "./utils.js"
2
2
  import Wire from "./wire.js"
3
3
  import User from "./user.js"
4
4
  import SEA from "./sea.js"
5
+ import {attachNested} from "./nested.js"
5
6
 
6
7
  const Holster = opt => {
7
8
  if (typeof opt === "string") opt = {peers: [opt]}
@@ -14,6 +15,12 @@ const Holster = opt => {
14
15
  const map = new Map()
15
16
  // Allow concurrent calls to the api by storing each context.
16
17
  const allctx = new Map()
18
+ // Tracks on(..., {nested: true}) subscriptions, keyed by the caller's
19
+ // own callback (the same reference off() will be called with) - see
20
+ // attachNested in nested.js. The actual top-level/child listeners it
21
+ // creates use their own, separate contexts, so off() just needs to
22
+ // call the detach function this stores to tear the whole thing down.
23
+ const nested = new Map()
17
24
  // Serializes concurrent creation of a missing rel for the same
18
25
  // soul+item, so only the first caller creates it and the rest reuse
19
26
  // its result rather than each minting a competing soul. Map<soul,
@@ -680,6 +687,23 @@ const Holster = opt => {
680
687
  // Get the context to check if it has user info
681
688
  const ctx = allctx.get(ctxid)
682
689
 
690
+ if (_opt && _opt.nested) {
691
+ // attachNested calls this once for the top level and again for
692
+ // every discovered child, each needing its own independent
693
+ // context - a single shared _ctxid would accumulate .next()
694
+ // calls onto the same path instead of giving sibling paths.
695
+ const factory = () => {
696
+ const _ctxid = utils.text.random()
697
+ allctx.set(_ctxid, {
698
+ chain: [{item: item, soul: soul}],
699
+ user: ctx ? ctx.user : null,
700
+ })
701
+ return api(_ctxid)
702
+ }
703
+ nested.set(cb, attachNested(factory, cb, _get, _opt))
704
+ return
705
+ }
706
+
683
707
  // Flag that this context is set from on and shouldn't be removed.
684
708
  allctx.set(ctxid, {
685
709
  chain: [{item: item, soul: soul}],
@@ -837,6 +861,12 @@ const Holster = opt => {
837
861
  return
838
862
  }
839
863
 
864
+ if (nested.has(cb)) {
865
+ nested.get(cb)()
866
+ nested.delete(cb)
867
+ return
868
+ }
869
+
840
870
  // Resolve the current context before removing event listener.
841
871
  const {item, soul} = resolve({off: true}, cb)
842
872
  if (!soul) return
package/src/nested.js ADDED
@@ -0,0 +1,89 @@
1
+ // Backs on(cb, _get, {nested: true}) - see holster.js. A plain on() only
2
+ // resolves the node it's given; it never re-checks a child whose own
3
+ // value is an object (which always has its own soul, since only scalars
4
+ // live directly on a node) once that child's content changes in place -
5
+ // nothing on the parent's own soul changes when that happens, so a
6
+ // listener on the parent alone never sees it. get()'s own read-resolution
7
+ // (holster.js) already recursively resolves every such child into plain
8
+ // data for a one-time read - this doesn't repeat that; it adds a live
9
+ // subscription for each child that resolution already exposed, so a
10
+ // later change to it reaches the caller too.
11
+ //
12
+ // Pure and holster-internals-free: chain is a factory - () =>
13
+ // user.get([host, "shared"]).next("domains").next(code) - not a
14
+ // pre-built chained object, since a fresh, independent instance is
15
+ // needed per discovered child (reusing one chained object across
16
+ // multiple .next() calls would push them onto the same path instead of
17
+ // giving sibling paths).
18
+ export function attachNested(chain, cb, _get, _opt) {
19
+ // Stripped before being used on any .on() call this function makes
20
+ // itself (top-level or child) - otherwise a caller-provided {nested:
21
+ // true} would re-trigger holster.js's own nested check on re-entry,
22
+ // before the normal rel-resolution logic that actually finds the right
23
+ // soul ever runs.
24
+ const opt = {..._opt}
25
+ delete opt.nested
26
+
27
+ const childListeners = new Map() // key -> {api, handler}
28
+ let latest = null
29
+
30
+ const notify = () => cb(latest === null ? null : {...latest})
31
+
32
+ const topLevelHandler = data => {
33
+ if (data === null || typeof data !== "object") {
34
+ latest = data
35
+ notify()
36
+ return
37
+ }
38
+
39
+ if (!latest || typeof latest !== "object") latest = {}
40
+ for (const [key, value] of Object.entries(data)) {
41
+ if (key === "_") continue
42
+ latest[key] = value
43
+
44
+ if (
45
+ value !== null &&
46
+ typeof value === "object" &&
47
+ !childListeners.has(key)
48
+ ) {
49
+ const childApi = chain().next(key)
50
+ const handler = childData => {
51
+ if (childData === null) {
52
+ delete latest[key]
53
+ childListeners.delete(key)
54
+ } else {
55
+ latest[key] = childData
56
+ }
57
+ notify()
58
+ }
59
+ childListeners.set(key, {api: childApi, handler})
60
+ // false, not true - the current value is already in data above,
61
+ // so this only needs to watch for future changes, not trigger a
62
+ // redundant immediate re-read.
63
+ childApi.on(handler, false, opt)
64
+ }
65
+ }
66
+
67
+ for (const key of [...childListeners.keys()]) {
68
+ if (!(key in data)) {
69
+ const {api: childApi, handler} = childListeners.get(key)
70
+ childApi.off(handler)
71
+ childListeners.delete(key)
72
+ delete latest[key]
73
+ }
74
+ }
75
+
76
+ notify()
77
+ }
78
+
79
+ const topLevelApi = chain()
80
+ topLevelApi.on(topLevelHandler, _get, opt)
81
+
82
+ return () => {
83
+ topLevelApi.off(topLevelHandler)
84
+ for (const {api: childApi, handler} of childListeners.values()) {
85
+ childApi.off(handler)
86
+ }
87
+ childListeners.clear()
88
+ }
89
+ }
package/src/wire.js CHANGED
@@ -452,14 +452,18 @@ const Wire = opt => {
452
452
  continue
453
453
  }
454
454
  const limit = userLimits.get(pub) ?? defaultLimit
455
- if ((userStorage.get(pub) ?? 0) >= limit) continue
456
- toStore[soul] = node
455
+ const current = userStorage.get(pub) ?? 0
457
456
  const oldBytes = oldGraphBytes[soul] ?? 0
458
457
  const newBytes = graph[soul] ? JSON.stringify(graph[soul]).length : 0
459
- userStorage.set(
460
- pub,
461
- Math.max(0, (userStorage.get(pub) ?? 0) - oldBytes + newBytes),
462
- )
458
+ const updated = Math.max(0, current - oldBytes + newBytes)
459
+ // A write that doesn't grow this user's total usage (e.g. a
460
+ // delete) is always let through, even past the limit - otherwise
461
+ // a user already over quota could never delete anything to get
462
+ // back under it. Only a write that both grows usage and pushes
463
+ // the total past the limit is rejected.
464
+ if (updated > current && updated > limit) continue
465
+ toStore[soul] = node
466
+ userStorage.set(pub, updated)
463
467
  saveUserStorage()
464
468
  }
465
469
  if (Object.keys(toStore).length > 0) {
@@ -683,16 +687,22 @@ const Wire = opt => {
683
687
  if (!pub) continue
684
688
  const limit = userLimits.get(pub) ?? defaultLimit
685
689
  const current = userStorage.get(pub) ?? 0
686
- if (current >= limit) {
687
- if (cb) cb("storage limit exceeded")
688
- return
689
- }
690
-
691
690
  const oldBytes = oldGraphBytes[soul] ?? 0
692
691
  const newBytes = graph[soul]
693
692
  ? JSON.stringify(graph[soul]).length
694
693
  : 0
695
- userStorage.set(pub, Math.max(0, current - oldBytes + newBytes))
694
+ const updated = Math.max(0, current - oldBytes + newBytes)
695
+ // A write that doesn't grow this user's total usage (e.g. a
696
+ // delete) is always let through, even past the limit - otherwise
697
+ // a user already over quota could never delete anything to get
698
+ // back under it. Only a write that both grows usage and pushes
699
+ // the total past the limit is rejected.
700
+ if (updated > current && updated > limit) {
701
+ if (cb) cb("storage limit exceeded")
702
+ return
703
+ }
704
+
705
+ userStorage.set(pub, updated)
696
706
  saveUserStorage()
697
707
  }
698
708
  }
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,
@@ -884,6 +894,23 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
884
894
 
885
895
  const ctx = allctx.get(ctxid)
886
896
 
897
+ if (opts && opts.nested) {
898
+ // attachNested calls this once for the top level and again for
899
+ // every discovered child, each needing its own independent
900
+ // context - a single shared _ctxid would accumulate .next()
901
+ // calls onto the same path instead of giving sibling paths.
902
+ const factory = (): HolsterAPI => {
903
+ const _ctxid = utils.text.random()
904
+ allctx.set(_ctxid, {
905
+ chain: [{item: item, soul: soul}],
906
+ user: ctx ? ctx.user : null,
907
+ })
908
+ return api(_ctxid)
909
+ }
910
+ nested.set(callback, attachNested(factory, callback, _get, opts))
911
+ return
912
+ }
913
+
887
914
  allctx.set(ctxid, {
888
915
  chain: [{item: item, soul: soul}],
889
916
  on: true,
@@ -1043,7 +1070,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
1043
1070
  : options.secure,
1044
1071
  },
1045
1072
  )
1046
- },
1073
+ } as HolsterAPI["on"],
1047
1074
 
1048
1075
  off: function (cb?: (data: unknown) => void): HolsterAPI | void {
1049
1076
  if (!ctxid) {
@@ -1052,6 +1079,12 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
1052
1079
  return
1053
1080
  }
1054
1081
 
1082
+ if (cb && nested.has(cb)) {
1083
+ nested.get(cb)!()
1084
+ nested.delete(cb)
1085
+ return
1086
+ }
1087
+
1055
1088
  const resolved = resolve({off: true}, cb)
1056
1089
  if (!resolved) return
1057
1090
 
@@ -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
  }