@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/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@mblaney/holster",
3
- "version": "2.1.3",
3
+ "version": "2.2.0",
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",
7
7
  "scripts": {
8
- "test": "node --test",
8
+ "test": "node --test test/*.test.js test/system/*.test.js",
9
9
  "test:ts": "node --test --import tsx/esm ts/test/*.test.ts ts/test/system/*.test.ts",
10
10
  "build": "esbuild src/holster.js --bundle --format=esm --sourcemap \"--external:node:*\" \"--external:ws\" --minify --outdir=build",
11
11
  "build:ts": "esbuild ts/src/holster.ts --bundle --format=esm --sourcemap \"--external:node:*\" \"--external:ws\" --minify --outdir=dist",
package/src/holster.js CHANGED
@@ -2,6 +2,7 @@ import * as utils from "./utils.js"
2
2
  import Wire from "./wire.js"
3
3
  import User from "./user.js"
4
4
  import SEA from "./sea.js"
5
+ import {attachNested} from "./nested.js"
5
6
 
6
7
  const Holster = opt => {
7
8
  if (typeof opt === "string") opt = {peers: [opt]}
@@ -14,6 +15,17 @@ const Holster = opt => {
14
15
  const map = new Map()
15
16
  // Allow concurrent calls to the api by storing each context.
16
17
  const allctx = new Map()
18
+ // Tracks on(..., {nested: true}) subscriptions, keyed by the caller's
19
+ // own callback (the same reference off() will be called with) - see
20
+ // attachNested in nested.js. The actual top-level/child listeners it
21
+ // creates use their own, separate contexts, so off() just needs to
22
+ // call the detach function this stores to tear the whole thing down.
23
+ const nested = new Map()
24
+ // Serializes concurrent creation of a missing rel for the same
25
+ // soul+item, so only the first caller creates it and the rest reuse
26
+ // its result rather than each minting a competing soul. Map<soul,
27
+ // Map<item, promise resolving to the soul id>>.
28
+ const pendingRel = new Map()
17
29
 
18
30
  const ok = data => {
19
31
  return (
@@ -181,6 +193,65 @@ const Holster = opt => {
181
193
  }
182
194
  }
183
195
 
196
+ // Creates a rel for item on soul, or reuses one already being created
197
+ // by a concurrent caller for the same soul+item (see pendingRel above).
198
+ // Calls cb(err, id) with a ready-to-display err, already prefixed where
199
+ // needed - callers should pass it straight through, not wrap it again.
200
+ const createRel = (soul, item, node, user, cb) => {
201
+ let bySoul = pendingRel.get(soul)
202
+ const pending = bySoul && bySoul.get(item)
203
+ if (pending) {
204
+ pending.then(
205
+ id => cb(null, id),
206
+ err => cb(err),
207
+ )
208
+ return
209
+ }
210
+
211
+ const id = utils.text.random()
212
+ node[item] = utils.rel.ify(id)
213
+ let settle, fail
214
+ const promise = new Promise((res, rej) => {
215
+ settle = res
216
+ fail = rej
217
+ })
218
+ promise.catch(() => {})
219
+ if (!bySoul) {
220
+ bySoul = new Map()
221
+ pendingRel.set(soul, bySoul)
222
+ }
223
+ bySoul.set(item, promise)
224
+ const clear = () => {
225
+ bySoul.delete(item)
226
+ if (bySoul.size === 0) pendingRel.delete(soul)
227
+ }
228
+
229
+ ;(async () => {
230
+ // graph() itself calls cb with an already-formatted message when it
231
+ // fails (secure mode, no user) - pass cb straight through rather
232
+ // than wrapping, so that message isn't prefixed a second time.
233
+ const g = await graph(soul, node, user, cb)
234
+ if (g === null) {
235
+ clear()
236
+ fail(new Error("secure mode"))
237
+ return
238
+ }
239
+
240
+ wire.put(g, err => {
241
+ clear()
242
+ if (err) {
243
+ const message = `error putting ${item} on ${soul}: ${err}`
244
+ fail(message)
245
+ cb(message)
246
+ return
247
+ }
248
+
249
+ settle(id)
250
+ cb(null, id)
251
+ })
252
+ })()
253
+ }
254
+
184
255
  const resolve = (request, cb) => {
185
256
  if (!request) {
186
257
  console.log("error resolve request parameter required")
@@ -236,18 +307,13 @@ const Holster = opt => {
236
307
  cb(node[item])
237
308
  } else if (put) {
238
309
  // 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 => {
310
+ createRel(soul, item, node, ctx.user, (err, relId) => {
245
311
  if (err) {
246
- cb(`error putting ${item} on ${soul}: ${err}`)
312
+ cb(err)
247
313
  return
248
314
  }
249
315
 
250
- ctx.chain[i].soul = id
316
+ ctx.chain[i].soul = relId
251
317
  api(ctxid).put(request.put, cb)
252
318
  })
253
319
  } else if (on) {
@@ -261,19 +327,13 @@ const Holster = opt => {
261
327
  }
262
328
  } else if (put) {
263
329
  // 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 => {
330
+ createRel(soul, item, node || {}, ctx.user, (err, relId) => {
271
331
  if (err) {
272
- cb(`error putting ${item} on ${soul}: ${err}`)
332
+ cb(err)
273
333
  return
274
334
  }
275
335
 
276
- ctx.chain[i].soul = id
336
+ ctx.chain[i].soul = relId
277
337
  api(ctxid).put(request.put, cb)
278
338
  })
279
339
  } else {
@@ -541,25 +601,21 @@ const Holster = opt => {
541
601
  const id = utils.rel.is(current)
542
602
  if (!id) {
543
603
  // 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 => {
604
+ createRel(soul, item, node || {}, ctx.user, err => {
550
605
  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)
606
+ _ack(err)
607
+ return
562
608
  }
609
+
610
+ const _ctxid = utils.text.random()
611
+ const chain = [{item: item, soul: soul}]
612
+ // Pass on the previous ctx's callback and user flag here.
613
+ allctx.set(_ctxid, {
614
+ chain: chain,
615
+ user: ctx.user,
616
+ cb: ctx.cb,
617
+ })
618
+ api(_ctxid).put(data)
563
619
  })
564
620
  return
565
621
  }
@@ -631,6 +687,23 @@ const Holster = opt => {
631
687
  // Get the context to check if it has user info
632
688
  const ctx = allctx.get(ctxid)
633
689
 
690
+ if (_opt && _opt.nested) {
691
+ // attachNested calls this once for the top level and again for
692
+ // every discovered child, each needing its own independent
693
+ // context - a single shared _ctxid would accumulate .next()
694
+ // calls onto the same path instead of giving sibling paths.
695
+ const factory = () => {
696
+ const _ctxid = utils.text.random()
697
+ allctx.set(_ctxid, {
698
+ chain: [{item: item, soul: soul}],
699
+ user: ctx ? ctx.user : null,
700
+ })
701
+ return api(_ctxid)
702
+ }
703
+ nested.set(cb, attachNested(factory, cb, _get, _opt))
704
+ return
705
+ }
706
+
634
707
  // Flag that this context is set from on and shouldn't be removed.
635
708
  allctx.set(ctxid, {
636
709
  chain: [{item: item, soul: soul}],
@@ -788,6 +861,12 @@ const Holster = opt => {
788
861
  return
789
862
  }
790
863
 
864
+ if (nested.has(cb)) {
865
+ nested.get(cb)()
866
+ nested.delete(cb)
867
+ return
868
+ }
869
+
791
870
  // Resolve the current context before removing event listener.
792
871
  const {item, soul} = resolve({off: true}, cb)
793
872
  if (!soul) return
package/src/nested.js ADDED
@@ -0,0 +1,89 @@
1
+ // Backs on(cb, _get, {nested: true}) - see holster.js. A plain on() only
2
+ // resolves the node it's given; it never re-checks a child whose own
3
+ // value is an object (which always has its own soul, since only scalars
4
+ // live directly on a node) once that child's content changes in place -
5
+ // nothing on the parent's own soul changes when that happens, so a
6
+ // listener on the parent alone never sees it. get()'s own read-resolution
7
+ // (holster.js) already recursively resolves every such child into plain
8
+ // data for a one-time read - this doesn't repeat that; it adds a live
9
+ // subscription for each child that resolution already exposed, so a
10
+ // later change to it reaches the caller too.
11
+ //
12
+ // Pure and holster-internals-free: chain is a factory - () =>
13
+ // user.get([host, "shared"]).next("domains").next(code) - not a
14
+ // pre-built chained object, since a fresh, independent instance is
15
+ // needed per discovered child (reusing one chained object across
16
+ // multiple .next() calls would push them onto the same path instead of
17
+ // giving sibling paths).
18
+ export function attachNested(chain, cb, _get, _opt) {
19
+ // Stripped before being used on any .on() call this function makes
20
+ // itself (top-level or child) - otherwise a caller-provided {nested:
21
+ // true} would re-trigger holster.js's own nested check on re-entry,
22
+ // before the normal rel-resolution logic that actually finds the right
23
+ // soul ever runs.
24
+ const opt = {..._opt}
25
+ delete opt.nested
26
+
27
+ const childListeners = new Map() // key -> {api, handler}
28
+ let latest = null
29
+
30
+ const notify = () => cb(latest === null ? null : {...latest})
31
+
32
+ const topLevelHandler = data => {
33
+ if (data === null || typeof data !== "object") {
34
+ latest = data
35
+ notify()
36
+ return
37
+ }
38
+
39
+ if (!latest || typeof latest !== "object") latest = {}
40
+ for (const [key, value] of Object.entries(data)) {
41
+ if (key === "_") continue
42
+ latest[key] = value
43
+
44
+ if (
45
+ value !== null &&
46
+ typeof value === "object" &&
47
+ !childListeners.has(key)
48
+ ) {
49
+ const childApi = chain().next(key)
50
+ const handler = childData => {
51
+ if (childData === null) {
52
+ delete latest[key]
53
+ childListeners.delete(key)
54
+ } else {
55
+ latest[key] = childData
56
+ }
57
+ notify()
58
+ }
59
+ childListeners.set(key, {api: childApi, handler})
60
+ // false, not true - the current value is already in data above,
61
+ // so this only needs to watch for future changes, not trigger a
62
+ // redundant immediate re-read.
63
+ childApi.on(handler, false, opt)
64
+ }
65
+ }
66
+
67
+ for (const key of [...childListeners.keys()]) {
68
+ if (!(key in data)) {
69
+ const {api: childApi, handler} = childListeners.get(key)
70
+ childApi.off(handler)
71
+ childListeners.delete(key)
72
+ delete latest[key]
73
+ }
74
+ }
75
+
76
+ notify()
77
+ }
78
+
79
+ const topLevelApi = chain()
80
+ topLevelApi.on(topLevelHandler, _get, opt)
81
+
82
+ return () => {
83
+ topLevelApi.off(topLevelHandler)
84
+ for (const {api: childApi, handler} of childListeners.values()) {
85
+ childApi.off(handler)
86
+ }
87
+ childListeners.clear()
88
+ }
89
+ }
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