@mblaney/holster 1.0.34 → 1.0.36
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 +4 -0
- package/dist/holster.js.map +7 -0
- package/package.json +23 -5
- package/src/get.js +11 -19
- package/src/ham.js +17 -47
- package/src/holster.js +106 -28
- package/src/sea.js +0 -52
- package/src/store.js +7 -7
- package/src/wire.js +8 -2
- package/ts/src/array.ts +78 -0
- package/ts/src/buffer.ts +441 -0
- package/ts/src/dup.ts +49 -0
- package/ts/src/get.ts +83 -0
- package/ts/src/ham.ts +254 -0
- package/ts/src/holster.ts +931 -0
- package/ts/src/index.ts +35 -0
- package/ts/src/radisk.ts +585 -0
- package/ts/src/radix.ts +157 -0
- package/ts/src/schemas.ts +519 -0
- package/ts/src/sea-utils.ts +103 -0
- package/ts/src/sea.ts +388 -0
- package/ts/src/store.ts +321 -0
- package/ts/src/user.ts +381 -0
- package/ts/src/utils.ts +232 -0
- package/ts/src/wire.ts +829 -0
- package/.dockerignore +0 -9
- package/.github/workflows/node.js.yml +0 -31
- package/.github/workflows/npm-publish.yml +0 -33
- package/Dockerfile +0 -20
- package/examples/index.html +0 -33
- package/examples/index.js +0 -49
- package/examples/user.html +0 -31
- package/examples/user.js +0 -93
- package/examples/wire.html +0 -27
- package/examples/wire.js +0 -55
- package/test/holster.get.test.js +0 -94
- package/test/holster.lex.test.js +0 -196
- package/test/holster.off.test.js +0 -53
- package/test/holster.on.test.js +0 -264
- package/test/holster.put.test.js +0 -358
- package/test/holster.secure.test.js +0 -202
- package/test/holster.user.get.test.js +0 -114
- package/test/holster.user.lex.test.js +0 -149
- package/test/holster.user.off.test.js +0 -73
- package/test/holster.user.on.test.js +0 -300
- package/test/holster.user.put.test.js +0 -463
- package/test/kitty/! +0 -9
- package/test/names.js +0 -225
- package/test/radisk.cache.test.js +0 -213
- package/test/radisk.test.js +0 -331
- package/test/radix.test.js +0 -117
- package/test/sea.test.js +0 -284
- package/test/split.test.js +0 -317
- package/test/store.test.js +0 -316
- package/test/system/basic.test.js +0 -29
- package/test/system/chained-on.test.js +0 -41
- package/test/system/concurrent-listeners.test.js +0 -125
- package/test/system/immediate-read.test.js +0 -30
- package/test/system/listener-before-data.test.js +0 -51
- package/test/system/promise-wrapper.test.js +0 -47
- package/test/system/user-chained-on.test.js +0 -60
- package/test/system/user-concurrent-listeners.test.js +0 -142
- package/test/system/user-listener-before-data.test.js +0 -66
- package/test/user.test.js +0 -173
- package/test/wire.test.js +0 -158
package/package.json
CHANGED
|
@@ -1,25 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mblaney/holster",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.36",
|
|
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
|
-
"
|
|
10
|
-
|
|
9
|
+
"test:ts": "node --test --import tsx/esm ts/test/**/*.test.ts",
|
|
10
|
+
"test:ts:system": "node --test --import tsx/esm ts/test/system/**/*.test.ts",
|
|
11
|
+
"build": "esbuild src/holster.js --bundle --format=esm --sourcemap \"--external:node:*\" \"--external:ws\" --minify --outdir=build",
|
|
12
|
+
"build:ts": "esbuild ts/src/holster.ts --bundle --format=esm --sourcemap \"--external:node:*\" \"--external:ws\" --minify --outdir=dist",
|
|
13
|
+
"build:all": "npm run build && npm run build:ts",
|
|
14
|
+
"prepublishOnly": "npm run build:all",
|
|
15
|
+
"type-check": "tsc --noEmit",
|
|
16
|
+
"type-check:tests": "tsc --noEmit --project tsconfig.test.json"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"ts/src",
|
|
21
|
+
"build",
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
11
24
|
"keywords": [],
|
|
12
25
|
"author": "Malcolm Blaney",
|
|
13
26
|
"license": "Zlib, MIT, Apache-2.0",
|
|
14
27
|
"dependencies": {
|
|
15
28
|
"express": "^5.1.0",
|
|
16
29
|
"fs": "^0.0.1-security",
|
|
17
|
-
"ws": "^8.18.0"
|
|
30
|
+
"ws": "^8.18.0",
|
|
31
|
+
"zod": "^4.1.13"
|
|
18
32
|
},
|
|
19
33
|
"devDependencies": {
|
|
34
|
+
"@types/node": "^24.10.1",
|
|
35
|
+
"@types/ws": "^8.18.1",
|
|
20
36
|
"esbuild": "0.25.3",
|
|
21
37
|
"mock-socket": "^9.3.1",
|
|
22
|
-
"prettier": "^3.4.2"
|
|
38
|
+
"prettier": "^3.4.2",
|
|
39
|
+
"tsx": "^4.0.0",
|
|
40
|
+
"typescript": "^5.9.3"
|
|
23
41
|
},
|
|
24
42
|
"prettier": {
|
|
25
43
|
"semi": false,
|
package/src/get.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {match, userPublicKey} from "./utils.js"
|
|
2
2
|
|
|
3
|
-
const Get = (lex, graph) => {
|
|
3
|
+
const Get = (lex, graph, fast) => {
|
|
4
4
|
if (!lex || typeof lex !== "object") {
|
|
5
5
|
throw new TypeError("lex must be an object")
|
|
6
6
|
}
|
|
@@ -25,12 +25,8 @@ const Get = (lex, graph) => {
|
|
|
25
25
|
node[key] = graph[soul][key]
|
|
26
26
|
node._[">"][key] = graph[soul]._[">"][key]
|
|
27
27
|
const state = graph[soul]._[">"][key]
|
|
28
|
-
if (graph[soul]._["s"]) {
|
|
29
|
-
|
|
30
|
-
signatures[state] = graph[soul]._["s"][state]
|
|
31
|
-
} else if (graph[soul]._["s"][key]) {
|
|
32
|
-
signatures[key] = graph[soul]._["s"][key]
|
|
33
|
-
}
|
|
28
|
+
if (graph[soul]._["s"] && graph[soul]._["s"][state]) {
|
|
29
|
+
signatures[state] = graph[soul]._["s"][state]
|
|
34
30
|
}
|
|
35
31
|
|
|
36
32
|
// Always include userPublicKey for verification, regardless of filter
|
|
@@ -38,27 +34,23 @@ const Get = (lex, graph) => {
|
|
|
38
34
|
node[userPublicKey] = graph[soul][userPublicKey]
|
|
39
35
|
node._[">"][userPublicKey] = graph[soul]._[">"][userPublicKey]
|
|
40
36
|
const pkState = graph[soul]._[">"][userPublicKey]
|
|
41
|
-
if (graph[soul]._["s"]) {
|
|
42
|
-
|
|
43
|
-
signatures[pkState] = graph[soul]._["s"][pkState]
|
|
44
|
-
} else if (graph[soul]._["s"][userPublicKey]) {
|
|
45
|
-
signatures[userPublicKey] = graph[soul]._["s"][userPublicKey]
|
|
46
|
-
}
|
|
37
|
+
if (graph[soul]._["s"] && graph[soul]._["s"][pkState]) {
|
|
38
|
+
signatures[pkState] = graph[soul]._["s"][pkState]
|
|
47
39
|
}
|
|
48
40
|
}
|
|
49
41
|
} else {
|
|
42
|
+
// For fast requests, return properties currently in the graph. Otherwise
|
|
43
|
+
// return undefined to ensure we get all matching properties from store.
|
|
44
|
+
if (!fast) return
|
|
45
|
+
|
|
50
46
|
for (const key of Object.keys(graph[soul])) {
|
|
51
47
|
// Always include userPublicKey for verification, regardless of filter
|
|
52
48
|
if (key === userPublicKey || match(lex["."], key)) {
|
|
53
49
|
node[key] = graph[soul][key]
|
|
54
50
|
node._[">"][key] = graph[soul]._[">"][key]
|
|
55
51
|
const state = graph[soul]._[">"][key]
|
|
56
|
-
if (graph[soul]._["s"]) {
|
|
57
|
-
|
|
58
|
-
signatures[state] = graph[soul]._["s"][state]
|
|
59
|
-
} else if (graph[soul]._["s"][key]) {
|
|
60
|
-
signatures[key] = graph[soul]._["s"][key]
|
|
61
|
-
}
|
|
52
|
+
if (graph[soul]._["s"] && graph[soul]._["s"][state]) {
|
|
53
|
+
signatures[state] = graph[soul]._["s"][state]
|
|
62
54
|
}
|
|
63
55
|
}
|
|
64
56
|
}
|
package/src/ham.js
CHANGED
|
@@ -102,21 +102,21 @@ Ham.mix = async (change, graph, secure, listen) => {
|
|
|
102
102
|
for (const [key, sig] of Object.entries(node._["s"] || {})) {
|
|
103
103
|
const asNumber = Number(key)
|
|
104
104
|
if (!isNaN(asNumber) && asNumber > 0) {
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
// Skip verification only if ALL properties for this timestamp are
|
|
106
|
+
// already newer in the graph. Using some() would incorrectly skip
|
|
107
|
+
// valid updates when only one property in a batch is outdated.
|
|
108
|
+
const propsForTimestamp = incomingTimestamps.filter(
|
|
109
|
+
([, ts]) => Number(ts) === asNumber,
|
|
110
|
+
)
|
|
111
|
+
const allNewerInGraph =
|
|
112
|
+
propsForTimestamp.length > 0 &&
|
|
113
|
+
propsForTimestamp.every(([prop]) => {
|
|
109
114
|
const graphState =
|
|
110
115
|
graph[soul] && graph[soul]._[">"] ? graph[soul]._[">"][prop] : 0
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return true
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return false
|
|
117
|
-
})
|
|
116
|
+
return graphState > asNumber
|
|
117
|
+
})
|
|
118
118
|
|
|
119
|
-
if (
|
|
119
|
+
if (allNewerInGraph) {
|
|
120
120
|
continue
|
|
121
121
|
}
|
|
122
122
|
|
|
@@ -133,28 +133,6 @@ Ham.mix = async (change, graph, secure, listen) => {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
// TODO: Remove per-property signature verification after old data has
|
|
137
|
-
// been cleared. Only check per-property signatures if there are
|
|
138
|
-
// properties without timestamp signatures.
|
|
139
|
-
const propertiesWithoutTimestampSigs = Object.keys(node).filter(k => {
|
|
140
|
-
if (k === "_" || k === utils.userPublicKey) return false
|
|
141
|
-
return !incomingTimestamps.some(
|
|
142
|
-
([prop, ts]) => prop === k && signedTimestamps.has(Number(ts)),
|
|
143
|
-
)
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
if (propertiesWithoutTimestampSigs.length > 0) {
|
|
147
|
-
const nodeToVerify = {_: node._}
|
|
148
|
-
for (const prop of propertiesWithoutTimestampSigs) {
|
|
149
|
-
nodeToVerify[prop] = node[prop]
|
|
150
|
-
}
|
|
151
|
-
const perPropertySignedProps = await SEA.verifyProperties(
|
|
152
|
-
nodeToVerify,
|
|
153
|
-
pub,
|
|
154
|
-
)
|
|
155
|
-
perPropertySignedProps.forEach(prop => signedProperties.add(prop))
|
|
156
|
-
}
|
|
157
|
-
|
|
158
136
|
// If no properties verified, skip this update
|
|
159
137
|
if (signedProperties.size === 0) {
|
|
160
138
|
continue
|
|
@@ -196,12 +174,8 @@ Ham.mix = async (change, graph, secure, listen) => {
|
|
|
196
174
|
}
|
|
197
175
|
defer[soul][key] = value
|
|
198
176
|
defer[soul]._[">"][key] = state
|
|
199
|
-
if (node._["s"]) {
|
|
200
|
-
|
|
201
|
-
defer[soul]._["s"][state] = node._["s"][state]
|
|
202
|
-
} else if (node._["s"][key]) {
|
|
203
|
-
defer[soul]._["s"][key] = node._["s"][key]
|
|
204
|
-
}
|
|
177
|
+
if (node._["s"] && node._["s"][state]) {
|
|
178
|
+
defer[soul]._["s"][state] = node._["s"][state]
|
|
205
179
|
}
|
|
206
180
|
} else {
|
|
207
181
|
// Check if this property has a signed timestamp
|
|
@@ -217,13 +191,9 @@ Ham.mix = async (change, graph, secure, listen) => {
|
|
|
217
191
|
}
|
|
218
192
|
graph[soul][key] = now[soul][key] = value
|
|
219
193
|
graph[soul]._[">"][key] = now[soul]._[">"][key] = state
|
|
220
|
-
if (node._["s"]) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
node._["s"][state]
|
|
224
|
-
} else if (node._["s"][key]) {
|
|
225
|
-
graph[soul]._["s"][key] = now[soul]._["s"][key] = node._["s"][key]
|
|
226
|
-
}
|
|
194
|
+
if (node._["s"] && node._["s"][state]) {
|
|
195
|
+
graph[soul]._["s"][state] = now[soul]._["s"][state] =
|
|
196
|
+
node._["s"][state]
|
|
227
197
|
}
|
|
228
198
|
// Collect event listeners to fire after store.put completes.
|
|
229
199
|
// This ensures listeners always fire with data that's been stored.
|
package/src/holster.js
CHANGED
|
@@ -132,6 +132,30 @@ const Holster = opt => {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
const watchForRel = (soul, item, ctx, i, request, cb) => {
|
|
136
|
+
const handler = () => {
|
|
137
|
+
// Bail out if the context was removed by off().
|
|
138
|
+
if (!allctx.has(ctxid)) {
|
|
139
|
+
wire.off({"#": soul, ".": item}, handler)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
wire.get(
|
|
143
|
+
{"#": soul, ".": item},
|
|
144
|
+
msg => {
|
|
145
|
+
const node = msg.put && msg.put[soul]
|
|
146
|
+
const id = utils.rel.is(node && node[item])
|
|
147
|
+
if (!id) return
|
|
148
|
+
wire.off({"#": soul, ".": item}, handler)
|
|
149
|
+
ctx.chain[i].soul = id
|
|
150
|
+
allctx.set(ctxid, {...ctx})
|
|
151
|
+
api(ctxid).on(request.on, cb, request._get, request._opt)
|
|
152
|
+
},
|
|
153
|
+
{...request._opt, secure: ctx.user || opt.secure},
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
wire.on({"#": soul, ".": item}, handler, false, request._opt)
|
|
157
|
+
}
|
|
158
|
+
|
|
135
159
|
const resolve = (request, cb) => {
|
|
136
160
|
if (!request) {
|
|
137
161
|
console.log("error resolve request parameter required")
|
|
@@ -201,8 +225,10 @@ const Holster = opt => {
|
|
|
201
225
|
api(ctxid).put(request.put, cb)
|
|
202
226
|
})
|
|
203
227
|
} else if (on) {
|
|
204
|
-
//
|
|
205
|
-
|
|
228
|
+
// Item is not a rel yet — watch the parent soul for when it
|
|
229
|
+
// becomes one, then retry chain resolution.
|
|
230
|
+
watchForRel(soul, item, ctx, i, request, cb)
|
|
231
|
+
if (request._get) cb(null)
|
|
206
232
|
} else if (off) {
|
|
207
233
|
// Allow stop listening to a node that doesn't exist.
|
|
208
234
|
if (cb) cb(null)
|
|
@@ -225,8 +251,14 @@ const Holster = opt => {
|
|
|
225
251
|
api(ctxid).put(request.put, cb)
|
|
226
252
|
})
|
|
227
253
|
} else {
|
|
228
|
-
|
|
229
|
-
|
|
254
|
+
if (on) {
|
|
255
|
+
// Node doesn't exist yet — watch for it to appear.
|
|
256
|
+
watchForRel(soul, item, ctx, i, request, cb)
|
|
257
|
+
if (request._get) cb(null)
|
|
258
|
+
} else {
|
|
259
|
+
// Allow querying a node that doesn't exist.
|
|
260
|
+
if (cb) cb(null)
|
|
261
|
+
}
|
|
230
262
|
}
|
|
231
263
|
},
|
|
232
264
|
{...request._opt, secure: ctx.user || opt.secure},
|
|
@@ -521,25 +553,16 @@ const Holster = opt => {
|
|
|
521
553
|
return
|
|
522
554
|
}
|
|
523
555
|
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
_ack(`error getting ${id}: ${msg.err}`)
|
|
530
|
-
return
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
let node = msg.put && msg.put[id]
|
|
534
|
-
if (!node) node = {}
|
|
535
|
-
update.forEach(key => {
|
|
536
|
-
node[key] = data[key]
|
|
537
|
-
})
|
|
538
|
-
const g = await graph(id, node, ctx.user, _ack)
|
|
539
|
-
if (g === null) return
|
|
540
|
-
|
|
541
|
-
wire.put(g, _ack)
|
|
556
|
+
// Only write the primitive update keys. Rels created by the
|
|
557
|
+
// recursive puts above are already in the in-memory graph.
|
|
558
|
+
const updateNode = {}
|
|
559
|
+
update.forEach(key => {
|
|
560
|
+
updateNode[key] = data[key]
|
|
542
561
|
})
|
|
562
|
+
const g = await graph(id, updateNode, ctx.user, _ack)
|
|
563
|
+
if (g === null) return
|
|
564
|
+
|
|
565
|
+
wire.put(g, _ack)
|
|
543
566
|
},
|
|
544
567
|
{secure: ctx.user || opt.secure},
|
|
545
568
|
)
|
|
@@ -579,13 +602,28 @@ const Holster = opt => {
|
|
|
579
602
|
})
|
|
580
603
|
// Map the user's callback because it can also be passed to off,
|
|
581
604
|
// so need a reference to it to compare them.
|
|
582
|
-
//
|
|
605
|
+
// Shared retry state for this on() subscription.
|
|
606
|
+
let retryCount = 0
|
|
607
|
+
const maxRetries = 5
|
|
608
|
+
const retryDelay = 1000 // Start with 1 second
|
|
609
|
+
let retryTimer = null
|
|
610
|
+
|
|
583
611
|
map.set(cb, () => {
|
|
612
|
+
// Bail out if off() has already cleaned up this context — the mapped
|
|
613
|
+
// callback can fire twice (once from wire.on and once from the _get
|
|
614
|
+
// immediate-read path) and the second call after async context cleanup
|
|
615
|
+
// would crash in resolve(). Matches the guard in watchForRel.
|
|
616
|
+
if (!allctx.has(ctxid)) return
|
|
617
|
+
// Cancel any pending retry — the persistent listener firing means we
|
|
618
|
+
// have an update and should not call cb twice.
|
|
619
|
+
clearTimeout(retryTimer)
|
|
620
|
+
retryTimer = null
|
|
584
621
|
// When listener fires, re-check if the item is now a rel.
|
|
585
622
|
wire.get(
|
|
586
623
|
{"#": soul, ".": item},
|
|
587
624
|
msg => {
|
|
588
|
-
const
|
|
625
|
+
const node = msg.put && msg.put[soul]
|
|
626
|
+
const current = node && node[item]
|
|
589
627
|
const id = utils.rel.is(current)
|
|
590
628
|
if (id) {
|
|
591
629
|
// It's a rel, read the related node. It might not be in the
|
|
@@ -597,9 +635,10 @@ const Holster = opt => {
|
|
|
597
635
|
chain: [{item: null, soul: id}],
|
|
598
636
|
user: ctx ? ctx.user : null,
|
|
599
637
|
})
|
|
600
|
-
api(_ctxid).next(null, res, _opt)
|
|
638
|
+
api(_ctxid).next(null, res, retries === 0 ? utils.obj.put(_opt, "fast", true) : _opt)
|
|
601
639
|
})
|
|
602
640
|
if (data !== null || retries >= 5) {
|
|
641
|
+
retryCount = 0 // Reset retry counter on success
|
|
603
642
|
cb(data)
|
|
604
643
|
} else {
|
|
605
644
|
// Data not ready, retry after delay
|
|
@@ -609,8 +648,46 @@ const Holster = opt => {
|
|
|
609
648
|
}
|
|
610
649
|
attemptRead()
|
|
611
650
|
} else {
|
|
612
|
-
// It's a direct property or null
|
|
613
|
-
|
|
651
|
+
// It's a direct property or null.
|
|
652
|
+
const nodeExists = !!node
|
|
653
|
+
|
|
654
|
+
if (nodeExists) {
|
|
655
|
+
// Node exists — return the value and reset retry state.
|
|
656
|
+
retryCount = 0
|
|
657
|
+
cb(current !== undefined ? current : null)
|
|
658
|
+
} else if (retryCount < maxRetries) {
|
|
659
|
+
// No data yet — keep polling until we get a response or
|
|
660
|
+
// exhaust retries. on() with _get can return null on a cache
|
|
661
|
+
// miss and never fire again if the data doesn't change while
|
|
662
|
+
// the listener is attached, so we must poll for initial load.
|
|
663
|
+
const retry = () => {
|
|
664
|
+
const delay = Math.min(retryDelay * Math.pow(2, retryCount), 30000)
|
|
665
|
+
retryCount++
|
|
666
|
+
retryTimer = setTimeout(() => {
|
|
667
|
+
wire.get(
|
|
668
|
+
{"#": soul, ".": item},
|
|
669
|
+
msg => {
|
|
670
|
+
const retryNode = msg.put && msg.put[soul]
|
|
671
|
+
if (retryNode) {
|
|
672
|
+
retryCount = 0
|
|
673
|
+
retryTimer = null
|
|
674
|
+
const retryValue = retryNode[item]
|
|
675
|
+
cb(retryValue !== undefined ? retryValue : null)
|
|
676
|
+
} else if (retryCount < maxRetries) {
|
|
677
|
+
retry()
|
|
678
|
+
} else {
|
|
679
|
+
retryTimer = null
|
|
680
|
+
cb(null)
|
|
681
|
+
}
|
|
682
|
+
},
|
|
683
|
+
{..._opt, secure: ctx.user || opt.secure},
|
|
684
|
+
)
|
|
685
|
+
}, delay)
|
|
686
|
+
}
|
|
687
|
+
retry()
|
|
688
|
+
} else {
|
|
689
|
+
cb(null)
|
|
690
|
+
}
|
|
614
691
|
}
|
|
615
692
|
},
|
|
616
693
|
{..._opt, secure: ctx.user || opt.secure},
|
|
@@ -653,7 +730,8 @@ const Holster = opt => {
|
|
|
653
730
|
let relLex
|
|
654
731
|
if (lex) relLex = utils.obj.put(initialLex, "#", id)
|
|
655
732
|
else relLex = {"#": id, ".": null}
|
|
656
|
-
wire.on(relLex, map.get(cb),
|
|
733
|
+
wire.on(relLex, map.get(cb), false, _opt)
|
|
734
|
+
if (_get) map.get(cb)()
|
|
657
735
|
} else if (_get) {
|
|
658
736
|
// Not a rel, but _get was requested, so trigger callback.
|
|
659
737
|
map.get(cb)()
|
package/src/sea.js
CHANGED
|
@@ -238,58 +238,6 @@ const SEA = {
|
|
|
238
238
|
return false
|
|
239
239
|
}
|
|
240
240
|
},
|
|
241
|
-
// Verify individual property signatures stored in node._["s"]
|
|
242
|
-
// Returns array of valid property names
|
|
243
|
-
verifyProperties: async (node, pub, cb) => {
|
|
244
|
-
if (!pub || !node) {
|
|
245
|
-
if (cb) cb([])
|
|
246
|
-
return []
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const key = await utils.subtle.importKey(
|
|
250
|
-
"jwk",
|
|
251
|
-
utils.jwk(pub),
|
|
252
|
-
{name: "ECDSA", namedCurve: "P-256"},
|
|
253
|
-
false,
|
|
254
|
-
["verify"],
|
|
255
|
-
)
|
|
256
|
-
|
|
257
|
-
const alg = {name: "ECDSA", hash: {name: "SHA-256"}}
|
|
258
|
-
const valid = []
|
|
259
|
-
const propertySignatures = (node._ && node._["s"]) || {}
|
|
260
|
-
|
|
261
|
-
// Verify each property individually
|
|
262
|
-
for (const k of Object.keys(node).sort()) {
|
|
263
|
-
if (k === "_" || k == userPublicKey) continue
|
|
264
|
-
|
|
265
|
-
const propSig = propertySignatures[k]
|
|
266
|
-
if (!propSig) {
|
|
267
|
-
continue
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
try {
|
|
271
|
-
const hash = await utils.sha256(node[k])
|
|
272
|
-
const sig = new Uint8Array(SafeBuffer.from(propSig, "base64"))
|
|
273
|
-
|
|
274
|
-
const isValid = await utils.subtle.verify(
|
|
275
|
-
alg,
|
|
276
|
-
key,
|
|
277
|
-
sig,
|
|
278
|
-
new Uint8Array(hash),
|
|
279
|
-
)
|
|
280
|
-
if (isValid) {
|
|
281
|
-
valid.push(k)
|
|
282
|
-
} else {
|
|
283
|
-
console.log(`warning: property '${k}' signature verification failed`)
|
|
284
|
-
}
|
|
285
|
-
} catch (err) {
|
|
286
|
-
console.log(`warning: property '${k}' error verifying: ${err.message}`)
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
if (cb) cb(valid)
|
|
291
|
-
return valid
|
|
292
|
-
},
|
|
293
241
|
work: async (data, salt, cb) => {
|
|
294
242
|
if (typeof salt === "function") {
|
|
295
243
|
cb = salt
|
package/src/store.js
CHANGED
|
@@ -112,6 +112,7 @@ const fileSystem = opt => {
|
|
|
112
112
|
const req = tx.objectStore(dir).get(file)
|
|
113
113
|
req.onerror = () => {
|
|
114
114
|
console.log(`error getting ${dir}/${file}`)
|
|
115
|
+
cb(req.error)
|
|
115
116
|
}
|
|
116
117
|
req.onsuccess = () => {
|
|
117
118
|
cb(null, req.result)
|
|
@@ -132,6 +133,7 @@ const fileSystem = opt => {
|
|
|
132
133
|
const req = tx.objectStore(dir).put(data, file)
|
|
133
134
|
req.onerror = () => {
|
|
134
135
|
console.log(`error putting data on ${dir}/${file}`)
|
|
136
|
+
cb(req.error)
|
|
135
137
|
}
|
|
136
138
|
req.onsuccess = () => {
|
|
137
139
|
cb(null)
|
|
@@ -150,7 +152,10 @@ const fileSystem = opt => {
|
|
|
150
152
|
const _list = cb => {
|
|
151
153
|
const tx = db.transaction([dir], "readonly")
|
|
152
154
|
const req = tx.objectStore(dir).getAllKeys()
|
|
153
|
-
req.onerror = () =>
|
|
155
|
+
req.onerror = () => {
|
|
156
|
+
console.log("error getting keys for", dir)
|
|
157
|
+
cb()
|
|
158
|
+
}
|
|
154
159
|
req.onsuccess = () => {
|
|
155
160
|
req.result.forEach(cb)
|
|
156
161
|
cb()
|
|
@@ -217,12 +222,7 @@ const Store = opt => {
|
|
|
217
222
|
node._[">"][key] = value[1]
|
|
218
223
|
// If signature is present, store it in _["s"]
|
|
219
224
|
if (value.length === 3 && value[2]) {
|
|
220
|
-
|
|
221
|
-
// Store signature under both state and key for compatibility
|
|
222
|
-
signatures[state] = value[2]
|
|
223
|
-
// TODO: Can probably remove the following when old data is no
|
|
224
|
-
// longer a problem.
|
|
225
|
-
signatures[key] = value[2]
|
|
225
|
+
signatures[value[1]] = value[2]
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
|
package/src/wire.js
CHANGED
|
@@ -316,7 +316,7 @@ const Wire = opt => {
|
|
|
316
316
|
|
|
317
317
|
if (!utils.obj.is(_opt)) _opt = {}
|
|
318
318
|
|
|
319
|
-
const ack = Get(lex, graph)
|
|
319
|
+
const ack = Get(lex, graph, _opt.fast)
|
|
320
320
|
const track = utils.text.random(9)
|
|
321
321
|
const request = JSON.stringify({
|
|
322
322
|
"#": dup.track(track),
|
|
@@ -338,13 +338,19 @@ const Wire = opt => {
|
|
|
338
338
|
lex,
|
|
339
339
|
(err, ack) => {
|
|
340
340
|
if (ack) {
|
|
341
|
+
// Populate in-memory graph from store data before sending the server
|
|
342
|
+
// request, so stale server responses with older state are correctly
|
|
343
|
+
// rejected by ham.mix.
|
|
344
|
+
for (const [soul, node] of Object.entries(ack)) {
|
|
345
|
+
if (!graph[soul] && node) graph[soul] = {...node}
|
|
346
|
+
}
|
|
341
347
|
// Also send request on the wire to check for updates.
|
|
342
348
|
const sendResult = send(request)
|
|
343
349
|
if (sendResult && sendResult.err) {
|
|
344
350
|
cb({err: sendResult.err})
|
|
345
351
|
return
|
|
346
352
|
}
|
|
347
|
-
cb({put: ack, err: err})
|
|
353
|
+
cb(err ? {put: ack, err: err} : {put: ack})
|
|
348
354
|
return
|
|
349
355
|
}
|
|
350
356
|
|
package/ts/src/array.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SeaArray - Extended Array with custom toString for encoding support
|
|
3
|
+
* Supports utf8, hex, and base64 encoding
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
if (typeof btoa === "undefined") {
|
|
7
|
+
globalThis.btoa = (data: string): string =>
|
|
8
|
+
Buffer.from(data, "binary").toString("base64")
|
|
9
|
+
globalThis.atob = (data: string): string =>
|
|
10
|
+
Buffer.from(data, "base64").toString("binary")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Encoding = "utf8" | "hex" | "base64"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* SeaArray extends Array to provide encoding capabilities
|
|
17
|
+
*/
|
|
18
|
+
class SeaArray extends Array<number> {
|
|
19
|
+
/**
|
|
20
|
+
* Convert array to string with specified encoding
|
|
21
|
+
* @param enc - Encoding type (utf8, hex, or base64)
|
|
22
|
+
* @param start - Start index (default: 0)
|
|
23
|
+
* @param end - End index (default: array length)
|
|
24
|
+
*/
|
|
25
|
+
override toString(enc?: Encoding, start?: number, end?: number): string {
|
|
26
|
+
const encoding = enc || "utf8"
|
|
27
|
+
const startIdx = start || 0
|
|
28
|
+
const endIdx = end ? Math.min(Math.max(end, startIdx), this.length) : this.length
|
|
29
|
+
|
|
30
|
+
if (encoding === "hex") {
|
|
31
|
+
const buf = new Uint8Array(this.slice(startIdx, endIdx))
|
|
32
|
+
return Array.from(buf, byte => byte.toString(16).padStart(2, "0")).join("")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (encoding === "utf8") {
|
|
36
|
+
return Array.from({ length: endIdx - startIdx }, (_, i) => {
|
|
37
|
+
const charCode = this[i + startIdx]
|
|
38
|
+
// Use unicode replacement character for invalid character codes
|
|
39
|
+
if (
|
|
40
|
+
charCode === undefined ||
|
|
41
|
+
charCode < 0 ||
|
|
42
|
+
charCode > 0x10ffff
|
|
43
|
+
) {
|
|
44
|
+
return String.fromCharCode(0xfffd)
|
|
45
|
+
}
|
|
46
|
+
return String.fromCharCode(charCode)
|
|
47
|
+
}).join("")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (encoding === "base64") {
|
|
51
|
+
const utf8String = Array.from({ length: endIdx - startIdx }, (_, i) => {
|
|
52
|
+
const charCode = this[i + startIdx]
|
|
53
|
+
if (charCode === undefined || charCode < 0 || charCode > 255) {
|
|
54
|
+
return String.fromCharCode(0xfffd)
|
|
55
|
+
}
|
|
56
|
+
return String.fromCharCode(charCode)
|
|
57
|
+
}).join("")
|
|
58
|
+
return btoa(utf8String)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Default to utf8
|
|
62
|
+
return this.toString("utf8", startIdx, endIdx)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create SeaArray from iterable or array-like
|
|
67
|
+
*/
|
|
68
|
+
static override from<T>(
|
|
69
|
+
arrayLike: ArrayLike<T> | Iterable<T>
|
|
70
|
+
): SeaArray {
|
|
71
|
+
const arr = Array.from(arrayLike) as number[]
|
|
72
|
+
const seaArray = new SeaArray(...arr)
|
|
73
|
+
return seaArray
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default SeaArray
|
|
78
|
+
|