@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/README.md +22 -9
- package/build/holster.js +3 -3
- package/build/holster.js.map +4 -4
- package/dist/holster.js +3 -3
- package/dist/holster.js.map +4 -4
- package/package.json +2 -2
- package/src/holster.js +113 -34
- package/src/nested.js +89 -0
- package/src/radisk.js +82 -23
- package/src/radix.js +52 -22
- package/src/store.js +1 -0
- package/src/wire.js +51 -18
- package/ts/src/holster.ts +136 -55
- package/ts/src/nested.ts +101 -0
- package/ts/src/radisk.ts +73 -18
- package/ts/src/radix.ts +58 -28
- package/ts/src/schemas.ts +1 -0
- package/ts/src/store.ts +1 -0
- package/ts/src/wire.ts +55 -17
package/ts/src/radix.ts
CHANGED
|
@@ -57,44 +57,74 @@ const Radix = (): RadixFunction => {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
if (!found) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
matchingKey
|
|
68
|
-
j
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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
|
}
|