@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/ts/src/wire.ts
CHANGED
|
@@ -10,6 +10,7 @@ import Store from "./store.ts"
|
|
|
10
10
|
import * as utils from "./utils.ts"
|
|
11
11
|
import type {
|
|
12
12
|
Graph,
|
|
13
|
+
GraphNode,
|
|
13
14
|
WireMessage,
|
|
14
15
|
WireOptions,
|
|
15
16
|
Lex,
|
|
@@ -25,7 +26,7 @@ type UnifiedWebSocket = WebSocket & {
|
|
|
25
26
|
send: (data: string | Buffer | ArrayBuffer | Uint8Array, options?: unknown, cb?: (err?: Error) => void) => void
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
const isNode = typeof
|
|
29
|
+
const isNode = typeof process !== "undefined" && process.versions?.node != null
|
|
29
30
|
|
|
30
31
|
// Dynamic import for Node.js ws module - won't execute in browser/service worker
|
|
31
32
|
const wsModule = isNode ? await import("ws") : undefined
|
|
@@ -219,17 +220,22 @@ export interface WireAPI {
|
|
|
219
220
|
|
|
220
221
|
const Wire = (opt: HolsterOptions): WireAPI => {
|
|
221
222
|
const options = opt || {}
|
|
222
|
-
const dup = Dup(
|
|
223
|
+
const dup = Dup()
|
|
223
224
|
const store = Store(options as never)
|
|
224
225
|
const graph: Graph = {}
|
|
225
226
|
const queue: Record<string, (msg: WireMessage) => void> = {}
|
|
226
227
|
const listen: ListenMap = {}
|
|
227
228
|
|
|
228
229
|
const pendingReferences = new Set<string>()
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
230
|
+
const hasNode = new Set<string>()
|
|
231
|
+
|
|
232
|
+
// Debounce client-side wire update checks: skip if the same lex was checked
|
|
233
|
+
// recently. Keyed by "soul" or "soul.property".
|
|
234
|
+
const recentChecks = new Map<string, number>()
|
|
235
|
+
const recentCheckTTL = 10000 // 10 seconds
|
|
236
|
+
|
|
237
|
+
const pendingTimeouts = new Map<string, Lex>()
|
|
238
|
+
const offlineTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
|
233
239
|
|
|
234
240
|
const hasSoul = async (soul: string): Promise<boolean> => {
|
|
235
241
|
if (graph[soul]) return true
|
|
@@ -252,19 +258,26 @@ const Wire = (opt: HolsterOptions): WireAPI => {
|
|
|
252
258
|
const key = utils.userPublicKey
|
|
253
259
|
|
|
254
260
|
for (const soul of Object.keys(data)) {
|
|
261
|
+
const node = data[soul]!
|
|
262
|
+
// If the incoming update doesn't include a userPublicKey property at all,
|
|
263
|
+
// allow it - the update is not trying to change the public key.
|
|
264
|
+
if (node[key] === undefined) continue
|
|
265
|
+
|
|
255
266
|
const msg = await new Promise<WireMessage>(res => {
|
|
256
|
-
getWithCallback({ "#": soul, ".": key }, res, send)
|
|
267
|
+
getWithCallback({ "#": soul, ".": key }, res, send, {put: true})
|
|
257
268
|
})
|
|
258
269
|
if (msg.err) {
|
|
259
270
|
if (cb) cb(msg.err)
|
|
260
271
|
return false
|
|
261
272
|
}
|
|
262
273
|
|
|
263
|
-
|
|
274
|
+
// If there is no current node then the data is ok to write without
|
|
275
|
+
// matching public keys, as the provided soul also needs a rel on the
|
|
276
|
+
// parent node which then also requires checking. Otherwise public keys
|
|
277
|
+
// need to match for existing data.
|
|
264
278
|
if (
|
|
265
279
|
!msg.put ||
|
|
266
280
|
!msg.put[soul] ||
|
|
267
|
-
node[key] === undefined ||
|
|
268
281
|
msg.put[soul]![key] === node[key]
|
|
269
282
|
) {
|
|
270
283
|
continue
|
|
@@ -311,7 +324,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
|
|
|
311
324
|
msg: { put: Graph; "#": string },
|
|
312
325
|
send: (msg: string) => void
|
|
313
326
|
): Promise<void> => {
|
|
314
|
-
const update = await Ham.mix(msg.put, graph, options.secure || false, listen)
|
|
327
|
+
const update = await Ham.mix(msg.put, graph, hasNode, options.secure || false, listen)
|
|
315
328
|
if (Object.keys(update.now).length === 0) {
|
|
316
329
|
if (Object.keys(update.defer).length !== 0) {
|
|
317
330
|
setTimeout(() => put({ put: update.defer, "#": msg["#"] }, send), update.wait)
|
|
@@ -322,6 +335,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
|
|
|
322
335
|
if (!(await check(update.now, send as never))) return
|
|
323
336
|
|
|
324
337
|
store.put(update.now, err => {
|
|
338
|
+
if (err) console.warn("store.put", err)
|
|
325
339
|
send(
|
|
326
340
|
JSON.stringify({
|
|
327
341
|
"#": dup.track(utils.text.random(9)),
|
|
@@ -346,7 +360,8 @@ const Wire = (opt: HolsterOptions): WireAPI => {
|
|
|
346
360
|
if (!cb) return
|
|
347
361
|
|
|
348
362
|
const opts = _opt || {}
|
|
349
|
-
const
|
|
363
|
+
const fast = opts.fast || (!lex["."] && hasNode.has(lex["#"]))
|
|
364
|
+
const ack = Get(lex, graph, fast)
|
|
350
365
|
const track = utils.text.random(9)
|
|
351
366
|
const request = JSON.stringify({
|
|
352
367
|
"#": dup.track(track),
|
|
@@ -354,10 +369,30 @@ const Wire = (opt: HolsterOptions): WireAPI => {
|
|
|
354
369
|
})
|
|
355
370
|
|
|
356
371
|
if (ack) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
372
|
+
if (!isNode) {
|
|
373
|
+
// Client: send request on the wire to check for updates,
|
|
374
|
+
// since it may have missed puts while disconnected.
|
|
375
|
+
// Skip if the same lex was checked recently to avoid flooding.
|
|
376
|
+
if (!lex["."] || typeof lex["."] === "string") {
|
|
377
|
+
const checkKey = lex["#"] + (lex["."] ? "." + lex["."] : "")
|
|
378
|
+
const now = Date.now()
|
|
379
|
+
const lastCheck = recentChecks.get(checkKey)
|
|
380
|
+
if (!lastCheck || now - lastCheck > recentCheckTTL) {
|
|
381
|
+
recentChecks.set(checkKey, now)
|
|
382
|
+
const sendResult = send(request)
|
|
383
|
+
if (sendResult && sendResult.err) {
|
|
384
|
+
cb({ err: sendResult.err })
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
389
|
+
// Don't skip checks for other types of lex queries.
|
|
390
|
+
const sendResult = send(request)
|
|
391
|
+
if (sendResult && sendResult.err) {
|
|
392
|
+
cb({ err: sendResult.err })
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
}
|
|
361
396
|
}
|
|
362
397
|
cb({ put: ack })
|
|
363
398
|
return
|
|
@@ -371,25 +406,84 @@ const Wire = (opt: HolsterOptions): WireAPI => {
|
|
|
371
406
|
// request, so stale server responses with older state are correctly
|
|
372
407
|
// rejected by ham.mix.
|
|
373
408
|
for (const [soul, node] of Object.entries(ack)) {
|
|
374
|
-
if (!
|
|
409
|
+
if (!node) continue
|
|
410
|
+
if (!graph[soul]) {
|
|
411
|
+
graph[soul] = {...node} as unknown as Graph[string]
|
|
412
|
+
continue
|
|
413
|
+
}
|
|
414
|
+
for (const [key, value] of Object.entries(node)) {
|
|
415
|
+
if (key === "_") continue
|
|
416
|
+
const gNode = graph[soul] as unknown as Record<string, unknown>
|
|
417
|
+
if (typeof gNode[key] !== "undefined") continue
|
|
418
|
+
gNode[key] = value
|
|
419
|
+
const meta = (node as unknown as GraphNode)._
|
|
420
|
+
if (meta?.[">"] && typeof meta[">"][key] !== "undefined") {
|
|
421
|
+
const state = meta[">"][key]
|
|
422
|
+
const gMeta = (graph[soul] as unknown as GraphNode)._
|
|
423
|
+
gMeta[">"][key] = state
|
|
424
|
+
if (meta["s"]?.[state]) {
|
|
425
|
+
if (!gMeta["s"]) gMeta["s"] = {}
|
|
426
|
+
gMeta["s"]![state] = meta["s"][state]
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
375
430
|
}
|
|
376
|
-
|
|
377
|
-
if (
|
|
378
|
-
|
|
431
|
+
// Full-node fetch: the graph is now complete for this soul.
|
|
432
|
+
if (!lex["."] && ack[lex["#"]]) hasNode.add(lex["#"])
|
|
433
|
+
// Only serve from store if the requested property is present.
|
|
434
|
+
const node = ack[lex["#"]]
|
|
435
|
+
const hasProperty =
|
|
436
|
+
node &&
|
|
437
|
+
Object.keys(node).some(
|
|
438
|
+
key => key !== "_" && utils.match(lex["."], key),
|
|
439
|
+
)
|
|
440
|
+
if (hasProperty) {
|
|
441
|
+
// Public keys are immutable once written — never need a wire check.
|
|
442
|
+
if (!isNode && lex["."] !== utils.userPublicKey) {
|
|
443
|
+
if (!lex["."] || typeof lex["."] === "string") {
|
|
444
|
+
const checkKey = lex["#"] + (lex["."] ? "." + lex["."] : "")
|
|
445
|
+
const now = Date.now()
|
|
446
|
+
const lastCheck = recentChecks.get(checkKey)
|
|
447
|
+
if (!lastCheck || now - lastCheck > recentCheckTTL) {
|
|
448
|
+
recentChecks.set(checkKey, now)
|
|
449
|
+
const sendResult = send(request)
|
|
450
|
+
if (sendResult && sendResult.err) {
|
|
451
|
+
cb({ err: sendResult.err })
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
} else {
|
|
456
|
+
const sendResult = send(request)
|
|
457
|
+
if (sendResult && sendResult.err) {
|
|
458
|
+
cb({ err: sendResult.err })
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
cb(err ? { put: ack, err: err } : { put: ack })
|
|
379
464
|
return
|
|
380
465
|
}
|
|
381
|
-
cb(err ? { put: ack, err: err } : { put: ack })
|
|
382
|
-
return
|
|
383
466
|
}
|
|
384
467
|
|
|
385
468
|
if (err) console.log(err)
|
|
386
469
|
|
|
470
|
+
// Put requests only need a local answer — skip the wire round-trip.
|
|
471
|
+
if (opts && opts.put) {
|
|
472
|
+
const id = lex["#"]
|
|
473
|
+
const ack: Record<string, null | Record<string, null>> = {[id]: null}
|
|
474
|
+
if (typeof lex["."] === "string") {
|
|
475
|
+
ack[id] = {[lex["."]]: null}
|
|
476
|
+
}
|
|
477
|
+
cb({put: ack as never})
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
|
|
387
481
|
queue[track] = cb
|
|
388
482
|
|
|
389
|
-
pendingTimeouts.set(track,
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
483
|
+
pendingTimeouts.set(track, lex)
|
|
484
|
+
|
|
485
|
+
// Ensure the requested soul is stored when the response arrives.
|
|
486
|
+
pendingReferences.add(lex["#"])
|
|
393
487
|
|
|
394
488
|
const sendResult = send(request)
|
|
395
489
|
if (sendResult && sendResult.err) {
|
|
@@ -415,7 +509,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
|
|
|
415
509
|
getWithCallback(lex, cb, send, _opt)
|
|
416
510
|
},
|
|
417
511
|
put: async (data, cb) => {
|
|
418
|
-
const update = await Ham.mix(data, graph, options.secure || false, listen)
|
|
512
|
+
const update = await Ham.mix(data, graph, hasNode, options.secure || false, listen)
|
|
419
513
|
if (Object.keys(update.now).length === 0) {
|
|
420
514
|
if (cb) cb(null)
|
|
421
515
|
return
|
|
@@ -482,14 +576,14 @@ const Wire = (opt: HolsterOptions): WireAPI => {
|
|
|
482
576
|
}
|
|
483
577
|
}
|
|
484
578
|
|
|
485
|
-
if (isNode) {
|
|
579
|
+
if (isNode && (options.wss || options.server || options.port != null)) {
|
|
486
580
|
let wss = options.wss
|
|
487
581
|
let clients = (): WebSocket[] =>
|
|
488
582
|
(wss as { clients?: () => WebSocket[] }).clients?.() || []
|
|
489
583
|
if (!wss) {
|
|
490
584
|
const config = options.server
|
|
491
585
|
? { server: options.server }
|
|
492
|
-
: { port: options.port
|
|
586
|
+
: { port: options.port }
|
|
493
587
|
wss = new wsModule!.WebSocketServer(config)
|
|
494
588
|
clients = () => Array.from((wss as { clients: Set<WebSocket> }).clients || [])
|
|
495
589
|
}
|
|
@@ -498,21 +592,21 @@ const Wire = (opt: HolsterOptions): WireAPI => {
|
|
|
498
592
|
const msg = JSON.parse(data) as WireMessage
|
|
499
593
|
const trackId = msg["#"]
|
|
500
594
|
if (trackId && pendingTimeouts.has(trackId)) {
|
|
501
|
-
const
|
|
595
|
+
const lex = pendingTimeouts.get(trackId)!
|
|
502
596
|
pendingTimeouts.delete(trackId)
|
|
503
597
|
|
|
504
|
-
|
|
598
|
+
setTimeout(() => {
|
|
505
599
|
const cb = queue[trackId]
|
|
506
600
|
if (cb) {
|
|
507
|
-
const id =
|
|
601
|
+
const id = lex["#"]
|
|
508
602
|
const ack: Graph = { [id]: null as never }
|
|
509
|
-
if (typeof
|
|
510
|
-
ack[id] = { [
|
|
603
|
+
if (typeof lex["."] === "string") {
|
|
604
|
+
ack[id] = { [lex["."]]: null } as never
|
|
511
605
|
}
|
|
512
606
|
cb({ "#": trackId, put: ack })
|
|
513
607
|
delete queue[trackId]
|
|
514
608
|
}
|
|
515
|
-
},
|
|
609
|
+
}, isTestEnv ? 100 : 10000)
|
|
516
610
|
}
|
|
517
611
|
|
|
518
612
|
clients().forEach(client => {
|
|
@@ -643,6 +737,23 @@ const Wire = (opt: HolsterOptions): WireAPI => {
|
|
|
643
737
|
let queueProcessor: NodeJS.Timeout | null = null
|
|
644
738
|
const maxQueueLength = options.maxQueueLength || 10000
|
|
645
739
|
|
|
740
|
+
const startResponseTimeout = (trackId: string, lex: Lex, delay = isTestEnv ? 100 : 10000): ReturnType<typeof setTimeout> => {
|
|
741
|
+
return setTimeout(() => {
|
|
742
|
+
offlineTimeouts.delete(trackId)
|
|
743
|
+
pendingTimeouts.delete(trackId)
|
|
744
|
+
const cb = queue[trackId]
|
|
745
|
+
if (cb) {
|
|
746
|
+
const id = lex["#"]
|
|
747
|
+
const ack: Graph = { [id]: null as never }
|
|
748
|
+
if (typeof lex["."] === "string") {
|
|
749
|
+
ack[id] = { [lex["."]]: null } as never
|
|
750
|
+
}
|
|
751
|
+
cb({ "#": trackId, put: ack })
|
|
752
|
+
delete queue[trackId]
|
|
753
|
+
}
|
|
754
|
+
}, delay)
|
|
755
|
+
}
|
|
756
|
+
|
|
646
757
|
const processQueue = (): void => {
|
|
647
758
|
if (messageQueue.length === 0) {
|
|
648
759
|
queueProcessor = null
|
|
@@ -664,27 +775,27 @@ const Wire = (opt: HolsterOptions): WireAPI => {
|
|
|
664
775
|
const data = messageQueue[0]!
|
|
665
776
|
const sent = sendToPeers(data)
|
|
666
777
|
|
|
667
|
-
|
|
668
|
-
|
|
778
|
+
const msg = JSON.parse(data) as WireMessage
|
|
779
|
+
const trackId = msg["#"]
|
|
669
780
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
const timeoutConfig = pendingTimeouts.get(trackId)!
|
|
674
|
-
pendingTimeouts.delete(trackId)
|
|
781
|
+
// Drop GET messages with no pending callback — these were already served
|
|
782
|
+
// from local store so no caller is waiting for the wire response.
|
|
783
|
+
const isStaleCheckMessage = !sent && !!msg.get && !pendingTimeouts.has(trackId!)
|
|
675
784
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
785
|
+
if (sent || isStaleCheckMessage) {
|
|
786
|
+
messageQueue.shift()
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (trackId && pendingTimeouts.has(trackId)) {
|
|
790
|
+
const lex = pendingTimeouts.get(trackId)!
|
|
791
|
+
if (sent) {
|
|
792
|
+
if (offlineTimeouts.has(trackId)) {
|
|
793
|
+
clearTimeout(offlineTimeouts.get(trackId)!)
|
|
794
|
+
offlineTimeouts.delete(trackId)
|
|
795
|
+
}
|
|
796
|
+
startResponseTimeout(trackId, lex)
|
|
797
|
+
} else if (!offlineTimeouts.has(trackId)) {
|
|
798
|
+
offlineTimeouts.set(trackId, startResponseTimeout(trackId, lex, 0))
|
|
688
799
|
}
|
|
689
800
|
}
|
|
690
801
|
|
|
@@ -781,32 +892,53 @@ const Wire = (opt: HolsterOptions): WireAPI => {
|
|
|
781
892
|
dup.track(msg["#"]!)
|
|
782
893
|
if (msg.get) get(msg as never, send as never)
|
|
783
894
|
if (msg.put) {
|
|
784
|
-
|
|
895
|
+
// Pre-pass: expand pendingReferences to include rels of every soul
|
|
896
|
+
// that will be stored in this batch. This handles the case where a
|
|
897
|
+
// server batch contains both a parent soul (e.g. day_soul with item
|
|
898
|
+
// rels) and its child souls (item_souls), in any order — without the
|
|
899
|
+
// pre-pass, a child soul that appears before its parent in the batch
|
|
900
|
+
// would be missed because pendingReferences hasn't been updated yet.
|
|
901
|
+
const hasSoulMap = new Map<string, boolean>()
|
|
785
902
|
for (const [soul, node] of Object.entries(msg.put)) {
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
if (soulId) {
|
|
793
|
-
pendingReferences.add(soulId)
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
}
|
|
903
|
+
const known = await hasSoul(soul)
|
|
904
|
+
hasSoulMap.set(soul, known)
|
|
905
|
+
if ((!known && !pendingReferences.has(soul)) || !node || typeof node !== "object") continue
|
|
906
|
+
for (const [, value] of Object.entries(node)) {
|
|
907
|
+
const soulId = utils.rel.is(value as GraphValue)
|
|
908
|
+
if (soulId) pendingReferences.add(soulId)
|
|
797
909
|
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Main pass: filter using cached hasSoul results and the now-complete
|
|
913
|
+
// pendingReferences set.
|
|
914
|
+
const filteredPut: Graph = {}
|
|
915
|
+
const pendingToDelete: string[] = []
|
|
916
|
+
for (const [soul, node] of Object.entries(msg.put)) {
|
|
917
|
+
let shouldStore = hasSoulMap.get(soul)
|
|
798
918
|
if (pendingReferences.has(soul)) {
|
|
799
919
|
shouldStore = true
|
|
800
|
-
pendingReferences
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
920
|
+
// Don't delete from pendingReferences yet — concurrent onmessage
|
|
921
|
+
// handlers for the same soul may have yielded at await hasSoul()
|
|
922
|
+
// before this pass ran. Deleting here would cause those handlers
|
|
923
|
+
// to see shouldStore=false and skip storing the soul's data.
|
|
924
|
+
// Instead, defer the delete until after put() updates the graph,
|
|
925
|
+
// so any concurrent handler can find the soul via hasSoul(graph).
|
|
926
|
+
pendingToDelete.push(soul)
|
|
804
927
|
}
|
|
928
|
+
if (shouldStore) filteredPut[soul] = node
|
|
805
929
|
}
|
|
930
|
+
|
|
931
|
+
// Store filtered data if any
|
|
806
932
|
if (Object.keys(filteredPut).length > 0) {
|
|
933
|
+
// Create filtered message for Ham.mix with original message ID
|
|
807
934
|
const filteredMsg = { put: filteredPut, "#": msg["#"]! }
|
|
808
935
|
await put(filteredMsg, send as never)
|
|
809
936
|
}
|
|
937
|
+
|
|
938
|
+
// Safe to delete now — put() has run Ham.mix which updates graph
|
|
939
|
+
// in-place, so hasSoul() will return true for these souls going
|
|
940
|
+
// forward, making pendingReferences no longer needed for them.
|
|
941
|
+
pendingToDelete.forEach(soul => pendingReferences.delete(soul))
|
|
810
942
|
}
|
|
811
943
|
|
|
812
944
|
const id = msg["@"]
|