@mblaney/holster 2.1.3 → 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/ts/src/radix.ts CHANGED
@@ -57,44 +57,74 @@ const Radix = (): RadixFunction => {
57
57
  }
58
58
 
59
59
  if (!found) {
60
- // If not found from the provided keys try matching with an existing key
61
- const result = utils.obj.map(
62
- tree!,
63
- (hasValue: RadixValue, hasKey: string) => {
64
- let j = 0
65
- let matchingKey = ""
66
- while (hasKey[j] === keys[j]) {
67
- matchingKey += hasKey[j]
68
- j++
69
- }
70
- if (matchingKey) {
71
- if (noValue) {
72
- // matchingKey has to be as long as the original keys when reading
60
+ if (noValue) {
61
+ // If not found from the provided keys try matching with an
62
+ // existing key.
63
+ const result = utils.obj.map(
64
+ tree!,
65
+ (hasValue: RadixValue, hasKey: string) => {
66
+ let j = 0
67
+ let matchingKey = ""
68
+ while (hasKey[j] === keys[j]) {
69
+ matchingKey += hasKey[j]
70
+ j++
71
+ }
72
+ if (matchingKey) {
73
+ // matchingKey has to be as long as the original keys when
74
+ // reading.
73
75
  if (j <= max) return undefined
74
76
 
75
77
  tmp[hasKey.slice(j)] = hasValue
76
78
  return hasValue
77
79
  }
80
+ return undefined
81
+ },
82
+ )
83
+ if (!result) return undefined
78
84
 
79
- const replace: RadixTree = {
80
- [hasKey.slice(j)]: hasValue,
81
- [keys.slice(j)]: {[record]: value!},
82
- }
83
- tree![matchingKey] = {[group]: replace}
84
- delete tree![hasKey]
85
- return true
86
- }
87
- return undefined
88
- },
89
- )
85
+ return tmp
86
+ }
90
87
 
91
- if (!result) {
92
- if (noValue) return undefined
88
+ // Writing: find the EXISTING key with the longest shared prefix,
89
+ // not just the first one with any overlap at all (obj.map stops at
90
+ // the first non-undefined result, so a plain find here would settle
91
+ // for whichever candidate happens to come first in Object.keys'
92
+ // own iteration order). A radix tree's own invariant - no two
93
+ // sibling keys share a prefix - only holds if every insertion
94
+ // merges with its one true match; with many keys sharing this tree
95
+ // at once (many concurrent writers batched together), a
96
+ // coincidentally shorter, unrelated key can otherwise "win" over
97
+ // the real, much longer match - silently misplacing the new key
98
+ // under the wrong group, while its actual sibling is never touched
99
+ // at all and stays wherever it already was, invisible to any read
100
+ // that expects it inside this new group.
101
+ let bestKey: string | null = null
102
+ let bestMatch = ""
103
+ for (const hasKey of Object.keys(tree!)) {
104
+ let j = 0
105
+ let matchingKey = ""
106
+ while (hasKey[j] === keys[j]) {
107
+ matchingKey += hasKey[j]
108
+ j++
109
+ }
110
+ if (matchingKey.length > bestMatch.length) {
111
+ bestKey = hasKey
112
+ bestMatch = matchingKey
113
+ }
114
+ }
93
115
 
116
+ if (bestKey) {
117
+ const hasValue = tree![bestKey]!
118
+ const j = bestMatch.length
119
+ const replace: RadixTree = {
120
+ [bestKey.slice(j)]: hasValue,
121
+ [keys.slice(j)]: {[record]: value!},
122
+ }
123
+ tree![bestMatch] = {[group]: replace}
124
+ delete tree![bestKey]
125
+ } else {
94
126
  if (!tree![key]) tree![key] = {}
95
127
  tree![key]![record] = value!
96
- } else if (noValue) {
97
- return tmp
98
128
  }
99
129
  } else if (i === max) {
100
130
  // If no value use the key provided to return a whole group or record
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/store.ts CHANGED
@@ -336,4 +336,5 @@ const Store = (opt?: StoreOptions): StoreInterface => {
336
336
  }
337
337
  }
338
338
 
339
+ export {fileSystem}
339
340
  export default Store
package/ts/src/wire.ts CHANGED
@@ -370,6 +370,14 @@ const Wire = (opt: HolsterOptions): WireAPI => {
370
370
  console.log("Run npx holster-user-storage to view full keys and all users")
371
371
  }
372
372
 
373
+ // The check function is required because user data must provide a public key
374
+ // so that it can be verified. The public key might verify the provided
375
+ // signature but not actually match the user under which the data is being
376
+ // stored. To avoid this, the current data on a soul needs to be checked to
377
+ // make sure the stored public key matches the one provided with the update.
378
+ // This must run before Ham.mix, which updates graph in place — checking
379
+ // afterwards would compare the incoming update's public key against itself
380
+ // instead of the previously stored one.
373
381
  const check = async (
374
382
  data: Graph,
375
383
  send: (msg: string) => {err?: string} | void,
@@ -383,8 +391,12 @@ const Wire = (opt: HolsterOptions): WireAPI => {
383
391
  // allow it - the update is not trying to change the public key.
384
392
  if (node[key] === undefined) continue
385
393
 
394
+ // A full-node lookup is required (rather than a targeted property
395
+ // lookup) so that a soul which exists but has never had a public key
396
+ // can be told apart from a soul that doesn't exist yet — both would
397
+ // otherwise resolve to the same "not found" shape for this one key.
386
398
  const msg = await new Promise<WireMessage>(res => {
387
- getWithCallback({"#": soul, ".": key}, res, send, {put: true})
399
+ getWithCallback({"#": soul}, res, send, {put: true})
388
400
  })
389
401
  if (msg.err) {
390
402
  if (cb) cb(msg.err)
@@ -395,7 +407,12 @@ const Wire = (opt: HolsterOptions): WireAPI => {
395
407
  // matching public keys, as the provided soul also needs a rel on the
396
408
  // parent node which then also requires checking. Otherwise public keys
397
409
  // need to match for existing data.
398
- if (!msg.put || !msg.put[soul] || msg.put[soul]![key] === node[key]) {
410
+ const existing = msg.put && msg.put[soul]
411
+ if (
412
+ !existing ||
413
+ existing[key] === undefined ||
414
+ existing[key] === node[key]
415
+ ) {
399
416
  continue
400
417
  }
401
418
 
@@ -449,6 +466,8 @@ const Wire = (opt: HolsterOptions): WireAPI => {
449
466
  }
450
467
  }
451
468
 
469
+ if (!(await check(msg.put, send as never))) return
470
+
452
471
  const update = await Ham.mix(
453
472
  msg.put,
454
473
  graph,
@@ -467,8 +486,6 @@ const Wire = (opt: HolsterOptions): WireAPI => {
467
486
  return
468
487
  }
469
488
 
470
- if (!(await check(update.now, send as never))) return
471
-
472
489
  const finish = (err?: string | null): void => {
473
490
  if (err) console.warn("store.put", err)
474
491
  send(
@@ -492,14 +509,18 @@ const Wire = (opt: HolsterOptions): WireAPI => {
492
509
  continue
493
510
  }
494
511
  const limit = userLimits.get(pub) ?? defaultLimit
495
- if ((userStorage.get(pub) ?? 0) >= limit) continue
496
- toStore[soul] = node
512
+ const current = userStorage.get(pub) ?? 0
497
513
  const oldBytes = oldGraphBytes[soul] ?? 0
498
514
  const newBytes = graph[soul] ? JSON.stringify(graph[soul]).length : 0
499
- userStorage.set(
500
- pub,
501
- Math.max(0, (userStorage.get(pub) ?? 0) - oldBytes + newBytes),
502
- )
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)
503
524
  saveUserStorage()
504
525
  }
505
526
  if (Object.keys(toStore).length > 0) {
@@ -679,6 +700,10 @@ const Wire = (opt: HolsterOptions): WireAPI => {
679
700
  }
680
701
  }
681
702
  }
703
+ if (!(await check(data, send as never, cb))) {
704
+ return
705
+ }
706
+
682
707
  const update = await Ham.mix(
683
708
  data,
684
709
  graph,
@@ -692,8 +717,15 @@ const Wire = (opt: HolsterOptions): WireAPI => {
692
717
  return
693
718
  }
694
719
 
695
- if (!(await check(update.now, send as never, cb))) {
696
- return
720
+ // Ham.mix has already merged this locally-authored update into
721
+ // graph[soul], synchronously, before store.put below even starts
722
+ // persisting it - mark it as fully known now rather than waiting
723
+ // for a future disk fetch to do so, so a read landing in that
724
+ // window doesn't get forced through a disk round-trip that could
725
+ // itself race ahead of this same write and find nothing, even
726
+ // though the correct answer was already sitting in memory.
727
+ for (const soul of Object.keys(update.now)) {
728
+ hasNode.add(soul)
697
729
  }
698
730
 
699
731
  if (options.userLimit) {
@@ -702,15 +734,21 @@ const Wire = (opt: HolsterOptions): WireAPI => {
702
734
  if (!pub) continue
703
735
  const limit = userLimits.get(pub) ?? defaultLimit
704
736
  const current = userStorage.get(pub) ?? 0
705
- if (current >= limit) {
706
- if (cb) cb("storage limit exceeded")
707
- return
708
- }
709
737
  const oldBytes = oldGraphBytes[soul] ?? 0
710
738
  const newBytes = graph[soul]
711
739
  ? JSON.stringify(graph[soul]).length
712
740
  : 0
713
- 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)
714
752
  saveUserStorage()
715
753
  }
716
754
  }