@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/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mblaney/holster",
|
|
3
|
-
"version": "2.
|
|
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
|
-
|
|
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(
|
|
312
|
+
cb(err)
|
|
247
313
|
return
|
|
248
314
|
}
|
|
249
315
|
|
|
250
|
-
ctx.chain[i].soul =
|
|
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
|
-
|
|
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(
|
|
332
|
+
cb(err)
|
|
273
333
|
return
|
|
274
334
|
}
|
|
275
335
|
|
|
276
|
-
ctx.chain[i].soul =
|
|
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
|
-
|
|
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(
|
|
552
|
-
|
|
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
|
-
|
|
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:
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
matchingKey
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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.
|