@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/src/wire.js
CHANGED
|
@@ -319,6 +319,9 @@ const Wire = opt => {
|
|
|
319
319
|
// signature but not actually match the user under which the data is being
|
|
320
320
|
// stored. To avoid this, the current data on a soul needs to be checked to
|
|
321
321
|
// make sure the stored public key matches the one provided with the update.
|
|
322
|
+
// This must run before Ham.mix, which updates graph in place — checking
|
|
323
|
+
// afterwards would compare the incoming update's public key against itself
|
|
324
|
+
// instead of the previously stored one.
|
|
322
325
|
const check = async (data, send, cb) => {
|
|
323
326
|
const key = utils.userPublicKey
|
|
324
327
|
|
|
@@ -328,8 +331,12 @@ const Wire = opt => {
|
|
|
328
331
|
// allow it - the update is not trying to change the public key.
|
|
329
332
|
if (node[key] === undefined) continue
|
|
330
333
|
|
|
334
|
+
// A full-node lookup is required (rather than a targeted property
|
|
335
|
+
// lookup) so that a soul which exists but has never had a public key
|
|
336
|
+
// can be told apart from a soul that doesn't exist yet — both would
|
|
337
|
+
// otherwise resolve to the same "not found" shape for this one key.
|
|
331
338
|
const msg = await new Promise(res => {
|
|
332
|
-
getWithCallback({"#": soul
|
|
339
|
+
getWithCallback({"#": soul}, res, send, {put: true})
|
|
333
340
|
})
|
|
334
341
|
if (msg.err) {
|
|
335
342
|
if (cb) cb(msg.err)
|
|
@@ -340,7 +347,12 @@ const Wire = opt => {
|
|
|
340
347
|
// matching public keys, as the provided soul also needs a rel on the
|
|
341
348
|
// parent node which then also requires checking. Otherwise public keys
|
|
342
349
|
// need to match for existing data.
|
|
343
|
-
|
|
350
|
+
const existing = msg.put && msg.put[soul]
|
|
351
|
+
if (
|
|
352
|
+
!existing ||
|
|
353
|
+
existing[key] === undefined ||
|
|
354
|
+
existing[key] === node[key]
|
|
355
|
+
) {
|
|
344
356
|
continue
|
|
345
357
|
}
|
|
346
358
|
|
|
@@ -394,6 +406,8 @@ const Wire = opt => {
|
|
|
394
406
|
}
|
|
395
407
|
}
|
|
396
408
|
|
|
409
|
+
if (!(await check(msg.put, send))) return
|
|
410
|
+
|
|
397
411
|
// Store updates returned from Ham.mix and defer updates if required.
|
|
398
412
|
const update = await Ham.mix(
|
|
399
413
|
msg.put,
|
|
@@ -414,8 +428,6 @@ const Wire = opt => {
|
|
|
414
428
|
return
|
|
415
429
|
}
|
|
416
430
|
|
|
417
|
-
if (!(await check(update.now, send))) return
|
|
418
|
-
|
|
419
431
|
const finish = err => {
|
|
420
432
|
if (err) console.warn("store.put", err)
|
|
421
433
|
send(
|
|
@@ -440,14 +452,18 @@ const Wire = opt => {
|
|
|
440
452
|
continue
|
|
441
453
|
}
|
|
442
454
|
const limit = userLimits.get(pub) ?? defaultLimit
|
|
443
|
-
|
|
444
|
-
toStore[soul] = node
|
|
455
|
+
const current = userStorage.get(pub) ?? 0
|
|
445
456
|
const oldBytes = oldGraphBytes[soul] ?? 0
|
|
446
457
|
const newBytes = graph[soul] ? JSON.stringify(graph[soul]).length : 0
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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)
|
|
451
467
|
saveUserStorage()
|
|
452
468
|
}
|
|
453
469
|
if (Object.keys(toStore).length > 0) {
|
|
@@ -636,6 +652,10 @@ const Wire = opt => {
|
|
|
636
652
|
}
|
|
637
653
|
}
|
|
638
654
|
}
|
|
655
|
+
if (!(await check(data, send, cb))) {
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
|
|
639
659
|
const update = await Ham.mix(
|
|
640
660
|
data,
|
|
641
661
|
graph,
|
|
@@ -650,8 +670,15 @@ const Wire = opt => {
|
|
|
650
670
|
return
|
|
651
671
|
}
|
|
652
672
|
|
|
653
|
-
|
|
654
|
-
|
|
673
|
+
// Ham.mix has already merged this locally-authored update into
|
|
674
|
+
// graph[soul], synchronously, before store.put below even starts
|
|
675
|
+
// persisting it - mark it as fully known now rather than waiting
|
|
676
|
+
// for a future disk fetch to do so, so a read landing in that
|
|
677
|
+
// window doesn't get forced through a disk round-trip that could
|
|
678
|
+
// itself race ahead of this same write and find nothing, even
|
|
679
|
+
// though the correct answer was already sitting in memory.
|
|
680
|
+
for (const soul of Object.keys(update.now)) {
|
|
681
|
+
hasNode.add(soul)
|
|
655
682
|
}
|
|
656
683
|
|
|
657
684
|
if (opt.userLimit) {
|
|
@@ -660,16 +687,22 @@ const Wire = opt => {
|
|
|
660
687
|
if (!pub) continue
|
|
661
688
|
const limit = userLimits.get(pub) ?? defaultLimit
|
|
662
689
|
const current = userStorage.get(pub) ?? 0
|
|
663
|
-
if (current >= limit) {
|
|
664
|
-
if (cb) cb("storage limit exceeded")
|
|
665
|
-
return
|
|
666
|
-
}
|
|
667
|
-
|
|
668
690
|
const oldBytes = oldGraphBytes[soul] ?? 0
|
|
669
691
|
const newBytes = graph[soul]
|
|
670
692
|
? JSON.stringify(graph[soul]).length
|
|
671
693
|
: 0
|
|
672
|
-
|
|
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)
|
|
673
706
|
saveUserStorage()
|
|
674
707
|
}
|
|
675
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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,17 @@ 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>()
|
|
91
|
+
// Serializes concurrent creation of a missing rel for the same
|
|
92
|
+
// soul+item, so only the first caller creates it and the rest reuse
|
|
93
|
+
// its result rather than each minting a competing soul. Map<soul,
|
|
94
|
+
// Map<item, promise resolving to the soul id>>.
|
|
95
|
+
const pendingRel = new Map<string, Map<string, Promise<string>>>()
|
|
81
96
|
|
|
82
97
|
const ok = (data: GraphValue): boolean => {
|
|
83
98
|
return (
|
|
@@ -275,6 +290,72 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
|
|
|
275
290
|
}
|
|
276
291
|
}
|
|
277
292
|
|
|
293
|
+
// Creates a rel for item on soul, or reuses one already being created
|
|
294
|
+
// by a concurrent caller for the same soul+item (see pendingRel above).
|
|
295
|
+
// Calls cb(err, id) with a ready-to-display err, already prefixed where
|
|
296
|
+
// needed - callers should pass it straight through, not wrap it again.
|
|
297
|
+
const createRel = (
|
|
298
|
+
soul: string,
|
|
299
|
+
item: string,
|
|
300
|
+
node: Record<string, GraphValue>,
|
|
301
|
+
user: UserIdentity | null | undefined,
|
|
302
|
+
cb: (err?: string | null, id?: string) => void,
|
|
303
|
+
): void => {
|
|
304
|
+
let bySoul = pendingRel.get(soul)
|
|
305
|
+
const pending = bySoul && bySoul.get(item)
|
|
306
|
+
if (pending) {
|
|
307
|
+
pending.then(
|
|
308
|
+
id => cb(undefined, id),
|
|
309
|
+
err => cb(err as string),
|
|
310
|
+
)
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const id = utils.text.random()
|
|
315
|
+
node[item] = utils.rel.ify(id)
|
|
316
|
+
let settle!: (id: string) => void
|
|
317
|
+
let fail!: (err: unknown) => void
|
|
318
|
+
const promise = new Promise<string>((res, rej) => {
|
|
319
|
+
settle = res
|
|
320
|
+
fail = rej
|
|
321
|
+
})
|
|
322
|
+
promise.catch(() => {})
|
|
323
|
+
if (!bySoul) {
|
|
324
|
+
bySoul = new Map()
|
|
325
|
+
pendingRel.set(soul, bySoul)
|
|
326
|
+
}
|
|
327
|
+
bySoul.set(item, promise)
|
|
328
|
+
const clear = () => {
|
|
329
|
+
bySoul!.delete(item)
|
|
330
|
+
if (bySoul!.size === 0) pendingRel.delete(soul)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
void (async () => {
|
|
334
|
+
// graph() itself calls cb with an already-formatted message when it
|
|
335
|
+
// fails (secure mode, no user) - pass cb straight through rather
|
|
336
|
+
// than wrapping, so that message isn't prefixed a second time.
|
|
337
|
+
const g = await graph(soul, node, user, cb)
|
|
338
|
+
if (g === null) {
|
|
339
|
+
clear()
|
|
340
|
+
fail(new Error("secure mode"))
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
wire.put(g, err => {
|
|
345
|
+
clear()
|
|
346
|
+
if (err) {
|
|
347
|
+
const message = `error putting ${item} on ${soul}: ${err}`
|
|
348
|
+
fail(message)
|
|
349
|
+
cb(message)
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
settle(id)
|
|
354
|
+
cb(undefined, id)
|
|
355
|
+
})
|
|
356
|
+
})()
|
|
357
|
+
}
|
|
358
|
+
|
|
278
359
|
const resolve = (
|
|
279
360
|
request: {
|
|
280
361
|
get?: LexFilter
|
|
@@ -341,28 +422,20 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
|
|
|
341
422
|
} else if (get) {
|
|
342
423
|
cb!(node[item!])
|
|
343
424
|
} else if (put) {
|
|
344
|
-
|
|
345
|
-
node[item!] = utils.rel.ify(id)
|
|
346
|
-
const g = await graph(
|
|
425
|
+
createRel(
|
|
347
426
|
soul!,
|
|
427
|
+
item!,
|
|
348
428
|
node as never,
|
|
349
429
|
ctx.user,
|
|
350
|
-
|
|
430
|
+
(err, relId) => {
|
|
431
|
+
if (err) {
|
|
432
|
+
;(cb as (err: string) => void)(err)
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
ctx.chain[i]!.soul = relId!
|
|
436
|
+
api(ctxid).put(request.put!, cb as never)
|
|
437
|
+
},
|
|
351
438
|
)
|
|
352
|
-
if (g === null) return
|
|
353
|
-
|
|
354
|
-
wire.put(g, err => {
|
|
355
|
-
if (err) {
|
|
356
|
-
;(cb as (err: string) => void)(
|
|
357
|
-
`error putting ${item} on ${soul}: ${err}`,
|
|
358
|
-
)
|
|
359
|
-
return
|
|
360
|
-
}
|
|
361
|
-
if (id) {
|
|
362
|
-
ctx.chain[i]!.soul = id
|
|
363
|
-
}
|
|
364
|
-
api(ctxid).put(request.put!, cb as never)
|
|
365
|
-
})
|
|
366
439
|
} else if (on) {
|
|
367
440
|
// Item is not a rel yet — watch the parent soul for when it
|
|
368
441
|
// becomes one, then retry chain resolution.
|
|
@@ -372,20 +445,13 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
|
|
|
372
445
|
if (cb) cb(null)
|
|
373
446
|
}
|
|
374
447
|
} else if (put) {
|
|
375
|
-
const id = utils.text.random()
|
|
376
448
|
if (!node) node = {} as never
|
|
377
|
-
|
|
378
|
-
const g = await graph(soul!, node as never, ctx.user, cb as never)
|
|
379
|
-
if (g === null) return
|
|
380
|
-
|
|
381
|
-
wire.put(g, err => {
|
|
449
|
+
createRel(soul!, item!, node as never, ctx.user, (err, relId) => {
|
|
382
450
|
if (err) {
|
|
383
|
-
;(cb as (err: string) => void)(
|
|
384
|
-
`error putting ${item} on ${soul}: ${err}`,
|
|
385
|
-
)
|
|
451
|
+
;(cb as (err: string) => void)(err)
|
|
386
452
|
return
|
|
387
453
|
}
|
|
388
|
-
ctx.chain[i]!.soul =
|
|
454
|
+
ctx.chain[i]!.soul = relId!
|
|
389
455
|
api(ctxid).put(request.put!, cb as never)
|
|
390
456
|
})
|
|
391
457
|
} else {
|
|
@@ -708,28 +774,20 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
|
|
|
708
774
|
const id = utils.rel.is(current as GraphValue)
|
|
709
775
|
if (!id) {
|
|
710
776
|
if (!node) node = {} as never
|
|
711
|
-
|
|
712
|
-
const g = await graph(
|
|
713
|
-
soul,
|
|
714
|
-
node as never,
|
|
715
|
-
ctx.user,
|
|
716
|
-
_ack as never,
|
|
717
|
-
)
|
|
718
|
-
if (g === null) return
|
|
719
|
-
|
|
720
|
-
wire.put(g, err => {
|
|
777
|
+
createRel(soul, item!, node as never, ctx.user, err => {
|
|
721
778
|
if (err) {
|
|
722
|
-
_ack(
|
|
723
|
-
|
|
724
|
-
const _ctxid = utils.text.random()
|
|
725
|
-
const chain: ChainItem[] = [{item: item!, soul: soul}]
|
|
726
|
-
allctx.set(_ctxid, {
|
|
727
|
-
chain: chain,
|
|
728
|
-
user: ctx.user,
|
|
729
|
-
cb: ctx.cb,
|
|
730
|
-
})
|
|
731
|
-
api(_ctxid).put(data)
|
|
779
|
+
_ack(err)
|
|
780
|
+
return
|
|
732
781
|
}
|
|
782
|
+
|
|
783
|
+
const _ctxid = utils.text.random()
|
|
784
|
+
const chain: ChainItem[] = [{item: item!, soul: soul}]
|
|
785
|
+
allctx.set(_ctxid, {
|
|
786
|
+
chain: chain,
|
|
787
|
+
user: ctx.user,
|
|
788
|
+
cb: ctx.cb,
|
|
789
|
+
})
|
|
790
|
+
api(_ctxid).put(data)
|
|
733
791
|
})
|
|
734
792
|
return
|
|
735
793
|
}
|
|
@@ -836,6 +894,23 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
|
|
|
836
894
|
|
|
837
895
|
const ctx = allctx.get(ctxid)
|
|
838
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
|
+
|
|
839
914
|
allctx.set(ctxid, {
|
|
840
915
|
chain: [{item: item, soul: soul}],
|
|
841
916
|
on: true,
|
|
@@ -995,7 +1070,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
|
|
|
995
1070
|
: options.secure,
|
|
996
1071
|
},
|
|
997
1072
|
)
|
|
998
|
-
},
|
|
1073
|
+
} as HolsterAPI["on"],
|
|
999
1074
|
|
|
1000
1075
|
off: function (cb?: (data: unknown) => void): HolsterAPI | void {
|
|
1001
1076
|
if (!ctxid) {
|
|
@@ -1004,6 +1079,12 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
|
|
|
1004
1079
|
return
|
|
1005
1080
|
}
|
|
1006
1081
|
|
|
1082
|
+
if (cb && nested.has(cb)) {
|
|
1083
|
+
nested.get(cb)!()
|
|
1084
|
+
nested.delete(cb)
|
|
1085
|
+
return
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1007
1088
|
const resolved = resolve({off: true}, cb)
|
|
1008
1089
|
if (!resolved) return
|
|
1009
1090
|
|
package/ts/src/nested.ts
ADDED
|
@@ -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/radisk.ts
CHANGED
|
@@ -229,10 +229,7 @@ const Radisk = (opt: RadiskOptions) => {
|
|
|
229
229
|
radisk.batch = Radix() as RadiskInterface["batch"]
|
|
230
230
|
radisk.batch.acks = []
|
|
231
231
|
radisk.batch.ed = 0
|
|
232
|
-
let i = 0
|
|
233
232
|
radisk.save(batch, err => {
|
|
234
|
-
if (++i > 1) return
|
|
235
|
-
|
|
236
233
|
if (err) options.log(err)
|
|
237
234
|
batch.acks.forEach(cb => cb(err))
|
|
238
235
|
radisk.thrash.at = undefined
|
|
@@ -246,26 +243,54 @@ const Radisk = (opt: RadiskOptions) => {
|
|
|
246
243
|
find: (_tree: unknown, key: string): boolean | undefined => {
|
|
247
244
|
if (key < (save.start || "")) return undefined
|
|
248
245
|
save.start = key
|
|
249
|
-
store.list
|
|
246
|
+
// store.list's own streamed order isn't guaranteed to be sorted -
|
|
247
|
+
// fs.readdir in particular makes no such promise, unlike
|
|
248
|
+
// radisk.read's own file lookup, which already sorts before
|
|
249
|
+
// searching. Collect the full list and sort it first, rather
|
|
250
|
+
// than scanning the raw stream as it arrives, since save.lex's
|
|
251
|
+
// own boundary search below only works correctly over genuinely
|
|
252
|
+
// sorted input - fed an unsorted stream, it can match the wrong
|
|
253
|
+
// file, or stop tracking new candidates the moment the stream
|
|
254
|
+
// stops looking monotonic, silently losing track of files that
|
|
255
|
+
// arrive afterward.
|
|
256
|
+
const files: string[] = []
|
|
257
|
+
store.list(file => {
|
|
258
|
+
if (file) {
|
|
259
|
+
files.push(file)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
files.sort()
|
|
263
|
+
save.lex(files)
|
|
264
|
+
})
|
|
250
265
|
return true
|
|
251
266
|
},
|
|
252
|
-
lex: (
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
267
|
+
lex: (files: string[]): void => {
|
|
268
|
+
// save.start can be set to undefined by a previous call to
|
|
269
|
+
// save.mix, so don't continue in this case.
|
|
270
|
+
if (!save.start) return
|
|
271
|
+
|
|
272
|
+
let candidate: string | null = null
|
|
273
|
+
for (const file of files) {
|
|
274
|
+
if (file > save.start) {
|
|
275
|
+
// ! is used as the first file name as it's the first
|
|
276
|
+
// printable character, so always matches as lexically less
|
|
277
|
+
// than any node.
|
|
278
|
+
save.mix(candidate || "!", save.start, file)
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
candidate = file
|
|
258
282
|
}
|
|
283
|
+
save.mix(candidate || "!", save.start, undefined)
|
|
259
284
|
},
|
|
260
285
|
mix: (file: string, start: string, end?: string): void => {
|
|
261
|
-
save.start =
|
|
286
|
+
save.start = undefined
|
|
262
287
|
if (cache.has(file)) {
|
|
263
288
|
const disk = cache.get(file)!
|
|
264
289
|
Radix.map(rad, (value, key) => {
|
|
265
290
|
if (key < start) return undefined
|
|
266
291
|
if (end && end < key) {
|
|
267
292
|
save.start = key
|
|
268
|
-
return
|
|
293
|
+
return true
|
|
269
294
|
}
|
|
270
295
|
disk(key, value)
|
|
271
296
|
return undefined
|
|
@@ -278,11 +303,21 @@ const Radisk = (opt: RadiskOptions) => {
|
|
|
278
303
|
if (key < start) return undefined
|
|
279
304
|
if (end && end < key) {
|
|
280
305
|
save.start = key
|
|
281
|
-
return
|
|
306
|
+
return true
|
|
282
307
|
}
|
|
283
308
|
disk!(key, value)
|
|
284
309
|
return undefined
|
|
285
310
|
})
|
|
311
|
+
// Populate the cache with this merge synchronously, before the
|
|
312
|
+
// async store.put below even starts - a write's own merged
|
|
313
|
+
// result is always authoritative (it's this batch's actual
|
|
314
|
+
// content), unlike a plain read's parse, which can't tell
|
|
315
|
+
// whether it raced ahead of a write. Closes the window where a
|
|
316
|
+
// concurrent read's own parse-from-disk (started before this
|
|
317
|
+
// write, resolving after it) would otherwise be free to
|
|
318
|
+
// populate the cache with stale, pre-write data - see
|
|
319
|
+
// radisk.read's own matching change below.
|
|
320
|
+
if (options.cache) cache.set(file, disk!)
|
|
286
321
|
radisk.write(file, disk!, save.next)
|
|
287
322
|
})
|
|
288
323
|
}
|
|
@@ -296,13 +331,26 @@ const Radisk = (opt: RadiskOptions) => {
|
|
|
296
331
|
cb(err)
|
|
297
332
|
},
|
|
298
333
|
start: undefined as string | undefined,
|
|
299
|
-
end: undefined as string | undefined,
|
|
300
|
-
file: undefined as string | undefined,
|
|
301
334
|
}
|
|
302
335
|
Radix.map(rad, save.find)
|
|
303
336
|
}
|
|
304
337
|
|
|
305
338
|
radisk.write = (file, rad, cb) => {
|
|
339
|
+
// A split produces two independent store.put calls - this file's own
|
|
340
|
+
// (truncated) remainder below, and the recursive call for the
|
|
341
|
+
// split-off sub-file. pending starts at 1 for this file's own write,
|
|
342
|
+
// and is bumped to 2 the moment a split is decided, so cb only fires
|
|
343
|
+
// once both halves are actually durable - calling it as soon as
|
|
344
|
+
// whichever one finishes first (the previous behavior) let a caller
|
|
345
|
+
// continue - including resolving the original put()'s own callback -
|
|
346
|
+
// before the other half's data had actually been written.
|
|
347
|
+
let pending = 1
|
|
348
|
+
let firstErr: string | null = null
|
|
349
|
+
const settle = (err?: string | null): void => {
|
|
350
|
+
if (err && !firstErr) firstErr = err
|
|
351
|
+
if (--pending > 0) return
|
|
352
|
+
cb(firstErr)
|
|
353
|
+
}
|
|
306
354
|
const write = {
|
|
307
355
|
text: "",
|
|
308
356
|
limit: "",
|
|
@@ -360,7 +408,8 @@ const Radisk = (opt: RadiskOptions) => {
|
|
|
360
408
|
write.done = true
|
|
361
409
|
write.sub = Radix()
|
|
362
410
|
Radix.map(rad, write.slice)
|
|
363
|
-
|
|
411
|
+
pending++
|
|
412
|
+
radisk.write(write.limit, write.sub, settle)
|
|
364
413
|
return undefined
|
|
365
414
|
}
|
|
366
415
|
}
|
|
@@ -380,7 +429,7 @@ const Radisk = (opt: RadiskOptions) => {
|
|
|
380
429
|
cache.delete(file)
|
|
381
430
|
fileListCache = null
|
|
382
431
|
}
|
|
383
|
-
|
|
432
|
+
settle(err)
|
|
384
433
|
})
|
|
385
434
|
}
|
|
386
435
|
|
|
@@ -421,7 +470,13 @@ const Radisk = (opt: RadiskOptions) => {
|
|
|
421
470
|
}
|
|
422
471
|
radisk.parse(file, (err, disk) => {
|
|
423
472
|
if (err) options.log(err)
|
|
424
|
-
|
|
473
|
+
// Never overwrite an existing cache entry from a plain read - it
|
|
474
|
+
// can't tell whether it raced ahead of a concurrent write (started
|
|
475
|
+
// before that write, but resolving after the write already
|
|
476
|
+
// installed its own authoritative, freshly-merged entry - see
|
|
477
|
+
// save.mix's own matching change). Only populate the cache when
|
|
478
|
+
// nothing beat this read to it.
|
|
479
|
+
if (disk && options.cache && !cache.has(file)) {
|
|
425
480
|
cache.set(file, disk)
|
|
426
481
|
checkMemoryUsage()
|
|
427
482
|
}
|