@mblaney/holster 2.2.2 → 2.3.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@mblaney/holster",
3
- "version": "2.2.2",
3
+ "version": "2.3.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",
package/src/holster.js CHANGED
@@ -84,6 +84,24 @@ const Holster = opt => {
84
84
  return readRelTarget(id, ctxUser, _opt, retries + 1)
85
85
  }
86
86
 
87
+ // Wraps a not-yet-terminal chain object (api(ctxid), returned wherever no
88
+ // callback was given to resolve it yet) with a .then, so awaiting it
89
+ // resolves the same way a callback given right here would - mirrors
90
+ // readRelTarget's own api(ctxid).next(null, cb) idiom, which resolves the
91
+ // current chain without pushing a new key onto it. There's no separate
92
+ // error channel on this path (get/next's own callback only ever receives
93
+ // a value, never an error), so reject is simply never called - a
94
+ // rejection-less promise is still a valid one. Deliberately only applied
95
+ // at these specific "chain not yet resolved" return points, not to
96
+ // api()'s own bare object (see holster.user(), which Object.assigns that
97
+ // onto the long-lived user object) - that object has no chain to resolve
98
+ // at all, and giving user itself a .then would make a bare `await user`
99
+ // (or passing it to Promise.all, etc) silently try to resolve one.
100
+ const awaitable = chainApi => ({
101
+ ...chainApi,
102
+ then: resolve => chainApi.next(null, resolve),
103
+ })
104
+
87
105
  const api = ctxid => {
88
106
  const get = (lex, soul, ack, _opt) => {
89
107
  wire.get(
@@ -412,7 +430,7 @@ const Holster = opt => {
412
430
  // to be objects.
413
431
  ctxid = utils.text.random()
414
432
  allctx.set(ctxid, {chain: [{item: String(key), soul: "root"}], cb: cb})
415
- if (!cb) return api(ctxid)
433
+ if (!cb) return awaitable(api(ctxid))
416
434
 
417
435
  const _done = done(ctxid)
418
436
  // When there's a callback need to resolve the context first.
@@ -467,7 +485,7 @@ const Holster = opt => {
467
485
  // Push the key to the context as it needs a soul lookup.
468
486
  // (null is used to call the api with updated context)
469
487
  if (key !== null) ctx.chain.push({item: String(key), soul: null})
470
- if (!ctx.cb) return api(ctxid)
488
+ if (!ctx.cb) return awaitable(api(ctxid))
471
489
 
472
490
  // When there's a callback need to resolve the context first.
473
491
  const {soul} = resolve({get: lex, _opt: _opt}, _ack)
@@ -478,6 +496,25 @@ const Holster = opt => {
478
496
  cb = set
479
497
  set = false
480
498
  }
499
+ // Unlike get/next (see awaitable above), put's own callback always
500
+ // receives an error channel - an error string, or undefined on
501
+ // success - so a promise here can reject, not just resolve.
502
+ if (!cb) {
503
+ const promise = new Promise((resolve, reject) => {
504
+ api(ctxid).put(data, set, err =>
505
+ err ? reject(new Error(err)) : resolve(),
506
+ )
507
+ })
508
+ // A caller using the old fire-and-forget idiom (put(data) with
509
+ // no callback, result never touched) shouldn't crash the
510
+ // process on an unhandled rejection if something genuinely
511
+ // fails now that this returns a promise - doesn't suppress the
512
+ // rejection for a caller that does await/catch it, since a
513
+ // promise can have more than one handler attached (same
514
+ // pattern as createRel's own promise above).
515
+ promise.catch(() => {})
516
+ return promise
517
+ }
481
518
  const ack = ctxid => {
482
519
  return data => {
483
520
  if (cb) {
@@ -619,13 +656,14 @@ const Holster = opt => {
619
656
 
620
657
  const _ctxid = utils.text.random()
621
658
  const chain = [{item: item, soul: soul}]
622
- // Pass on the previous ctx's callback and user flag here.
623
- allctx.set(_ctxid, {
624
- chain: chain,
625
- user: ctx.user,
626
- cb: ctx.cb,
627
- })
628
- api(_ctxid).put(data)
659
+ allctx.set(_ctxid, {chain: chain, user: ctx.user})
660
+ // Pass on the previous ctx's own callback (if any)
661
+ // positionally rather than pre-registering it via
662
+ // allctx - otherwise it's invisible to put's own "no cb
663
+ // given" check above, which would then wrap this retry
664
+ // in a brand new promise competing with (and never
665
+ // actually calling) the real one.
666
+ api(_ctxid).put(data, ctx.cb)
629
667
  })
630
668
  return
631
669
  }
@@ -952,7 +990,7 @@ const Holster = opt => {
952
990
  ctxid = utils.text.random()
953
991
  const chain = [{item: String(key), soul: "~" + pub}]
954
992
  allctx.set(ctxid, {chain: chain, user: user.is, cb: cb})
955
- if (!cb) return api(ctxid)
993
+ if (!cb) return awaitable(api(ctxid))
956
994
 
957
995
  // When there's a callback need to resolve the context first.
958
996
  const _done = done(ctxid)
package/src/wire.js CHANGED
@@ -497,7 +497,15 @@ const Wire = opt => {
497
497
  })
498
498
 
499
499
  if (ack) {
500
- if (!isNode) {
500
+ // Put requests only need a local answer - skip the wire round-trip
501
+ // (matches the same check below, once store.get is reached instead
502
+ // of this fast graph path). Without this, check()'s own bare
503
+ // full-node lookup - always {put: true} - re-sends on the wire every
504
+ // time it's called once the soul's been through store.get once (see
505
+ // hasNode above), and since its own reply re-enters put() and calls
506
+ // check() again, this becomes a self-sustaining loop: a get every
507
+ // ~recentCheckTTL, forever, per soul, for every soul ever fetched.
508
+ if (!isNode && !_opt.put) {
501
509
  // Client: send request on the wire to check for updates,
502
510
  // since it may have missed puts while disconnected.
503
511
  // Skip if the same lex was checked recently to avoid flooding.
package/ts/src/holster.ts CHANGED
@@ -28,34 +28,54 @@ export interface HolsterAPI {
28
28
  get: {
29
29
  (
30
30
  key: string | null,
31
- lex?: LexWithDot,
32
- cb?: (data: unknown) => void,
31
+ lex: LexWithDot,
32
+ cb: (data: unknown) => void,
33
33
  _opt?: WireOptions,
34
34
  ): HolsterAPI
35
35
  (
36
36
  key: string | null,
37
- cb?: (data: unknown) => void,
37
+ cb: (data: unknown) => void,
38
38
  _opt?: WireOptions,
39
39
  ): HolsterAPI
40
+ (
41
+ key: string | null,
42
+ lex?: LexWithDot,
43
+ _opt?: WireOptions,
44
+ ): AwaitableHolsterAPI
40
45
  }
41
46
  next: {
42
47
  (
43
48
  key: string | null,
44
- lex?: LexWithDot,
45
- cb?: (data: unknown) => void,
49
+ lex: LexWithDot,
50
+ cb: (data: unknown) => void,
46
51
  _opt?: WireOptions,
47
52
  ): HolsterAPI
48
53
  (
49
54
  key: string | null,
50
- cb?: (data: unknown) => void,
55
+ cb: (data: unknown) => void,
51
56
  _opt?: WireOptions,
52
57
  ): HolsterAPI
58
+ (
59
+ key: string | null,
60
+ lex?: LexWithDot,
61
+ _opt?: WireOptions,
62
+ ): AwaitableHolsterAPI
63
+ }
64
+ put: {
65
+ (
66
+ data: GraphValue | Record<string, GraphValue>,
67
+ set: boolean,
68
+ cb: (data?: string | null) => void,
69
+ ): void
70
+ (
71
+ data: GraphValue | Record<string, GraphValue>,
72
+ cb: (data?: string | null) => void,
73
+ ): void
74
+ (
75
+ data: GraphValue | Record<string, GraphValue>,
76
+ set?: boolean,
77
+ ): Promise<void>
53
78
  }
54
- put: (
55
- data: GraphValue | Record<string, GraphValue>,
56
- set?: boolean | ((data?: string | null) => void),
57
- cb?: (data?: string | null) => void,
58
- ) => HolsterAPI | void
59
79
  on: {
60
80
  (
61
81
  lex: LexWithDot,
@@ -71,6 +91,23 @@ export interface HolsterAPI {
71
91
  SEA: typeof SEA
72
92
  }
73
93
 
94
+ // Deliberately not the built-in PromiseLike<T> - that also requires a
95
+ // return value and an onrejected param awaitable's own then() never uses
96
+ // (there's no separate error channel on this path, see awaitable's own
97
+ // comment). TS's Awaited<T> only needs a callable then(onfulfilled, ...)
98
+ // to recognize something as thenable - this minimal shape is enough for
99
+ // `await` to work and to correctly infer the resolved type as unknown.
100
+ interface Thenable {
101
+ then: (resolve: (data: unknown) => void) => void
102
+ }
103
+
104
+ // A chain object (api(ctxid), returned wherever no callback was given to
105
+ // resolve it yet) with a .then added, so `await`-ing it resolves the same
106
+ // way a callback given right here would (see awaitable in holster.ts).
107
+ // Only the "no callback given" get/next overloads above return this -
108
+ // the "has a real callback" ones genuinely don't have a .then.
109
+ export type AwaitableHolsterAPI = HolsterAPI & Thenable
110
+
74
111
  const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
75
112
  let options: HolsterOptions
76
113
  if (typeof opt === "string") options = {peers: [opt]}
@@ -156,6 +193,25 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
156
193
  return readRelTarget(id, ctxUser, opt, retries + 1)
157
194
  }
158
195
 
196
+ // Wraps a not-yet-terminal chain object (api(ctxid), returned wherever no
197
+ // callback was given to resolve it yet) with a .then, so awaiting it
198
+ // resolves the same way a callback given right here would - mirrors
199
+ // readRelTarget's own api(ctxid).next(null, cb) idiom, which resolves the
200
+ // current chain without pushing a new key onto it. There's no separate
201
+ // error channel on this path (get/next's own callback only ever receives
202
+ // a value, never an error), so reject is simply never called - a
203
+ // rejection-less promise is still a valid one. Deliberately only applied
204
+ // at these specific "chain not yet resolved" return points, not to
205
+ // api()'s own bare object (see holster.user(), which Object.assigns that
206
+ // onto the long-lived user object) - that object has no chain to resolve
207
+ // at all, and giving user itself a .then would make a bare `await user`
208
+ // (or passing it to Promise.all, etc) silently try to resolve one.
209
+ const awaitable = (chainApi: HolsterAPI): AwaitableHolsterAPI => ({
210
+ ...chainApi,
211
+ then: (resolve: (data: unknown) => void) =>
212
+ chainApi.next(null, resolve as never),
213
+ })
214
+
159
215
  const api = (initCtxid?: string): HolsterAPI => {
160
216
  let ctxid = initCtxid
161
217
  const get = (
@@ -559,7 +615,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
559
615
  chain: [{item: String(key), soul: "root"}],
560
616
  cb: callback,
561
617
  })
562
- if (!callback) return api(ctxid)
618
+ if (!callback) return awaitable(api(ctxid))
563
619
 
564
620
  const _done = done(ctxid)
565
621
  const result = resolve({get: lexFilter, _opt: opts}, _done)
@@ -624,7 +680,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
624
680
  }
625
681
 
626
682
  if (key !== null) ctx.chain.push({item: String(key), soul: null})
627
- if (!ctx.cb) return api(ctxid)
683
+ if (!ctx.cb) return awaitable(api(ctxid))
628
684
 
629
685
  const result = resolve({get: lexFilter, _opt: opts}, _ack)
630
686
  if (result) get(lexFilter as never, result.soul!, _ack, opts)
@@ -635,7 +691,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
635
691
  data: GraphValue | Record<string, GraphValue>,
636
692
  set?: boolean | ((data?: string | null) => void),
637
693
  cb?: (data?: string | null) => void,
638
- ): HolsterAPI | void {
694
+ ): void | Promise<void> {
639
695
  let isSet = false
640
696
  let callback: ((data?: string | null) => void) | undefined
641
697
 
@@ -647,6 +703,26 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
647
703
  callback = cb
648
704
  }
649
705
 
706
+ // Unlike get/next (see awaitable above), put's own callback always
707
+ // receives an error channel - an error string, or undefined on
708
+ // success - so a promise here can reject, not just resolve.
709
+ if (!callback) {
710
+ const promise = new Promise<void>((promiseResolve, reject) => {
711
+ api(ctxid).put(data, isSet, err =>
712
+ err ? reject(new Error(err)) : promiseResolve(),
713
+ )
714
+ })
715
+ // A caller using the old fire-and-forget idiom (put(data) with
716
+ // no callback, result never touched) shouldn't crash the
717
+ // process on an unhandled rejection if something genuinely
718
+ // fails now that this returns a promise - doesn't suppress the
719
+ // rejection for a caller that does await/catch it, since a
720
+ // promise can have more than one handler attached (same
721
+ // pattern as createRel's own promise above).
722
+ promise.catch(() => {})
723
+ return promise
724
+ }
725
+
650
726
  const ack = (ctxid: string) => {
651
727
  return (data?: string | null): void => {
652
728
  if (callback) {
@@ -797,12 +873,14 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
797
873
 
798
874
  const _ctxid = utils.text.random()
799
875
  const chain: ChainItem[] = [{item: item!, soul: soul}]
800
- allctx.set(_ctxid, {
801
- chain: chain,
802
- user: ctx.user,
803
- cb: ctx.cb,
804
- })
805
- api(_ctxid).put(data)
876
+ allctx.set(_ctxid, {chain: chain, user: ctx.user})
877
+ // Pass on the previous ctx's own callback (if any)
878
+ // positionally rather than pre-registering it via
879
+ // allctx - otherwise it's invisible to put's own "no cb
880
+ // given" check above, which would then wrap this retry
881
+ // in a brand new promise competing with (and never
882
+ // actually calling) the real one.
883
+ api(_ctxid).put(data, ctx.cb as never)
806
884
  })
807
885
  return
808
886
  }
@@ -864,7 +942,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
864
942
  )
865
943
  }
866
944
  attemptRead()
867
- },
945
+ } as HolsterAPI["put"],
868
946
 
869
947
  on: function (
870
948
  lex: LexWithDot | ((data: unknown) => void),
@@ -1199,7 +1277,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
1199
1277
  user: user.is,
1200
1278
  cb: callback as never,
1201
1279
  })
1202
- if (!callback) return api(ctxid)
1280
+ if (!callback) return awaitable(api(ctxid))
1203
1281
 
1204
1282
  const _done = done(ctxid)
1205
1283
  const resolved = resolve({get: lexFilter, _opt: opts}, _done)
package/ts/src/user.ts CHANGED
@@ -12,7 +12,7 @@ import type {
12
12
  LexFilter,
13
13
  WireOptions,
14
14
  } from "./schemas.ts"
15
- import type {HolsterAPI} from "./holster.ts"
15
+ import type {HolsterAPI, AwaitableHolsterAPI} from "./holster.ts"
16
16
 
17
17
  type WireInterface = ReturnType<typeof Wire>
18
18
 
@@ -42,12 +42,24 @@ export interface UserInterface {
42
42
  password: string,
43
43
  cb?: (err?: string | null) => void,
44
44
  ) => void
45
- get?(
46
- keys: string | string[],
47
- lex?: LexFilter | ((data: unknown) => void),
48
- cb?: ((data: unknown) => void) | WireOptions,
49
- _opt?: WireOptions,
50
- ): HolsterAPI
45
+ get?: {
46
+ (
47
+ keys: string | string[],
48
+ lex: LexFilter,
49
+ cb: (data: unknown) => void,
50
+ _opt?: WireOptions,
51
+ ): HolsterAPI
52
+ (
53
+ keys: string | string[],
54
+ cb: (data: unknown) => void,
55
+ _opt?: WireOptions,
56
+ ): HolsterAPI
57
+ (
58
+ keys: string | string[],
59
+ lex?: LexFilter,
60
+ _opt?: WireOptions,
61
+ ): AwaitableHolsterAPI
62
+ }
51
63
  }
52
64
 
53
65
  /**
package/ts/src/wire.ts CHANGED
@@ -556,7 +556,15 @@ const Wire = (opt: HolsterOptions): WireAPI => {
556
556
  })
557
557
 
558
558
  if (ack) {
559
- if (!isNode) {
559
+ // Put requests only need a local answer - skip the wire round-trip
560
+ // (matches the same check below, once store.get is reached instead
561
+ // of this fast graph path). Without this, check()'s own bare
562
+ // full-node lookup - always {put: true} - re-sends on the wire every
563
+ // time it's called once the soul's been through store.get once (see
564
+ // hasNode above), and since its own reply re-enters put() and calls
565
+ // check() again, this becomes a self-sustaining loop: a get every
566
+ // ~recentCheckTTL, forever, per soul, for every soul ever fetched.
567
+ if (!isNode && !opts.put) {
560
568
  // Client: send request on the wire to check for updates,
561
569
  // since it may have missed puts while disconnected.
562
570
  // Skip if the same lex was checked recently to avoid flooding.