@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/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, ".": key}, res, send, {put: true})
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
- if (!msg.put || !msg.put[soul] || msg.put[soul][key] === node[key]) {
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
- if ((userStorage.get(pub) ?? 0) >= limit) continue
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
- userStorage.set(
448
- pub,
449
- Math.max(0, (userStorage.get(pub) ?? 0) - oldBytes + newBytes),
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
- if (!(await check(update.now, send, cb))) {
654
- return
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
- userStorage.set(pub, Math.max(0, current - oldBytes + newBytes))
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
- lex: LexWithDot | ((data: unknown) => void),
60
- cb?: ((data: unknown) => void) | boolean,
61
- _get?: boolean,
62
- _opt?: WireOptions,
63
- ) => void
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
- id = utils.text.random()
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
- cb as never,
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
- node[item!] = utils.rel.ify(id)
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 = id
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
- node[item!] = utils.rel.ify(utils.text.random())
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(`error putting ${item} on ${soul}: ${err}`)
723
- } else {
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
 
@@ -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(save.lex)
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: (file?: string): void => {
253
- if (!file || file > (save.start || "")) {
254
- save.end = file
255
- if (save.start) save.mix(save.file || "!", save.start, save.end)
256
- } else {
257
- save.file = file
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 = save.end = save.file = undefined
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 undefined
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 undefined
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
- radisk.write(write.limit, write.sub, cb)
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
- cb(err)
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
- if (disk && options.cache) {
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
  }