@mblaney/holster 2.1.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mblaney/holster",
3
- "version": "2.1.3",
3
+ "version": "2.1.4",
4
4
  "description": "Holster is a service that allows synchronising data between devices using Node, Deno, Bun or the browser.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/holster.js CHANGED
@@ -14,6 +14,11 @@ const Holster = opt => {
14
14
  const map = new Map()
15
15
  // Allow concurrent calls to the api by storing each context.
16
16
  const allctx = new Map()
17
+ // Serializes concurrent creation of a missing rel for the same
18
+ // soul+item, so only the first caller creates it and the rest reuse
19
+ // its result rather than each minting a competing soul. Map<soul,
20
+ // Map<item, promise resolving to the soul id>>.
21
+ const pendingRel = new Map()
17
22
 
18
23
  const ok = data => {
19
24
  return (
@@ -181,6 +186,65 @@ const Holster = opt => {
181
186
  }
182
187
  }
183
188
 
189
+ // Creates a rel for item on soul, or reuses one already being created
190
+ // by a concurrent caller for the same soul+item (see pendingRel above).
191
+ // Calls cb(err, id) with a ready-to-display err, already prefixed where
192
+ // needed - callers should pass it straight through, not wrap it again.
193
+ const createRel = (soul, item, node, user, cb) => {
194
+ let bySoul = pendingRel.get(soul)
195
+ const pending = bySoul && bySoul.get(item)
196
+ if (pending) {
197
+ pending.then(
198
+ id => cb(null, id),
199
+ err => cb(err),
200
+ )
201
+ return
202
+ }
203
+
204
+ const id = utils.text.random()
205
+ node[item] = utils.rel.ify(id)
206
+ let settle, fail
207
+ const promise = new Promise((res, rej) => {
208
+ settle = res
209
+ fail = rej
210
+ })
211
+ promise.catch(() => {})
212
+ if (!bySoul) {
213
+ bySoul = new Map()
214
+ pendingRel.set(soul, bySoul)
215
+ }
216
+ bySoul.set(item, promise)
217
+ const clear = () => {
218
+ bySoul.delete(item)
219
+ if (bySoul.size === 0) pendingRel.delete(soul)
220
+ }
221
+
222
+ ;(async () => {
223
+ // graph() itself calls cb with an already-formatted message when it
224
+ // fails (secure mode, no user) - pass cb straight through rather
225
+ // than wrapping, so that message isn't prefixed a second time.
226
+ const g = await graph(soul, node, user, cb)
227
+ if (g === null) {
228
+ clear()
229
+ fail(new Error("secure mode"))
230
+ return
231
+ }
232
+
233
+ wire.put(g, err => {
234
+ clear()
235
+ if (err) {
236
+ const message = `error putting ${item} on ${soul}: ${err}`
237
+ fail(message)
238
+ cb(message)
239
+ return
240
+ }
241
+
242
+ settle(id)
243
+ cb(null, id)
244
+ })
245
+ })()
246
+ }
247
+
184
248
  const resolve = (request, cb) => {
185
249
  if (!request) {
186
250
  console.log("error resolve request parameter required")
@@ -236,18 +300,13 @@ const Holster = opt => {
236
300
  cb(node[item])
237
301
  } else if (put) {
238
302
  // Request was chained before put, so rel doesn't exist yet.
239
- id = utils.text.random()
240
- node[item] = utils.rel.ify(id)
241
- const g = await graph(soul, node, ctx.user, cb)
242
- if (g === null) return
243
-
244
- wire.put(g, err => {
303
+ createRel(soul, item, node, ctx.user, (err, relId) => {
245
304
  if (err) {
246
- cb(`error putting ${item} on ${soul}: ${err}`)
305
+ cb(err)
247
306
  return
248
307
  }
249
308
 
250
- ctx.chain[i].soul = id
309
+ ctx.chain[i].soul = relId
251
310
  api(ctxid).put(request.put, cb)
252
311
  })
253
312
  } else if (on) {
@@ -261,19 +320,13 @@ const Holster = opt => {
261
320
  }
262
321
  } else if (put) {
263
322
  // Request was chained before put, so rel doesn't exist yet.
264
- const id = utils.text.random()
265
- if (!node) node = {}
266
- node[item] = utils.rel.ify(id)
267
- const g = await graph(soul, node, ctx.user, cb)
268
- if (g === null) return
269
-
270
- wire.put(g, err => {
323
+ createRel(soul, item, node || {}, ctx.user, (err, relId) => {
271
324
  if (err) {
272
- cb(`error putting ${item} on ${soul}: ${err}`)
325
+ cb(err)
273
326
  return
274
327
  }
275
328
 
276
- ctx.chain[i].soul = id
329
+ ctx.chain[i].soul = relId
277
330
  api(ctxid).put(request.put, cb)
278
331
  })
279
332
  } else {
@@ -541,25 +594,21 @@ const Holster = opt => {
541
594
  const id = utils.rel.is(current)
542
595
  if (!id) {
543
596
  // The current rel doesn't exist, so add it first.
544
- if (!node) node = {}
545
- node[item] = utils.rel.ify(utils.text.random())
546
- const g = await graph(soul, node, ctx.user, _ack)
547
- if (g === null) return
548
-
549
- wire.put(g, err => {
597
+ createRel(soul, item, node || {}, ctx.user, err => {
550
598
  if (err) {
551
- _ack(`error putting ${item} on ${soul}: ${err}`)
552
- } else {
553
- const _ctxid = utils.text.random()
554
- const chain = [{item: item, soul: soul}]
555
- // Pass on the previous ctx's callback and user flag here.
556
- allctx.set(_ctxid, {
557
- chain: chain,
558
- user: ctx.user,
559
- cb: ctx.cb,
560
- })
561
- api(_ctxid).put(data)
599
+ _ack(err)
600
+ return
562
601
  }
602
+
603
+ const _ctxid = utils.text.random()
604
+ const chain = [{item: item, soul: soul}]
605
+ // Pass on the previous ctx's callback and user flag here.
606
+ allctx.set(_ctxid, {
607
+ chain: chain,
608
+ user: ctx.user,
609
+ cb: ctx.cb,
610
+ })
611
+ api(_ctxid).put(data)
563
612
  })
564
613
  return
565
614
  }
package/src/radisk.js CHANGED
@@ -171,13 +171,7 @@ const Radisk = opt => {
171
171
  radisk.batch = Radix()
172
172
  radisk.batch.acks = []
173
173
  radisk.batch.ed = 0
174
- let i = 0
175
174
  radisk.save(batch, err => {
176
- // This is to ignore multiple callbacks from radisk.save calling
177
- // radisk.write? It looks like multiple callbacks will be made if a
178
- // file needs to be split.
179
- if (++i > 1) return
180
-
181
175
  if (err) opt.log(err)
182
176
  batch.acks.forEach(cb => cb(err))
183
177
  radisk.thrash.at = null
@@ -199,23 +193,47 @@ const Radisk = opt => {
199
193
  if (key < save.start) return
200
194
 
201
195
  save.start = key
202
- opt.store.list(save.lex)
196
+ // store.list's own streamed order isn't guaranteed to be sorted -
197
+ // fs.readdir in particular makes no such promise, unlike
198
+ // radisk.read's own file lookup, which already sorts before
199
+ // searching. Collect the full list and sort it first, rather
200
+ // than scanning the raw stream as it arrives, since save.lex's
201
+ // own boundary search below only works correctly over genuinely
202
+ // sorted input - fed an unsorted stream, it can match the wrong
203
+ // file, or stop tracking new candidates the moment the stream
204
+ // stops looking monotonic, silently losing track of files that
205
+ // arrive afterward.
206
+ const files = []
207
+ opt.store.list(file => {
208
+ if (file) {
209
+ files.push(file)
210
+ return
211
+ }
212
+ files.sort()
213
+ save.lex(files)
214
+ })
203
215
  return true
204
216
  },
205
- lex: file => {
206
- if (!file || file > save.start) {
207
- save.end = file
208
- // ! is used as the first file name as it's the first printable
209
- // character, so always matches as lexically less than any node.
210
- // Also save.start can be set to undefined by a previous call to
211
- // save.mix, so don't continue in this case.
212
- if (save.start) save.mix(save.file || "!", save.start, save.end)
213
- } else {
214
- save.file = file
217
+ lex: files => {
218
+ // save.start can be set to undefined by a previous call to
219
+ // save.mix, so don't continue in this case.
220
+ if (!save.start) return
221
+
222
+ let candidate = null
223
+ for (const file of files) {
224
+ if (file > save.start) {
225
+ // ! is used as the first file name as it's the first
226
+ // printable character, so always matches as lexically less
227
+ // than any node.
228
+ save.mix(candidate || "!", save.start, file)
229
+ return
230
+ }
231
+ candidate = file
215
232
  }
233
+ save.mix(candidate || "!", save.start, u)
216
234
  },
217
235
  mix: (file, start, end) => {
218
- save.start = save.end = save.file = u
236
+ save.start = u
219
237
  // Use cache if available, otherwise parse from disk
220
238
  if (cache.has(file)) {
221
239
  const disk = cache.get(file)
@@ -223,8 +241,17 @@ const Radisk = opt => {
223
241
  if (key < start) return
224
242
 
225
243
  if (end && end < key) {
244
+ // Found where the next file segment starts - stop here (the
245
+ // non-undefined return is what actually stops Radix.map's
246
+ // own iteration). Without it, the scan kept going through
247
+ // every remaining entry in the whole batch, each one
248
+ // overwriting save.start in turn, so save.next's own resume
249
+ // point ended up as the LAST key in the batch rather than
250
+ // the very next one - silently skipping every entry in
251
+ // between as "already processed" when none of them ever
252
+ // were.
226
253
  save.start = key
227
- return
254
+ return true
228
255
  }
229
256
 
230
257
  disk(key, value)
@@ -239,11 +266,21 @@ const Radisk = opt => {
239
266
 
240
267
  if (end && end < key) {
241
268
  save.start = key
242
- return
269
+ return true
243
270
  }
244
271
 
245
272
  disk(key, value)
246
273
  })
274
+ // Populate the cache with this merge synchronously, before the
275
+ // async store.put below even starts - a write's own merged
276
+ // result is always authoritative (it's this batch's actual
277
+ // content), unlike a plain read's parse, which can't tell
278
+ // whether it raced ahead of a write. Closes the window where a
279
+ // concurrent read's own parse-from-disk (started before this
280
+ // write, resolving after it) would otherwise be free to
281
+ // populate the cache with stale, pre-write data - see
282
+ // radisk.read's own matching change below.
283
+ if (opt.cache) cache.set(file, disk)
247
284
  radisk.write(file, disk, save.next)
248
285
  })
249
286
  }
@@ -260,6 +297,21 @@ const Radisk = opt => {
260
297
  }
261
298
 
262
299
  radisk.write = (file, rad, cb) => {
300
+ // A split produces two independent opt.store.put calls - this file's
301
+ // own (truncated) remainder below, and the recursive call for the
302
+ // split-off sub-file. pending starts at 1 for this file's own write,
303
+ // and is bumped to 2 the moment a split is decided, so cb only fires
304
+ // once both halves are actually durable - calling it as soon as
305
+ // whichever one finishes first (the previous behavior) let a caller
306
+ // continue - including resolving the original put()'s own callback -
307
+ // before the other half's data had actually been written.
308
+ let pending = 1
309
+ let firstErr = null
310
+ const settle = err => {
311
+ if (err && !firstErr) firstErr = err
312
+ if (--pending > 0) return
313
+ cb(firstErr)
314
+ }
263
315
  const write = {
264
316
  text: "",
265
317
  limit: "",
@@ -318,7 +370,8 @@ const Radisk = opt => {
318
370
  write.done = true
319
371
  write.sub = Radix()
320
372
  Radix.map(rad, write.slice)
321
- radisk.write(write.limit, write.sub, cb)
373
+ pending++
374
+ radisk.write(write.limit, write.sub, settle)
322
375
  return
323
376
  }
324
377
  }
@@ -342,7 +395,7 @@ const Radisk = opt => {
342
395
  cache.delete(file)
343
396
  fileListCache = null
344
397
  }
345
- cb(err)
398
+ settle(err)
346
399
  })
347
400
  }
348
401
 
@@ -378,7 +431,13 @@ const Radisk = opt => {
378
431
  }
379
432
  radisk.parse(file, (err, disk) => {
380
433
  if (err) opt.log(err)
381
- if (disk && opt.cache) {
434
+ // Never overwrite an existing cache entry from a plain read - it
435
+ // can't tell whether it raced ahead of a concurrent write (started
436
+ // before that write, but resolving after the write already
437
+ // installed its own authoritative, freshly-merged entry - see
438
+ // save.mix's own matching change). Only populate the cache when
439
+ // nothing beat this read to it.
440
+ if (disk && opt.cache && !cache.has(file)) {
382
441
  cache.set(file, disk)
383
442
  checkMemoryUsage()
384
443
  }
package/src/radix.js CHANGED
@@ -26,38 +26,68 @@ const Radix = () => {
26
26
  }
27
27
 
28
28
  if (!found) {
29
- // If not found from the provided keys try matching with an existing key.
30
- const result = utils.obj.map(tree, (hasValue, hasKey) => {
31
- let j = 0
32
- let matchingKey = ""
33
- while (hasKey[j] === keys[j]) {
34
- matchingKey += hasKey[j++]
35
- }
36
- if (matchingKey) {
37
- if (noValue) {
38
- // matchingKey has to be as long as the original keys when reading.
29
+ if (noValue) {
30
+ // If not found from the provided keys try matching with an existing
31
+ // key.
32
+ const result = utils.obj.map(tree, (hasValue, hasKey) => {
33
+ let j = 0
34
+ let matchingKey = ""
35
+ while (hasKey[j] === keys[j]) {
36
+ matchingKey += hasKey[j++]
37
+ }
38
+ if (matchingKey) {
39
+ // matchingKey has to be as long as the original keys when
40
+ // reading.
39
41
  if (j <= max) return
40
42
 
41
43
  tmp[hasKey.slice(j)] = hasValue
42
44
  return hasValue
43
45
  }
46
+ })
47
+ if (!result) return
44
48
 
45
- let replace = {
46
- [hasKey.slice(j)]: hasValue,
47
- [keys.slice(j)]: {[record]: value},
48
- }
49
- tree[matchingKey] = {[group]: replace}
50
- delete tree[hasKey]
51
- return true
49
+ return tmp
50
+ }
51
+
52
+ // Writing: find the EXISTING key with the longest shared prefix,
53
+ // not just the first one with any overlap at all (obj.map stops at
54
+ // the first non-undefined result, so a plain find here would settle
55
+ // for whichever candidate happens to come first in Object.keys'
56
+ // own iteration order). A radix tree's own invariant - no two
57
+ // sibling keys share a prefix - only holds if every insertion
58
+ // merges with its one true match; with many keys sharing this tree
59
+ // at once (many concurrent writers batched together), a
60
+ // coincidentally shorter, unrelated key can otherwise "win" over
61
+ // the real, much longer match - silently misplacing the new key
62
+ // under the wrong group, while its actual sibling is never touched
63
+ // at all and stays wherever it already was, invisible to any read
64
+ // that expects it inside this new group.
65
+ let bestKey = null
66
+ let bestMatch = ""
67
+ for (const hasKey of Object.keys(tree)) {
68
+ let j = 0
69
+ let matchingKey = ""
70
+ while (hasKey[j] === keys[j]) {
71
+ matchingKey += hasKey[j++]
52
72
  }
53
- })
54
- if (!result) {
55
- if (noValue) return
73
+ if (matchingKey.length > bestMatch.length) {
74
+ bestKey = hasKey
75
+ bestMatch = matchingKey
76
+ }
77
+ }
56
78
 
79
+ if (bestKey) {
80
+ const hasValue = tree[bestKey]
81
+ const j = bestMatch.length
82
+ let replace = {
83
+ [bestKey.slice(j)]: hasValue,
84
+ [keys.slice(j)]: {[record]: value},
85
+ }
86
+ tree[bestMatch] = {[group]: replace}
87
+ delete tree[bestKey]
88
+ } else {
57
89
  if (!tree[key]) tree[key] = {}
58
90
  tree[key][record] = value
59
- } else if (noValue) {
60
- return tmp
61
91
  }
62
92
  } else if (i === max) {
63
93
  // If no value use the key provided to return a whole group or record.
package/src/store.js CHANGED
@@ -297,4 +297,5 @@ const Store = opt => {
297
297
  }
298
298
  }
299
299
 
300
+ export {fileSystem}
300
301
  export default Store
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(
@@ -636,6 +648,10 @@ const Wire = opt => {
636
648
  }
637
649
  }
638
650
  }
651
+ if (!(await check(data, send, cb))) {
652
+ return
653
+ }
654
+
639
655
  const update = await Ham.mix(
640
656
  data,
641
657
  graph,
@@ -650,8 +666,15 @@ const Wire = opt => {
650
666
  return
651
667
  }
652
668
 
653
- if (!(await check(update.now, send, cb))) {
654
- return
669
+ // Ham.mix has already merged this locally-authored update into
670
+ // graph[soul], synchronously, before store.put below even starts
671
+ // persisting it - mark it as fully known now rather than waiting
672
+ // for a future disk fetch to do so, so a read landing in that
673
+ // window doesn't get forced through a disk round-trip that could
674
+ // itself race ahead of this same write and find nothing, even
675
+ // though the correct answer was already sitting in memory.
676
+ for (const soul of Object.keys(update.now)) {
677
+ hasNode.add(soul)
655
678
  }
656
679
 
657
680
  if (opt.userLimit) {