@mblaney/holster 2.1.2 → 2.1.4

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/holster.ts CHANGED
@@ -78,6 +78,11 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
78
78
  const user = User(options, wire as never)
79
79
  const map = new Map<(data: unknown) => void, () => void>()
80
80
  const allctx = new Map<string, ApiContext>()
81
+ // Serializes concurrent creation of a missing rel for the same
82
+ // soul+item, so only the first caller creates it and the rest reuse
83
+ // its result rather than each minting a competing soul. Map<soul,
84
+ // Map<item, promise resolving to the soul id>>.
85
+ const pendingRel = new Map<string, Map<string, Promise<string>>>()
81
86
 
82
87
  const ok = (data: GraphValue): boolean => {
83
88
  return (
@@ -275,6 +280,72 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
275
280
  }
276
281
  }
277
282
 
283
+ // Creates a rel for item on soul, or reuses one already being created
284
+ // by a concurrent caller for the same soul+item (see pendingRel above).
285
+ // Calls cb(err, id) with a ready-to-display err, already prefixed where
286
+ // needed - callers should pass it straight through, not wrap it again.
287
+ const createRel = (
288
+ soul: string,
289
+ item: string,
290
+ node: Record<string, GraphValue>,
291
+ user: UserIdentity | null | undefined,
292
+ cb: (err?: string | null, id?: string) => void,
293
+ ): void => {
294
+ let bySoul = pendingRel.get(soul)
295
+ const pending = bySoul && bySoul.get(item)
296
+ if (pending) {
297
+ pending.then(
298
+ id => cb(undefined, id),
299
+ err => cb(err as string),
300
+ )
301
+ return
302
+ }
303
+
304
+ const id = utils.text.random()
305
+ node[item] = utils.rel.ify(id)
306
+ let settle!: (id: string) => void
307
+ let fail!: (err: unknown) => void
308
+ const promise = new Promise<string>((res, rej) => {
309
+ settle = res
310
+ fail = rej
311
+ })
312
+ promise.catch(() => {})
313
+ if (!bySoul) {
314
+ bySoul = new Map()
315
+ pendingRel.set(soul, bySoul)
316
+ }
317
+ bySoul.set(item, promise)
318
+ const clear = () => {
319
+ bySoul!.delete(item)
320
+ if (bySoul!.size === 0) pendingRel.delete(soul)
321
+ }
322
+
323
+ void (async () => {
324
+ // graph() itself calls cb with an already-formatted message when it
325
+ // fails (secure mode, no user) - pass cb straight through rather
326
+ // than wrapping, so that message isn't prefixed a second time.
327
+ const g = await graph(soul, node, user, cb)
328
+ if (g === null) {
329
+ clear()
330
+ fail(new Error("secure mode"))
331
+ return
332
+ }
333
+
334
+ wire.put(g, err => {
335
+ clear()
336
+ if (err) {
337
+ const message = `error putting ${item} on ${soul}: ${err}`
338
+ fail(message)
339
+ cb(message)
340
+ return
341
+ }
342
+
343
+ settle(id)
344
+ cb(undefined, id)
345
+ })
346
+ })()
347
+ }
348
+
278
349
  const resolve = (
279
350
  request: {
280
351
  get?: LexFilter
@@ -341,28 +412,20 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
341
412
  } else if (get) {
342
413
  cb!(node[item!])
343
414
  } else if (put) {
344
- id = utils.text.random()
345
- node[item!] = utils.rel.ify(id)
346
- const g = await graph(
415
+ createRel(
347
416
  soul!,
417
+ item!,
348
418
  node as never,
349
419
  ctx.user,
350
- cb as never,
420
+ (err, relId) => {
421
+ if (err) {
422
+ ;(cb as (err: string) => void)(err)
423
+ return
424
+ }
425
+ ctx.chain[i]!.soul = relId!
426
+ api(ctxid).put(request.put!, cb as never)
427
+ },
351
428
  )
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
429
  } else if (on) {
367
430
  // Item is not a rel yet — watch the parent soul for when it
368
431
  // becomes one, then retry chain resolution.
@@ -372,20 +435,13 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
372
435
  if (cb) cb(null)
373
436
  }
374
437
  } else if (put) {
375
- const id = utils.text.random()
376
438
  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 => {
439
+ createRel(soul!, item!, node as never, ctx.user, (err, relId) => {
382
440
  if (err) {
383
- ;(cb as (err: string) => void)(
384
- `error putting ${item} on ${soul}: ${err}`,
385
- )
441
+ ;(cb as (err: string) => void)(err)
386
442
  return
387
443
  }
388
- ctx.chain[i]!.soul = id
444
+ ctx.chain[i]!.soul = relId!
389
445
  api(ctxid).put(request.put!, cb as never)
390
446
  })
391
447
  } else {
@@ -708,28 +764,20 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
708
764
  const id = utils.rel.is(current as GraphValue)
709
765
  if (!id) {
710
766
  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 => {
767
+ createRel(soul, item!, node as never, ctx.user, err => {
721
768
  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)
769
+ _ack(err)
770
+ return
732
771
  }
772
+
773
+ const _ctxid = utils.text.random()
774
+ const chain: ChainItem[] = [{item: item!, soul: soul}]
775
+ allctx.set(_ctxid, {
776
+ chain: chain,
777
+ user: ctx.user,
778
+ cb: ctx.cb,
779
+ })
780
+ api(_ctxid).put(data)
733
781
  })
734
782
  return
735
783
  }
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
  }
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/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
@@ -367,11 +367,17 @@ const Wire = (opt: HolsterOptions): WireAPI => {
367
367
  console.log(" Top 5 by storage used:")
368
368
  sorted.slice(0, 5).forEach(([pub]) => logUser(pub))
369
369
  }
370
- console.log(
371
- " Run `npx holster-user-storage` to view full keys and all users",
372
- )
370
+ console.log("Run npx holster-user-storage to view full keys and all users")
373
371
  }
374
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.
375
381
  const check = async (
376
382
  data: Graph,
377
383
  send: (msg: string) => {err?: string} | void,
@@ -385,8 +391,12 @@ const Wire = (opt: HolsterOptions): WireAPI => {
385
391
  // allow it - the update is not trying to change the public key.
386
392
  if (node[key] === undefined) continue
387
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.
388
398
  const msg = await new Promise<WireMessage>(res => {
389
- getWithCallback({"#": soul, ".": key}, res, send, {put: true})
399
+ getWithCallback({"#": soul}, res, send, {put: true})
390
400
  })
391
401
  if (msg.err) {
392
402
  if (cb) cb(msg.err)
@@ -397,7 +407,12 @@ const Wire = (opt: HolsterOptions): WireAPI => {
397
407
  // matching public keys, as the provided soul also needs a rel on the
398
408
  // parent node which then also requires checking. Otherwise public keys
399
409
  // need to match for existing data.
400
- 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
+ ) {
401
416
  continue
402
417
  }
403
418
 
@@ -451,6 +466,8 @@ const Wire = (opt: HolsterOptions): WireAPI => {
451
466
  }
452
467
  }
453
468
 
469
+ if (!(await check(msg.put, send as never))) return
470
+
454
471
  const update = await Ham.mix(
455
472
  msg.put,
456
473
  graph,
@@ -469,8 +486,6 @@ const Wire = (opt: HolsterOptions): WireAPI => {
469
486
  return
470
487
  }
471
488
 
472
- if (!(await check(update.now, send as never))) return
473
-
474
489
  const finish = (err?: string | null): void => {
475
490
  if (err) console.warn("store.put", err)
476
491
  send(
@@ -681,6 +696,10 @@ const Wire = (opt: HolsterOptions): WireAPI => {
681
696
  }
682
697
  }
683
698
  }
699
+ if (!(await check(data, send as never, cb))) {
700
+ return
701
+ }
702
+
684
703
  const update = await Ham.mix(
685
704
  data,
686
705
  graph,
@@ -694,8 +713,15 @@ const Wire = (opt: HolsterOptions): WireAPI => {
694
713
  return
695
714
  }
696
715
 
697
- if (!(await check(update.now, send as never, cb))) {
698
- return
716
+ // Ham.mix has already merged this locally-authored update into
717
+ // graph[soul], synchronously, before store.put below even starts
718
+ // persisting it - mark it as fully known now rather than waiting
719
+ // for a future disk fetch to do so, so a read landing in that
720
+ // window doesn't get forced through a disk round-trip that could
721
+ // itself race ahead of this same write and find nothing, even
722
+ // though the correct answer was already sitting in memory.
723
+ for (const soul of Object.keys(update.now)) {
724
+ hasNode.add(soul)
699
725
  }
700
726
 
701
727
  if (options.userLimit) {
File without changes