@mblaney/holster 2.2.3 → 2.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mblaney/holster",
3
- "version": "2.2.3",
3
+ "version": "2.3.1",
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
@@ -73,17 +73,31 @@ const Holster = opt => {
73
73
  const data = await new Promise(res => {
74
74
  const _ctxid = utils.text.random()
75
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
- )
76
+ api(_ctxid).next(null, res, _opt)
81
77
  })
82
78
  if (data !== null || retries >= 5) return data
83
79
  await new Promise(resolve => setTimeout(resolve, 50))
84
80
  return readRelTarget(id, ctxUser, _opt, retries + 1)
85
81
  }
86
82
 
83
+ // Wraps a not-yet-terminal chain object (api(ctxid), returned wherever no
84
+ // callback was given to resolve it yet) with a .then, so awaiting it
85
+ // resolves the same way a callback given right here would - mirrors
86
+ // readRelTarget's own api(ctxid).next(null, cb) idiom, which resolves the
87
+ // current chain without pushing a new key onto it. There's no separate
88
+ // error channel on this path (get/next's own callback only ever receives
89
+ // a value, never an error), so reject is simply never called - a
90
+ // rejection-less promise is still a valid one. Deliberately only applied
91
+ // at these specific "chain not yet resolved" return points, not to
92
+ // api()'s own bare object (see holster.user(), which Object.assigns that
93
+ // onto the long-lived user object) - that object has no chain to resolve
94
+ // at all, and giving user itself a .then would make a bare `await user`
95
+ // (or passing it to Promise.all, etc) silently try to resolve one.
96
+ const awaitable = chainApi => ({
97
+ ...chainApi,
98
+ then: resolve => chainApi.next(null, resolve),
99
+ })
100
+
87
101
  const api = ctxid => {
88
102
  const get = (lex, soul, ack, _opt) => {
89
103
  wire.get(
@@ -412,7 +426,7 @@ const Holster = opt => {
412
426
  // to be objects.
413
427
  ctxid = utils.text.random()
414
428
  allctx.set(ctxid, {chain: [{item: String(key), soul: "root"}], cb: cb})
415
- if (!cb) return api(ctxid)
429
+ if (!cb) return awaitable(api(ctxid))
416
430
 
417
431
  const _done = done(ctxid)
418
432
  // When there's a callback need to resolve the context first.
@@ -467,7 +481,7 @@ const Holster = opt => {
467
481
  // Push the key to the context as it needs a soul lookup.
468
482
  // (null is used to call the api with updated context)
469
483
  if (key !== null) ctx.chain.push({item: String(key), soul: null})
470
- if (!ctx.cb) return api(ctxid)
484
+ if (!ctx.cb) return awaitable(api(ctxid))
471
485
 
472
486
  // When there's a callback need to resolve the context first.
473
487
  const {soul} = resolve({get: lex, _opt: _opt}, _ack)
@@ -478,6 +492,25 @@ const Holster = opt => {
478
492
  cb = set
479
493
  set = false
480
494
  }
495
+ // Unlike get/next (see awaitable above), put's own callback always
496
+ // receives an error channel - an error string, or undefined on
497
+ // success - so a promise here can reject, not just resolve.
498
+ if (!cb) {
499
+ const promise = new Promise((resolve, reject) => {
500
+ api(ctxid).put(data, set, err =>
501
+ err ? reject(new Error(err)) : resolve(),
502
+ )
503
+ })
504
+ // A caller using the old fire-and-forget idiom (put(data) with
505
+ // no callback, result never touched) shouldn't crash the
506
+ // process on an unhandled rejection if something genuinely
507
+ // fails now that this returns a promise - doesn't suppress the
508
+ // rejection for a caller that does await/catch it, since a
509
+ // promise can have more than one handler attached (same
510
+ // pattern as createRel's own promise above).
511
+ promise.catch(() => {})
512
+ return promise
513
+ }
481
514
  const ack = ctxid => {
482
515
  return data => {
483
516
  if (cb) {
@@ -619,13 +652,14 @@ const Holster = opt => {
619
652
 
620
653
  const _ctxid = utils.text.random()
621
654
  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)
655
+ allctx.set(_ctxid, {chain: chain, user: ctx.user})
656
+ // Pass on the previous ctx's own callback (if any)
657
+ // positionally rather than pre-registering it via
658
+ // allctx - otherwise it's invisible to put's own "no cb
659
+ // given" check above, which would then wrap this retry
660
+ // in a brand new promise competing with (and never
661
+ // actually calling) the real one.
662
+ api(_ctxid).put(data, ctx.cb)
629
663
  })
630
664
  return
631
665
  }
@@ -952,7 +986,7 @@ const Holster = opt => {
952
986
  ctxid = utils.text.random()
953
987
  const chain = [{item: String(key), soul: "~" + pub}]
954
988
  allctx.set(ctxid, {chain: chain, user: user.is, cb: cb})
955
- if (!cb) return api(ctxid)
989
+ if (!cb) return awaitable(api(ctxid))
956
990
 
957
991
  // When there's a callback need to resolve the context first.
958
992
  const _done = done(ctxid)
package/src/wire.js CHANGED
@@ -534,12 +534,12 @@ const Wire = opt => {
534
534
  return
535
535
  }
536
536
 
537
- store.get(lex, (err, ack) => {
538
- if (ack) {
537
+ store.get(lex, (err, stored) => {
538
+ if (stored) {
539
539
  // Populate in-memory graph from store data before sending the server
540
540
  // request, so stale server responses with older state are correctly
541
541
  // rejected by ham.mix.
542
- for (const [soul, node] of Object.entries(ack)) {
542
+ for (const [soul, node] of Object.entries(stored)) {
543
543
  if (!node) continue
544
544
  if (!graph[soul]) {
545
545
  graph[soul] = {...node}
@@ -567,40 +567,44 @@ const Wire = opt => {
567
567
  }
568
568
  }
569
569
  // Full-node fetch: the graph is now complete for this soul.
570
- if (!lex["."] && ack[lex["#"]]) hasNode.add(lex["#"])
571
- // Only serve from store if the requested property is present.
572
- const node = ack[lex["#"]]
573
- const hasProperty =
574
- node &&
575
- Object.keys(node).some(
576
- key => key !== "_" && utils.match(lex["."], key),
577
- )
578
- if (hasProperty) {
579
- // Public keys are immutable once written — never need a wire check.
580
- if (!isNode && lex["."] !== utils.userPublicKey) {
581
- if (!lex["."] || typeof lex["."] === "string") {
582
- const checkKey = lex["#"] + (lex["."] ? "." + lex["."] : "")
583
- const now = Date.now()
584
- const lastCheck = recentChecks.get(checkKey)
585
- if (!lastCheck || now - lastCheck > recentCheckTTL) {
586
- recentChecks.set(checkKey, now)
587
- const sendResult = send(request)
588
- if (sendResult && sendResult.err) {
589
- cb({err: sendResult.err})
590
- return
591
- }
592
- }
593
- } else {
570
+ if (!lex["."] && stored[lex["#"]]) hasNode.add(lex["#"])
571
+ }
572
+
573
+ // Answered from the graph rather than the raw store result: the merge
574
+ // above keeps any newer in-memory values, so a local put that store
575
+ // hasn't finished persisting yet is still included, while everything
576
+ // already on disk fills in the rest of the node.
577
+ const ack = {...stored, ...Get(lex, graph, true)}
578
+ // Only serve locally if the requested property is present.
579
+ const node = ack[lex["#"]]
580
+ const hasProperty =
581
+ node &&
582
+ Object.keys(node).some(key => key !== "_" && utils.match(lex["."], key))
583
+ if (hasProperty) {
584
+ // Public keys are immutable once written - never need a wire check.
585
+ if (!isNode && lex["."] !== utils.userPublicKey) {
586
+ if (!lex["."] || typeof lex["."] === "string") {
587
+ const checkKey = lex["#"] + (lex["."] ? "." + lex["."] : "")
588
+ const now = Date.now()
589
+ const lastCheck = recentChecks.get(checkKey)
590
+ if (!lastCheck || now - lastCheck > recentCheckTTL) {
591
+ recentChecks.set(checkKey, now)
594
592
  const sendResult = send(request)
595
593
  if (sendResult && sendResult.err) {
596
594
  cb({err: sendResult.err})
597
595
  return
598
596
  }
599
597
  }
598
+ } else {
599
+ const sendResult = send(request)
600
+ if (sendResult && sendResult.err) {
601
+ cb({err: sendResult.err})
602
+ return
603
+ }
600
604
  }
601
- cb(err ? {put: ack, err: err} : {put: ack})
602
- return
603
605
  }
606
+ cb(err ? {put: ack, err: err} : {put: ack})
607
+ return
604
608
  }
605
609
 
606
610
  // Data wasn't found locally, log errors and call back after a timeout.
@@ -678,17 +682,6 @@ const Wire = opt => {
678
682
  return
679
683
  }
680
684
 
681
- // Ham.mix has already merged this locally-authored update into
682
- // graph[soul], synchronously, before store.put below even starts
683
- // persisting it - mark it as fully known now rather than waiting
684
- // for a future disk fetch to do so, so a read landing in that
685
- // window doesn't get forced through a disk round-trip that could
686
- // itself race ahead of this same write and find nothing, even
687
- // though the correct answer was already sitting in memory.
688
- for (const soul of Object.keys(update.now)) {
689
- hasNode.add(soul)
690
- }
691
-
692
685
  if (opt.userLimit) {
693
686
  for (const [soul, node] of Object.entries(update.now)) {
694
687
  const pub = node[utils.userPublicKey]
package/ts/src/get.ts CHANGED
@@ -59,9 +59,10 @@ const Get = (lex: Lex, graph: Graph, fast?: boolean): Graph | undefined => {
59
59
  // return undefined to ensure we get all matching properties from store.
60
60
  if (!fast) return undefined
61
61
 
62
+ let found = false
62
63
  for (const key of Object.keys(graph[soul]!)) {
63
64
  // Always include userPublicKey for verification, regardless of filter
64
- if (key === userPublicKey || match(lex["."], key)) {
65
+ if (key !== "_" && (key === userPublicKey || match(lex["."], key))) {
65
66
  node[key] = graph[soul]![key]!
66
67
  node._[">"][key] = graph[soul]!._[">"][key]!
67
68
  const state = graph[soul]!._[">"][key]!
@@ -69,8 +70,10 @@ const Get = (lex: Lex, graph: Graph, fast?: boolean): Graph | undefined => {
69
70
  if (sigs && sigs[state]) {
70
71
  signatures[state.toString()] = sigs[state]!
71
72
  }
73
+ found = true
72
74
  }
73
75
  }
76
+ if (!found) return undefined
74
77
  }
75
78
 
76
79
  if (Object.keys(signatures).length > 0) {
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]}
@@ -145,17 +182,32 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
145
182
  const data = await new Promise<unknown>(res => {
146
183
  const _ctxid = utils.text.random()
147
184
  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
- )
185
+ api(_ctxid).next(null as never, res as never, opt as never)
153
186
  })
154
187
  if (data !== null || retries >= 5) return data
155
188
  await new Promise(resolve => setTimeout(resolve, 50))
156
189
  return readRelTarget(id, ctxUser, opt, retries + 1)
157
190
  }
158
191
 
192
+ // Wraps a not-yet-terminal chain object (api(ctxid), returned wherever no
193
+ // callback was given to resolve it yet) with a .then, so awaiting it
194
+ // resolves the same way a callback given right here would - mirrors
195
+ // readRelTarget's own api(ctxid).next(null, cb) idiom, which resolves the
196
+ // current chain without pushing a new key onto it. There's no separate
197
+ // error channel on this path (get/next's own callback only ever receives
198
+ // a value, never an error), so reject is simply never called - a
199
+ // rejection-less promise is still a valid one. Deliberately only applied
200
+ // at these specific "chain not yet resolved" return points, not to
201
+ // api()'s own bare object (see holster.user(), which Object.assigns that
202
+ // onto the long-lived user object) - that object has no chain to resolve
203
+ // at all, and giving user itself a .then would make a bare `await user`
204
+ // (or passing it to Promise.all, etc) silently try to resolve one.
205
+ const awaitable = (chainApi: HolsterAPI): AwaitableHolsterAPI => ({
206
+ ...chainApi,
207
+ then: (resolve: (data: unknown) => void) =>
208
+ chainApi.next(null, resolve as never),
209
+ })
210
+
159
211
  const api = (initCtxid?: string): HolsterAPI => {
160
212
  let ctxid = initCtxid
161
213
  const get = (
@@ -559,7 +611,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
559
611
  chain: [{item: String(key), soul: "root"}],
560
612
  cb: callback,
561
613
  })
562
- if (!callback) return api(ctxid)
614
+ if (!callback) return awaitable(api(ctxid))
563
615
 
564
616
  const _done = done(ctxid)
565
617
  const result = resolve({get: lexFilter, _opt: opts}, _done)
@@ -624,7 +676,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
624
676
  }
625
677
 
626
678
  if (key !== null) ctx.chain.push({item: String(key), soul: null})
627
- if (!ctx.cb) return api(ctxid)
679
+ if (!ctx.cb) return awaitable(api(ctxid))
628
680
 
629
681
  const result = resolve({get: lexFilter, _opt: opts}, _ack)
630
682
  if (result) get(lexFilter as never, result.soul!, _ack, opts)
@@ -635,7 +687,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
635
687
  data: GraphValue | Record<string, GraphValue>,
636
688
  set?: boolean | ((data?: string | null) => void),
637
689
  cb?: (data?: string | null) => void,
638
- ): HolsterAPI | void {
690
+ ): void | Promise<void> {
639
691
  let isSet = false
640
692
  let callback: ((data?: string | null) => void) | undefined
641
693
 
@@ -647,6 +699,26 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
647
699
  callback = cb
648
700
  }
649
701
 
702
+ // Unlike get/next (see awaitable above), put's own callback always
703
+ // receives an error channel - an error string, or undefined on
704
+ // success - so a promise here can reject, not just resolve.
705
+ if (!callback) {
706
+ const promise = new Promise<void>((promiseResolve, reject) => {
707
+ api(ctxid).put(data, isSet, err =>
708
+ err ? reject(new Error(err)) : promiseResolve(),
709
+ )
710
+ })
711
+ // A caller using the old fire-and-forget idiom (put(data) with
712
+ // no callback, result never touched) shouldn't crash the
713
+ // process on an unhandled rejection if something genuinely
714
+ // fails now that this returns a promise - doesn't suppress the
715
+ // rejection for a caller that does await/catch it, since a
716
+ // promise can have more than one handler attached (same
717
+ // pattern as createRel's own promise above).
718
+ promise.catch(() => {})
719
+ return promise
720
+ }
721
+
650
722
  const ack = (ctxid: string) => {
651
723
  return (data?: string | null): void => {
652
724
  if (callback) {
@@ -797,12 +869,14 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
797
869
 
798
870
  const _ctxid = utils.text.random()
799
871
  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)
872
+ allctx.set(_ctxid, {chain: chain, user: ctx.user})
873
+ // Pass on the previous ctx's own callback (if any)
874
+ // positionally rather than pre-registering it via
875
+ // allctx - otherwise it's invisible to put's own "no cb
876
+ // given" check above, which would then wrap this retry
877
+ // in a brand new promise competing with (and never
878
+ // actually calling) the real one.
879
+ api(_ctxid).put(data, ctx.cb as never)
806
880
  })
807
881
  return
808
882
  }
@@ -864,7 +938,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
864
938
  )
865
939
  }
866
940
  attemptRead()
867
- },
941
+ } as HolsterAPI["put"],
868
942
 
869
943
  on: function (
870
944
  lex: LexWithDot | ((data: unknown) => void),
@@ -1199,7 +1273,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
1199
1273
  user: user.is,
1200
1274
  cb: callback as never,
1201
1275
  })
1202
- if (!callback) return api(ctxid)
1276
+ if (!callback) return awaitable(api(ctxid))
1203
1277
 
1204
1278
  const _done = done(ctxid)
1205
1279
  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
@@ -593,12 +593,12 @@ const Wire = (opt: HolsterOptions): WireAPI => {
593
593
  return
594
594
  }
595
595
 
596
- store.get(lex, (err, ack) => {
597
- if (ack) {
596
+ store.get(lex, (err, stored) => {
597
+ if (stored) {
598
598
  // Populate in-memory graph from store data before sending the server
599
599
  // request, so stale server responses with older state are correctly
600
600
  // rejected by ham.mix.
601
- for (const [soul, node] of Object.entries(ack)) {
601
+ for (const [soul, node] of Object.entries(stored)) {
602
602
  if (!node) continue
603
603
  if (!graph[soul]) {
604
604
  graph[soul] = {...node} as unknown as Graph[string]
@@ -622,40 +622,44 @@ const Wire = (opt: HolsterOptions): WireAPI => {
622
622
  }
623
623
  }
624
624
  // Full-node fetch: the graph is now complete for this soul.
625
- if (!lex["."] && ack[lex["#"]]) hasNode.add(lex["#"])
626
- // Only serve from store if the requested property is present.
627
- const node = ack[lex["#"]]
628
- const hasProperty =
629
- node &&
630
- Object.keys(node).some(
631
- key => key !== "_" && utils.match(lex["."], key),
632
- )
633
- if (hasProperty) {
634
- // Public keys are immutable once written — never need a wire check.
635
- if (!isNode && lex["."] !== utils.userPublicKey) {
636
- if (!lex["."] || typeof lex["."] === "string") {
637
- const checkKey = lex["#"] + (lex["."] ? "." + lex["."] : "")
638
- const now = Date.now()
639
- const lastCheck = recentChecks.get(checkKey)
640
- if (!lastCheck || now - lastCheck > recentCheckTTL) {
641
- recentChecks.set(checkKey, now)
642
- const sendResult = send(request)
643
- if (sendResult && sendResult.err) {
644
- cb({err: sendResult.err})
645
- return
646
- }
647
- }
648
- } else {
625
+ if (!lex["."] && stored[lex["#"]]) hasNode.add(lex["#"])
626
+ }
627
+
628
+ // Answered from the graph rather than the raw store result: the merge
629
+ // above keeps any newer in-memory values, so a local put that store
630
+ // hasn't finished persisting yet is still included, while everything
631
+ // already on disk fills in the rest of the node.
632
+ const ack = {...stored, ...Get(lex, graph, true)}
633
+ // Only serve locally if the requested property is present.
634
+ const node = ack[lex["#"]]
635
+ const hasProperty =
636
+ node &&
637
+ Object.keys(node).some(key => key !== "_" && utils.match(lex["."], key))
638
+ if (hasProperty) {
639
+ // Public keys are immutable once written - never need a wire check.
640
+ if (!isNode && lex["."] !== utils.userPublicKey) {
641
+ if (!lex["."] || typeof lex["."] === "string") {
642
+ const checkKey = lex["#"] + (lex["."] ? "." + lex["."] : "")
643
+ const now = Date.now()
644
+ const lastCheck = recentChecks.get(checkKey)
645
+ if (!lastCheck || now - lastCheck > recentCheckTTL) {
646
+ recentChecks.set(checkKey, now)
649
647
  const sendResult = send(request)
650
648
  if (sendResult && sendResult.err) {
651
649
  cb({err: sendResult.err})
652
650
  return
653
651
  }
654
652
  }
653
+ } else {
654
+ const sendResult = send(request)
655
+ if (sendResult && sendResult.err) {
656
+ cb({err: sendResult.err})
657
+ return
658
+ }
655
659
  }
656
- cb(err ? {put: ack, err: err} : {put: ack})
657
- return
658
660
  }
661
+ cb(err ? {put: ack, err: err} : {put: ack})
662
+ return
659
663
  }
660
664
 
661
665
  if (err) console.log(err)
@@ -725,17 +729,6 @@ const Wire = (opt: HolsterOptions): WireAPI => {
725
729
  return
726
730
  }
727
731
 
728
- // Ham.mix has already merged this locally-authored update into
729
- // graph[soul], synchronously, before store.put below even starts
730
- // persisting it - mark it as fully known now rather than waiting
731
- // for a future disk fetch to do so, so a read landing in that
732
- // window doesn't get forced through a disk round-trip that could
733
- // itself race ahead of this same write and find nothing, even
734
- // though the correct answer was already sitting in memory.
735
- for (const soul of Object.keys(update.now)) {
736
- hasNode.add(soul)
737
- }
738
-
739
732
  if (options.userLimit) {
740
733
  for (const [soul, node] of Object.entries(update.now)) {
741
734
  const pub = node![utils.userPublicKey] as string | undefined