@mblaney/holster 1.0.36 → 2.0.1
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/build/holster.js +3 -3
- package/build/holster.js.map +3 -3
- package/dist/holster.js +3 -3
- package/dist/holster.js.map +3 -3
- package/package.json +4 -5
- package/src/dup.js +4 -5
- package/src/get.js +4 -1
- package/src/ham.js +5 -2
- package/src/holster.js +78 -47
- package/src/index.js +1 -1
- package/src/radisk.js +90 -87
- package/src/sea-utils.js +1 -1
- package/src/store.js +1 -1
- package/src/user.js +0 -4
- package/src/wire.js +204 -80
- package/ts/src/dup.ts +6 -17
- package/ts/src/ham.ts +5 -1
- package/ts/src/holster.ts +66 -33
- package/ts/src/radisk.ts +87 -81
- package/ts/src/schemas.ts +1 -5
- package/ts/src/sea-utils.ts +1 -1
- package/ts/src/store.ts +1 -1
- package/ts/src/user.ts +0 -4
- package/ts/src/wire.ts +201 -69
package/package.json
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mblaney/holster",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
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
8
|
"test": "node --test",
|
|
9
|
-
"test:ts": "node --test --import tsx/esm ts/test
|
|
10
|
-
"test:ts:system": "node --test --import tsx/esm ts/test/system/**/*.test.ts",
|
|
9
|
+
"test:ts": "node --test --import tsx/esm ts/test/*.test.ts ts/test/system/*.test.ts",
|
|
11
10
|
"build": "esbuild src/holster.js --bundle --format=esm --sourcemap \"--external:node:*\" \"--external:ws\" --minify --outdir=build",
|
|
12
11
|
"build:ts": "esbuild ts/src/holster.ts --bundle --format=esm --sourcemap \"--external:node:*\" \"--external:ws\" --minify --outdir=dist",
|
|
13
12
|
"build:all": "npm run build && npm run build:ts",
|
|
14
13
|
"prepublishOnly": "npm run build:all",
|
|
15
|
-
"type-check": "tsc
|
|
16
|
-
"type-check:tests": "tsc
|
|
14
|
+
"type-check": "tsc -p tsconfig.json",
|
|
15
|
+
"type-check:tests": "tsc -p tsconfig.test.json"
|
|
17
16
|
},
|
|
18
17
|
"files": [
|
|
19
18
|
"src",
|
package/src/dup.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
const Dup =
|
|
2
|
-
|
|
3
|
-
if (!maxAge) maxAge = 9000
|
|
1
|
+
const Dup = () => {
|
|
2
|
+
const maxAge = 9000
|
|
4
3
|
const dup = {store: {}}
|
|
5
4
|
dup.check = id => (dup.store[id] ? dup.track(id) : false)
|
|
6
5
|
dup.track = id => {
|
|
@@ -8,14 +7,14 @@ const Dup = maxAge => {
|
|
|
8
7
|
dup.store[id] = Date.now()
|
|
9
8
|
if (!dup.expiry) {
|
|
10
9
|
dup.expiry = setTimeout(() => {
|
|
11
|
-
if (dup.expiry) return
|
|
12
|
-
|
|
13
10
|
const now = Date.now()
|
|
14
11
|
Object.keys(dup.store).forEach(id => {
|
|
15
12
|
if (now - dup.store[id] > maxAge) delete dup.store[id]
|
|
16
13
|
})
|
|
17
14
|
dup.expiry = null
|
|
18
15
|
}, maxAge)
|
|
16
|
+
// Don't block process exit — cleanup is best-effort.
|
|
17
|
+
if (dup.expiry.unref) dup.expiry.unref()
|
|
19
18
|
}
|
|
20
19
|
return id
|
|
21
20
|
}
|
package/src/get.js
CHANGED
|
@@ -43,17 +43,20 @@ const Get = (lex, graph, fast) => {
|
|
|
43
43
|
// return undefined to ensure we get all matching properties from store.
|
|
44
44
|
if (!fast) return
|
|
45
45
|
|
|
46
|
+
let found = false
|
|
46
47
|
for (const key of Object.keys(graph[soul])) {
|
|
47
48
|
// Always include userPublicKey for verification, regardless of filter
|
|
48
|
-
if (key === userPublicKey || match(lex["."], key)) {
|
|
49
|
+
if (key !== "_" && (key === userPublicKey || match(lex["."], key))) {
|
|
49
50
|
node[key] = graph[soul][key]
|
|
50
51
|
node._[">"][key] = graph[soul]._[">"][key]
|
|
51
52
|
const state = graph[soul]._[">"][key]
|
|
52
53
|
if (graph[soul]._["s"] && graph[soul]._["s"][state]) {
|
|
53
54
|
signatures[state] = graph[soul]._["s"][state]
|
|
54
55
|
}
|
|
56
|
+
found = true
|
|
55
57
|
}
|
|
56
58
|
}
|
|
59
|
+
if (!found) return
|
|
57
60
|
}
|
|
58
61
|
if (Object.keys(signatures).length > 0) {
|
|
59
62
|
node._["s"] = signatures
|
package/src/ham.js
CHANGED
|
@@ -35,7 +35,7 @@ const Ham = (state, currentState, value, currentValue, signed = false) => {
|
|
|
35
35
|
return {incoming: true}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
Ham.mix = async (change, graph, secure, listen) => {
|
|
38
|
+
Ham.mix = async (change, graph, hasNode, secure, listen) => {
|
|
39
39
|
if (!change || typeof change !== "object") {
|
|
40
40
|
throw new TypeError("change must be an object")
|
|
41
41
|
}
|
|
@@ -226,7 +226,10 @@ Ham.mix = async (change, graph, secure, listen) => {
|
|
|
226
226
|
})
|
|
227
227
|
.sort((a, b) => a.maxState - b.maxState)
|
|
228
228
|
const remove = soulsByAge.slice(0, souls.length - MAX_GRAPH_SIZE)
|
|
229
|
-
remove.forEach(({soul}) =>
|
|
229
|
+
remove.forEach(({soul}) => {
|
|
230
|
+
delete graph[soul]
|
|
231
|
+
if (hasNode) hasNode.delete(soul)
|
|
232
|
+
})
|
|
230
233
|
}
|
|
231
234
|
|
|
232
235
|
return {now: now, defer: defer, wait: wait, listeners: listeners}
|
package/src/holster.js
CHANGED
|
@@ -64,30 +64,28 @@ const Holster = opt => {
|
|
|
64
64
|
delete msg.put[soul][utils.userPublicKey]
|
|
65
65
|
delete msg.put[soul][utils.userSignature]
|
|
66
66
|
// Resolve any rels on the node before returning to the user.
|
|
67
|
-
|
|
67
|
+
await Promise.all(Object.keys(msg.put[soul]).map(async key => {
|
|
68
68
|
const id = utils.rel.is(msg.put[soul][key])
|
|
69
|
-
if (id)
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
user: ctx ? ctx.user : null,
|
|
78
|
-
})
|
|
79
|
-
api(_ctxid).next(null, res, _opt)
|
|
69
|
+
if (!id) return
|
|
70
|
+
const attemptRead = async (retries = 0) => {
|
|
71
|
+
const data = await new Promise(res => {
|
|
72
|
+
const _ctxid = utils.text.random()
|
|
73
|
+
const ctx = allctx.get(ctxid)
|
|
74
|
+
allctx.set(_ctxid, {
|
|
75
|
+
chain: [{item: null, soul: id}],
|
|
76
|
+
user: ctx ? ctx.user : null,
|
|
80
77
|
})
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
86
|
-
return attemptRead(retries + 1)
|
|
78
|
+
api(_ctxid).next(null, res, _opt)
|
|
79
|
+
})
|
|
80
|
+
if (data !== null || retries >= 5) {
|
|
81
|
+
return data
|
|
87
82
|
}
|
|
88
|
-
|
|
83
|
+
// Data not ready, retry after delay
|
|
84
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
85
|
+
return attemptRead(retries + 1)
|
|
89
86
|
}
|
|
90
|
-
|
|
87
|
+
msg.put[soul][key] = await attemptRead()
|
|
88
|
+
}))
|
|
91
89
|
ack(msg.put[soul])
|
|
92
90
|
} else {
|
|
93
91
|
// No data callback.
|
|
@@ -132,11 +130,13 @@ const Holster = opt => {
|
|
|
132
130
|
}
|
|
133
131
|
}
|
|
134
132
|
|
|
135
|
-
const watchForRel = (soul, item, ctx, i, request, cb) => {
|
|
133
|
+
const watchForRel = (soul, item, ctx, i, request, cb, on = true) => {
|
|
134
|
+
let timer = null
|
|
136
135
|
const handler = () => {
|
|
137
136
|
// Bail out if the context was removed by off().
|
|
138
137
|
if (!allctx.has(ctxid)) {
|
|
139
138
|
wire.off({"#": soul, ".": item}, handler)
|
|
139
|
+
if (timer) clearTimeout(timer)
|
|
140
140
|
return
|
|
141
141
|
}
|
|
142
142
|
wire.get(
|
|
@@ -144,16 +144,39 @@ const Holster = opt => {
|
|
|
144
144
|
msg => {
|
|
145
145
|
const node = msg.put && msg.put[soul]
|
|
146
146
|
const id = utils.rel.is(node && node[item])
|
|
147
|
-
if (!id)
|
|
147
|
+
if (!id) {
|
|
148
|
+
// If it's a plain value (not a rel) and this is a get, deliver it.
|
|
149
|
+
// node._ guards against timeout null-acks which lack metadata.
|
|
150
|
+
if (!on && node && node._ && typeof node[item] !== "undefined") {
|
|
151
|
+
if (timer) clearTimeout(timer)
|
|
152
|
+
wire.off({"#": soul, ".": item}, handler)
|
|
153
|
+
cb(node[item])
|
|
154
|
+
}
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (timer) clearTimeout(timer)
|
|
148
158
|
wire.off({"#": soul, ".": item}, handler)
|
|
149
159
|
ctx.chain[i].soul = id
|
|
150
160
|
allctx.set(ctxid, {...ctx})
|
|
151
|
-
|
|
161
|
+
if (on) {
|
|
162
|
+
api(ctxid).on(request.on, cb, request._get, request._opt)
|
|
163
|
+
} else {
|
|
164
|
+
api(ctxid).next(null, request.get, cb, request._opt)
|
|
165
|
+
}
|
|
152
166
|
},
|
|
153
|
-
{...request._opt, secure: ctx.user || opt.secure},
|
|
167
|
+
{...request._opt, secure: !!(ctx.user || opt.secure)},
|
|
154
168
|
)
|
|
155
169
|
}
|
|
156
170
|
wire.on({"#": soul, ".": item}, handler, false, request._opt)
|
|
171
|
+
// Time out after the same total duration as the on() retry loop
|
|
172
|
+
// (1+2+4+8+16 = 31s) so callers are not blocked forever if the node
|
|
173
|
+
// genuinely doesn't exist.
|
|
174
|
+
if (!on) {
|
|
175
|
+
timer = setTimeout(() => {
|
|
176
|
+
wire.off({"#": soul, ".": item}, handler)
|
|
177
|
+
if (cb) cb(null)
|
|
178
|
+
}, 31000)
|
|
179
|
+
}
|
|
157
180
|
}
|
|
158
181
|
|
|
159
182
|
const resolve = (request, cb) => {
|
|
@@ -169,6 +192,7 @@ const Holster = opt => {
|
|
|
169
192
|
|
|
170
193
|
let found = false
|
|
171
194
|
const ctx = allctx.get(ctxid)
|
|
195
|
+
if (!ctx) return {item: null, soul: null}
|
|
172
196
|
for (var i = 1; i < ctx.chain.length; i++) {
|
|
173
197
|
if (ctx.chain[i].soul !== null) continue
|
|
174
198
|
|
|
@@ -255,13 +279,22 @@ const Holster = opt => {
|
|
|
255
279
|
// Node doesn't exist yet — watch for it to appear.
|
|
256
280
|
watchForRel(soul, item, ctx, i, request, cb)
|
|
257
281
|
if (request._get) cb(null)
|
|
282
|
+
} else if (get && node) {
|
|
283
|
+
const sv = node._ && node._[">"]
|
|
284
|
+
if (sv && Object.keys(sv).length > 0 && typeof sv[item] === "undefined") {
|
|
285
|
+
// State vector has other properties but not this one — never written.
|
|
286
|
+
if (cb) cb(null)
|
|
287
|
+
} else {
|
|
288
|
+
// Empty state vector or item is tracked — may arrive via push.
|
|
289
|
+
watchForRel(soul, item, ctx, i, request, cb, false)
|
|
290
|
+
}
|
|
258
291
|
} else {
|
|
259
|
-
//
|
|
292
|
+
// Soul doesn't exist or no get — return null.
|
|
260
293
|
if (cb) cb(null)
|
|
261
294
|
}
|
|
262
295
|
}
|
|
263
296
|
},
|
|
264
|
-
{...request._opt, secure: ctx.user || opt.secure},
|
|
297
|
+
{...request._opt, secure: !!(ctx.user || opt.secure)},
|
|
265
298
|
)
|
|
266
299
|
// Callback has been passed to next soul lookup or called above, so
|
|
267
300
|
// return false as the calling code should not continue.
|
|
@@ -387,9 +420,7 @@ const Holster = opt => {
|
|
|
387
420
|
// ctx already removed by another chained callback is ok?
|
|
388
421
|
if (!ctx) return
|
|
389
422
|
|
|
390
|
-
if (!ctx.cb) {
|
|
391
|
-
if (!cb) return
|
|
392
|
-
|
|
423
|
+
if (!ctx.cb && cb) {
|
|
393
424
|
// This (and ack) allows nested objects to set their own callbacks.
|
|
394
425
|
ctx.cb = cb
|
|
395
426
|
cb = null
|
|
@@ -406,7 +437,7 @@ const Holster = opt => {
|
|
|
406
437
|
|
|
407
438
|
// Resolve the current context before putting data. (Note that set is
|
|
408
439
|
// not passed to resolve because it's already been applied above.)
|
|
409
|
-
const {item, soul} = resolve({put: data}, _ack)
|
|
440
|
+
const {item, soul} = resolve({put: data, _opt: {put: true}}, _ack)
|
|
410
441
|
if (!soul) return
|
|
411
442
|
|
|
412
443
|
if (result === true) {
|
|
@@ -475,7 +506,7 @@ const Holster = opt => {
|
|
|
475
506
|
wire.put(g, _ack)
|
|
476
507
|
})
|
|
477
508
|
},
|
|
478
|
-
{secure: ctx.user || opt.secure},
|
|
509
|
+
{secure: !!(ctx.user || opt.secure), put: true},
|
|
479
510
|
)
|
|
480
511
|
return
|
|
481
512
|
}
|
|
@@ -515,7 +546,7 @@ const Holster = opt => {
|
|
|
515
546
|
} else {
|
|
516
547
|
const _ctxid = utils.text.random()
|
|
517
548
|
const chain = [{item: item, soul: soul}]
|
|
518
|
-
// Pass on the previous
|
|
549
|
+
// Pass on the previous ctx's callback and user flag here.
|
|
519
550
|
allctx.set(_ctxid, {
|
|
520
551
|
chain: chain,
|
|
521
552
|
user: ctx.user,
|
|
@@ -564,7 +595,7 @@ const Holster = opt => {
|
|
|
564
595
|
|
|
565
596
|
wire.put(g, _ack)
|
|
566
597
|
},
|
|
567
|
-
{secure: ctx.user || opt.secure},
|
|
598
|
+
{secure: !!(ctx.user || opt.secure), put: true},
|
|
568
599
|
)
|
|
569
600
|
}
|
|
570
601
|
attemptRead()
|
|
@@ -611,8 +642,8 @@ const Holster = opt => {
|
|
|
611
642
|
map.set(cb, () => {
|
|
612
643
|
// Bail out if off() has already cleaned up this context — the mapped
|
|
613
644
|
// callback can fire twice (once from wire.on and once from the _get
|
|
614
|
-
// immediate-read path) and the second call after async context
|
|
615
|
-
// would crash in resolve(). Matches the guard in watchForRel.
|
|
645
|
+
// immediate-read path) and the second call after async context
|
|
646
|
+
// cleanup would crash in resolve(). Matches the guard in watchForRel.
|
|
616
647
|
if (!allctx.has(ctxid)) return
|
|
617
648
|
// Cancel any pending retry — the persistent listener firing means we
|
|
618
649
|
// have an update and should not call cb twice.
|
|
@@ -635,7 +666,8 @@ const Holster = opt => {
|
|
|
635
666
|
chain: [{item: null, soul: id}],
|
|
636
667
|
user: ctx ? ctx.user : null,
|
|
637
668
|
})
|
|
638
|
-
api(_ctxid).next(null, res, retries === 0 ?
|
|
669
|
+
api(_ctxid).next(null, res, retries === 0 ?
|
|
670
|
+
utils.obj.put(_opt, "fast", true) : _opt)
|
|
639
671
|
})
|
|
640
672
|
if (data !== null || retries >= 5) {
|
|
641
673
|
retryCount = 0 // Reset retry counter on success
|
|
@@ -661,7 +693,8 @@ const Holster = opt => {
|
|
|
661
693
|
// miss and never fire again if the data doesn't change while
|
|
662
694
|
// the listener is attached, so we must poll for initial load.
|
|
663
695
|
const retry = () => {
|
|
664
|
-
const delay =
|
|
696
|
+
const delay =
|
|
697
|
+
Math.min(retryDelay * Math.pow(2, retryCount), 30000)
|
|
665
698
|
retryCount++
|
|
666
699
|
retryTimer = setTimeout(() => {
|
|
667
700
|
wire.get(
|
|
@@ -680,7 +713,7 @@ const Holster = opt => {
|
|
|
680
713
|
cb(null)
|
|
681
714
|
}
|
|
682
715
|
},
|
|
683
|
-
{..._opt, secure: ctx.user || opt.secure},
|
|
716
|
+
{..._opt, secure: !!(ctx.user || opt.secure)},
|
|
684
717
|
)
|
|
685
718
|
}, delay)
|
|
686
719
|
}
|
|
@@ -690,7 +723,7 @@ const Holster = opt => {
|
|
|
690
723
|
}
|
|
691
724
|
}
|
|
692
725
|
},
|
|
693
|
-
{..._opt, secure: ctx.user || opt.secure},
|
|
726
|
+
{..._opt, secure: !!(ctx.user || opt.secure)},
|
|
694
727
|
)
|
|
695
728
|
})
|
|
696
729
|
|
|
@@ -727,17 +760,14 @@ const Holster = opt => {
|
|
|
727
760
|
// First remove the initial listener
|
|
728
761
|
wire.off(initialLex, map.get(cb))
|
|
729
762
|
// Then add listener on the related node
|
|
730
|
-
|
|
731
|
-
if (lex) relLex = utils.obj.put(initialLex, "#", id)
|
|
732
|
-
else relLex = {"#": id, ".": null}
|
|
733
|
-
wire.on(relLex, map.get(cb), false, _opt)
|
|
763
|
+
wire.on({"#": id, ".": null}, map.get(cb), false, _opt)
|
|
734
764
|
if (_get) map.get(cb)()
|
|
735
765
|
} else if (_get) {
|
|
736
766
|
// Not a rel, but _get was requested, so trigger callback.
|
|
737
767
|
map.get(cb)()
|
|
738
768
|
}
|
|
739
769
|
},
|
|
740
|
-
{..._opt, secure: ctx.user || opt.secure},
|
|
770
|
+
{..._opt, secure: !!(ctx.user || opt.secure)},
|
|
741
771
|
)
|
|
742
772
|
},
|
|
743
773
|
off: cb => {
|
|
@@ -751,9 +781,10 @@ const Holster = opt => {
|
|
|
751
781
|
const {item, soul} = resolve({off: true}, cb)
|
|
752
782
|
if (!soul) return
|
|
753
783
|
|
|
754
|
-
// Remove listener immediately from the original soul to avoid race
|
|
755
|
-
// The listener may be on a related soul if item is a rel,
|
|
756
|
-
// from the original soul first since that's where it's
|
|
784
|
+
// Remove listener immediately from the original soul to avoid a race
|
|
785
|
+
// condition. The listener may be on a related soul if item is a rel,
|
|
786
|
+
// but we remove from the original soul first since that's where it's
|
|
787
|
+
//initially registered.
|
|
757
788
|
wire.off({"#": soul}, map.get(cb))
|
|
758
789
|
|
|
759
790
|
// Check if item is a rel and remove from the related node as well.
|
package/src/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from "path"
|
|
|
3
3
|
import {fileURLToPath} from "url"
|
|
4
4
|
import Holster from "./holster.js"
|
|
5
5
|
|
|
6
|
-
const holster = Holster()
|
|
6
|
+
const holster = Holster({port: 8765})
|
|
7
7
|
|
|
8
8
|
// Note that express is only used here to serve the examples folder. It is not
|
|
9
9
|
// required to run Holster itself.
|
package/src/radisk.js
CHANGED
|
@@ -34,7 +34,6 @@ const Radisk = opt => {
|
|
|
34
34
|
if (!opt.write) opt.write = 1 // Wait time before write in milliseconds
|
|
35
35
|
if (!opt.size) opt.size = 1024 * 1024 // File size on disk, default 1MB
|
|
36
36
|
if (!opt.memoryLimit) opt.memoryLimit = 500 // Memory limit in MB
|
|
37
|
-
if (!opt.readTimeout) opt.readTimeout = 1000 // Read timeout in milliseconds
|
|
38
37
|
if (typeof opt.cache === "undefined") opt.cache = true
|
|
39
38
|
|
|
40
39
|
if (!opt.store) {
|
|
@@ -59,7 +58,7 @@ const Radisk = opt => {
|
|
|
59
58
|
// Performance logging
|
|
60
59
|
const perfLog = (operation, startTime, key, size) => {
|
|
61
60
|
const duration = Date.now() - startTime
|
|
62
|
-
if (duration >
|
|
61
|
+
if (duration > 3000) {
|
|
63
62
|
console.log(
|
|
64
63
|
`[RADISK-SLOW] ${operation}: ${duration}ms for ${key}${size ? ` (${size} bytes)` : ""}`,
|
|
65
64
|
)
|
|
@@ -124,37 +123,29 @@ const Radisk = opt => {
|
|
|
124
123
|
}
|
|
125
124
|
}
|
|
126
125
|
|
|
127
|
-
// Check if there's already a pending read for this key
|
|
128
126
|
if (pendingReads.has(key)) {
|
|
129
|
-
|
|
130
|
-
pendingReads.get(key).callbacks.push(cb)
|
|
127
|
+
pendingReads.get(key).push(cb)
|
|
131
128
|
return
|
|
132
129
|
}
|
|
133
130
|
|
|
134
|
-
|
|
135
|
-
const pending = {callbacks: [cb], fired: false, timeoutId: null}
|
|
136
|
-
pendingReads.set(key, pending)
|
|
137
|
-
|
|
138
|
-
// Set a timeout to fire callbacks if read takes too long
|
|
139
|
-
pending.timeoutId = setTimeout(() => {
|
|
140
|
-
if (!pending.fired) {
|
|
141
|
-
pending.fired = true
|
|
142
|
-
pendingReads.delete(key)
|
|
143
|
-
const err = new Error("radisk read timeout")
|
|
144
|
-
pending.callbacks.forEach(callback => callback(err, undefined))
|
|
145
|
-
}
|
|
146
|
-
}, opt.readTimeout)
|
|
147
|
-
|
|
131
|
+
pendingReads.set(key, [cb])
|
|
148
132
|
const readStart = Date.now()
|
|
133
|
+
// If radisk.read genuinely hangs (fd leak, stuck I/O), recover
|
|
134
|
+
// callbacks as a cache miss so callers can fall through to the wire path.
|
|
135
|
+
// 30s is long enough to distinguish a hang from a slow-but-healthy read.
|
|
136
|
+
const timeout = setTimeout(() => {
|
|
137
|
+
const callbacks = pendingReads.get(key)
|
|
138
|
+
if (!callbacks) return
|
|
139
|
+
opt.log(`radisk read hang detected for key: ${key}`)
|
|
140
|
+
pendingReads.delete(key)
|
|
141
|
+
callbacks.forEach(callback => callback(null, null))
|
|
142
|
+
}, 30000)
|
|
149
143
|
return radisk.read(key, (err, result) => {
|
|
150
|
-
clearTimeout(
|
|
144
|
+
clearTimeout(timeout)
|
|
151
145
|
perfLog("read", readStart, key)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
pendingReads.delete(key)
|
|
156
|
-
pending.callbacks.forEach(callback => callback(err, result))
|
|
157
|
-
}
|
|
146
|
+
const callbacks = pendingReads.get(key) || []
|
|
147
|
+
pendingReads.delete(key)
|
|
148
|
+
callbacks.forEach(callback => callback(err, result))
|
|
158
149
|
})
|
|
159
150
|
}
|
|
160
151
|
|
|
@@ -288,6 +279,8 @@ const Radisk = opt => {
|
|
|
288
279
|
limit: "",
|
|
289
280
|
done: false,
|
|
290
281
|
count: 0,
|
|
282
|
+
halfKey: "", // soul ID at the ~opt.size/2 mark for a balanced split
|
|
283
|
+
halfOffset: 0, // write.text.length before that entry
|
|
291
284
|
each: (value, key, k, pre) => {
|
|
292
285
|
// each is called for all keys, but stop adding to write.text when
|
|
293
286
|
// write.slice is called, then the current contents of write.text
|
|
@@ -298,17 +291,46 @@ const Radisk = opt => {
|
|
|
298
291
|
value = typeof value === "undefined" ? "" : "=" + Radisk.encode(value)
|
|
299
292
|
const enc =
|
|
300
293
|
Radisk.encode(pre.length) + "#" + Radisk.encode(k) + value + "\n"
|
|
301
|
-
// Cannot split the file if only have one entry to write.
|
|
302
|
-
//
|
|
294
|
+
// Cannot split the file if only have one entry to write. Allow splitting
|
|
295
|
+
// at depth > 0 only when k itself contains enq, meaning k is a full
|
|
296
|
+
// "soulId\x05property" key, not merely a property suffix of a shared
|
|
297
|
+
// soul prefix. Use the full key (pre + k) to extract the soul boundary.
|
|
298
|
+
const validBoundary =
|
|
299
|
+
pre.length === 0 || (pre.length === 1 && k.includes(enq))
|
|
300
|
+
|
|
301
|
+
// Record the first valid split boundary past the halfway mark so a
|
|
302
|
+
// full overflow can split here instead of at the tail, keeping both
|
|
303
|
+
// halves near opt.size/2 rather than leaving the file always full.
|
|
304
|
+
if (
|
|
305
|
+
!write.halfKey &&
|
|
306
|
+
write.count > 1 &&
|
|
307
|
+
write.text.length > opt.size / 2 &&
|
|
308
|
+
validBoundary
|
|
309
|
+
) {
|
|
310
|
+
const fullKey = pre.join("") + k
|
|
311
|
+
const end = fullKey.indexOf(enq)
|
|
312
|
+
write.halfKey =
|
|
313
|
+
end === -1 ? fullKey : fullKey.substring(0, end)
|
|
314
|
+
write.halfOffset = write.text.length
|
|
315
|
+
}
|
|
316
|
+
|
|
303
317
|
if (
|
|
304
318
|
write.count > 1 &&
|
|
305
|
-
|
|
306
|
-
|
|
319
|
+
write.text.length + enc.length > opt.size &&
|
|
320
|
+
validBoundary
|
|
307
321
|
) {
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
322
|
+
const fullKey = pre.join("") + k
|
|
323
|
+
const end = fullKey.indexOf(enq)
|
|
324
|
+
write.limit =
|
|
325
|
+
write.halfKey ||
|
|
326
|
+
(end === -1 ? fullKey : fullKey.substring(0, end))
|
|
327
|
+
// Cannot split if the key is the same as the current file name.
|
|
311
328
|
if (write.limit !== file) {
|
|
329
|
+
// Truncate to the balanced split point — text after halfOffset is
|
|
330
|
+
// handled by the write.slice call below, which reads from rad.
|
|
331
|
+
if (write.halfKey) {
|
|
332
|
+
write.text = write.text.substring(0, write.halfOffset)
|
|
333
|
+
}
|
|
312
334
|
write.done = true
|
|
313
335
|
write.sub = Radix()
|
|
314
336
|
Radix.map(rad, write.slice)
|
|
@@ -330,6 +352,13 @@ const Radisk = opt => {
|
|
|
330
352
|
// finished.
|
|
331
353
|
const writeStart = Date.now()
|
|
332
354
|
opt.store.put(file, write.text, err => {
|
|
355
|
+
// If a split occurred the cached radix tree still contains entries that
|
|
356
|
+
// were moved to the sub-file. Invalidate so the next save.mix re-reads
|
|
357
|
+
// from disk rather than re-writing those entries back to this file.
|
|
358
|
+
if (write.done) {
|
|
359
|
+
cache.delete(file)
|
|
360
|
+
fileListCache = null
|
|
361
|
+
}
|
|
333
362
|
perfLog("file-write", writeStart, file, write.text.length)
|
|
334
363
|
cb(err)
|
|
335
364
|
})
|
|
@@ -340,71 +369,45 @@ const Radisk = opt => {
|
|
|
340
369
|
const end = key.indexOf(enq)
|
|
341
370
|
const soul = end === -1 ? key : key.substring(0, end)
|
|
342
371
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
// Check multi-file cache first
|
|
354
|
-
if (opt.cache && cache.has(read.file)) {
|
|
355
|
-
const cachedRadix = cache.get(read.file)
|
|
356
|
-
read.value = cachedRadix(key)
|
|
357
|
-
// Return cached result (defined or undefined) since in-memory
|
|
358
|
-
// radix tree is authoritative
|
|
359
|
-
return cb(u, read.value)
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
radisk.parse(read.file, read.it)
|
|
363
|
-
return
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// Want the filename closest to soul.
|
|
367
|
-
if (file > soul || file < read.file) return
|
|
372
|
+
// Binary search for largest filename <= soul in a sorted array.
|
|
373
|
+
const findFile = files => {
|
|
374
|
+
let lo = 0, hi = files.length - 1, result = null
|
|
375
|
+
while (lo <= hi) {
|
|
376
|
+
const mid = (lo + hi) >> 1
|
|
377
|
+
if (files[mid] <= soul) { result = files[mid]; lo = mid + 1 }
|
|
378
|
+
else { hi = mid - 1 }
|
|
379
|
+
}
|
|
380
|
+
return result
|
|
381
|
+
}
|
|
368
382
|
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
|
|
383
|
+
const read = file => {
|
|
384
|
+
if (!file) { cb("no file found", u); return }
|
|
385
|
+
if (opt.cache && cache.has(file)) {
|
|
386
|
+
return cb(u, cache.get(file)(key))
|
|
387
|
+
}
|
|
388
|
+
radisk.parse(file, (err, disk) => {
|
|
372
389
|
if (err) opt.log(err)
|
|
373
|
-
if (disk) {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
checkMemoryUsage() // Check memory usage after adding to cache
|
|
377
|
-
}
|
|
378
|
-
read.value = disk(key)
|
|
390
|
+
if (disk && opt.cache) {
|
|
391
|
+
cache.set(file, disk)
|
|
392
|
+
checkMemoryUsage()
|
|
379
393
|
}
|
|
380
|
-
cb(err,
|
|
381
|
-
}
|
|
394
|
+
cb(err, disk ? disk(key) : u)
|
|
395
|
+
})
|
|
382
396
|
}
|
|
397
|
+
|
|
383
398
|
const now = Date.now()
|
|
384
|
-
if (
|
|
385
|
-
|
|
386
|
-
fileListCache &&
|
|
387
|
-
now - fileListCacheTime < FILE_LIST_CACHE_TTL
|
|
388
|
-
) {
|
|
389
|
-
// Use cached file list
|
|
390
|
-
fileListCache.forEach(file => read.lex(file))
|
|
391
|
-
read.lex() // Signal end of list
|
|
399
|
+
if (opt.cache && fileListCache && now - fileListCacheTime < FILE_LIST_CACHE_TTL) {
|
|
400
|
+
read(findFile(fileListCache, soul))
|
|
392
401
|
} else {
|
|
393
|
-
// Refresh cache
|
|
394
402
|
const files = []
|
|
395
|
-
const originalLex = read.lex
|
|
396
|
-
read.lex = file => {
|
|
397
|
-
if (file) files.push(file)
|
|
398
|
-
return originalLex(file)
|
|
399
|
-
}
|
|
400
|
-
|
|
401
403
|
opt.store.list(file => {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
404
|
+
if (file) { files.push(file); return }
|
|
405
|
+
files.sort()
|
|
406
|
+
if (opt.cache) {
|
|
405
407
|
fileListCache = files
|
|
406
408
|
fileListCacheTime = now
|
|
407
409
|
}
|
|
410
|
+
read(findFile(files, soul))
|
|
408
411
|
})
|
|
409
412
|
}
|
|
410
413
|
}
|
package/src/sea-utils.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import SafeBuffer from "./buffer.js"
|
|
2
2
|
|
|
3
|
-
const isNode = typeof
|
|
3
|
+
const isNode = typeof process !== "undefined" && process.versions?.node != null
|
|
4
4
|
const crypto = isNode
|
|
5
5
|
? (await import(/*webpackIgnore: true*/ "node:crypto")).webcrypto
|
|
6
6
|
: globalThis.crypto
|
package/src/store.js
CHANGED
|
@@ -2,7 +2,7 @@ import Radisk from "./radisk.js"
|
|
|
2
2
|
import Radix from "./radix.js"
|
|
3
3
|
import * as utils from "./utils.js"
|
|
4
4
|
|
|
5
|
-
const isNode = typeof
|
|
5
|
+
const isNode = typeof process !== "undefined" && process.versions?.node != null
|
|
6
6
|
const fs = isNode ? await import(/*webpackIgnore: true*/ "node:fs") : undefined
|
|
7
7
|
|
|
8
8
|
// ASCII character for enquiry.
|
package/src/user.js
CHANGED
|
@@ -5,7 +5,6 @@ import {userPublicKey, userSignature} from "./utils.js"
|
|
|
5
5
|
|
|
6
6
|
const User = (opt, wire) => {
|
|
7
7
|
if (!wire) wire = Wire(opt)
|
|
8
|
-
const wait = opt?.wait ?? 5000
|
|
9
8
|
let pubs = []
|
|
10
9
|
let creating = false
|
|
11
10
|
let authing = false
|
|
@@ -76,7 +75,6 @@ const User = (opt, wire) => {
|
|
|
76
75
|
|
|
77
76
|
done(null)
|
|
78
77
|
},
|
|
79
|
-
{wait: wait},
|
|
80
78
|
)
|
|
81
79
|
}
|
|
82
80
|
|
|
@@ -103,7 +101,6 @@ const User = (opt, wire) => {
|
|
|
103
101
|
pubs = Object.keys(data)
|
|
104
102
|
next()
|
|
105
103
|
},
|
|
106
|
-
{wait: wait},
|
|
107
104
|
)
|
|
108
105
|
}
|
|
109
106
|
|
|
@@ -183,7 +180,6 @@ const User = (opt, wire) => {
|
|
|
183
180
|
})
|
|
184
181
|
})
|
|
185
182
|
},
|
|
186
|
-
{wait: wait},
|
|
187
183
|
)
|
|
188
184
|
},
|
|
189
185
|
auth: (username, password, cb) => {
|