@mblaney/holster 1.0.35 → 2.0.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/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 +22 -5
- package/src/dup.js +4 -5
- package/src/get.js +10 -19
- package/src/ham.js +22 -49
- package/src/holster.js +172 -58
- package/src/index.js +1 -1
- package/src/radisk.js +89 -86
- package/src/sea-utils.js +1 -1
- package/src/sea.js +0 -52
- package/src/store.js +8 -8
- package/src/user.js +0 -4
- package/src/wire.js +209 -79
- package/ts/src/array.ts +78 -0
- package/ts/src/buffer.ts +441 -0
- package/ts/src/dup.ts +38 -0
- package/ts/src/get.ts +83 -0
- package/ts/src/ham.ts +258 -0
- package/ts/src/holster.ts +968 -0
- package/ts/src/index.ts +35 -0
- package/ts/src/radisk.ts +591 -0
- package/ts/src/radix.ts +157 -0
- package/ts/src/schemas.ts +515 -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 +377 -0
- package/ts/src/utils.ts +232 -0
- package/ts/src/wire.ts +961 -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,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mblaney/holster",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.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
8
|
"test": "node --test",
|
|
9
|
-
"
|
|
10
|
-
|
|
9
|
+
"test:ts": "node --test --import tsx/esm ts/test/*.test.ts ts/test/system/*.test.ts",
|
|
10
|
+
"build": "esbuild src/holster.js --bundle --format=esm --sourcemap \"--external:node:*\" \"--external:ws\" --minify --outdir=build",
|
|
11
|
+
"build:ts": "esbuild ts/src/holster.ts --bundle --format=esm --sourcemap \"--external:node:*\" \"--external:ws\" --minify --outdir=dist",
|
|
12
|
+
"build:all": "npm run build && npm run build:ts",
|
|
13
|
+
"prepublishOnly": "npm run build:all",
|
|
14
|
+
"type-check": "tsc -p tsconfig.json",
|
|
15
|
+
"type-check:tests": "tsc -p tsconfig.test.json"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"ts/src",
|
|
20
|
+
"build",
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
11
23
|
"keywords": [],
|
|
12
24
|
"author": "Malcolm Blaney",
|
|
13
25
|
"license": "Zlib, MIT, Apache-2.0",
|
|
14
26
|
"dependencies": {
|
|
15
27
|
"express": "^5.1.0",
|
|
16
28
|
"fs": "^0.0.1-security",
|
|
17
|
-
"ws": "^8.18.0"
|
|
29
|
+
"ws": "^8.18.0",
|
|
30
|
+
"zod": "^4.1.13"
|
|
18
31
|
},
|
|
19
32
|
"devDependencies": {
|
|
33
|
+
"@types/node": "^24.10.1",
|
|
34
|
+
"@types/ws": "^8.18.1",
|
|
20
35
|
"esbuild": "0.25.3",
|
|
21
36
|
"mock-socket": "^9.3.1",
|
|
22
|
-
"prettier": "^3.4.2"
|
|
37
|
+
"prettier": "^3.4.2",
|
|
38
|
+
"tsx": "^4.0.0",
|
|
39
|
+
"typescript": "^5.9.3"
|
|
23
40
|
},
|
|
24
41
|
"prettier": {
|
|
25
42
|
"semi": false,
|
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
|
@@ -25,12 +25,8 @@ const Get = (lex, graph, fast) => {
|
|
|
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,12 +34,8 @@ const Get = (lex, graph, fast) => {
|
|
|
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 {
|
|
@@ -51,21 +43,20 @@ const Get = (lex, graph, fast) => {
|
|
|
51
43
|
// return undefined to ensure we get all matching properties from store.
|
|
52
44
|
if (!fast) return
|
|
53
45
|
|
|
46
|
+
let found = false
|
|
54
47
|
for (const key of Object.keys(graph[soul])) {
|
|
55
48
|
// Always include userPublicKey for verification, regardless of filter
|
|
56
|
-
if (key === userPublicKey || match(lex["."], key)) {
|
|
49
|
+
if (key !== "_" && (key === userPublicKey || match(lex["."], key))) {
|
|
57
50
|
node[key] = graph[soul][key]
|
|
58
51
|
node._[">"][key] = graph[soul]._[">"][key]
|
|
59
52
|
const state = graph[soul]._[">"][key]
|
|
60
|
-
if (graph[soul]._["s"]) {
|
|
61
|
-
|
|
62
|
-
signatures[state] = graph[soul]._["s"][state]
|
|
63
|
-
} else if (graph[soul]._["s"][key]) {
|
|
64
|
-
signatures[key] = graph[soul]._["s"][key]
|
|
65
|
-
}
|
|
53
|
+
if (graph[soul]._["s"] && graph[soul]._["s"][state]) {
|
|
54
|
+
signatures[state] = graph[soul]._["s"][state]
|
|
66
55
|
}
|
|
56
|
+
found = true
|
|
67
57
|
}
|
|
68
58
|
}
|
|
59
|
+
if (!found) return
|
|
69
60
|
}
|
|
70
61
|
if (Object.keys(signatures).length > 0) {
|
|
71
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
|
}
|
|
@@ -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.
|
|
@@ -256,7 +226,10 @@ Ham.mix = async (change, graph, secure, listen) => {
|
|
|
256
226
|
})
|
|
257
227
|
.sort((a, b) => a.maxState - b.maxState)
|
|
258
228
|
const remove = soulsByAge.slice(0, souls.length - MAX_GRAPH_SIZE)
|
|
259
|
-
remove.forEach(({soul}) =>
|
|
229
|
+
remove.forEach(({soul}) => {
|
|
230
|
+
delete graph[soul]
|
|
231
|
+
if (hasNode) hasNode.delete(soul)
|
|
232
|
+
})
|
|
260
233
|
}
|
|
261
234
|
|
|
262
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,6 +130,55 @@ const Holster = opt => {
|
|
|
132
130
|
}
|
|
133
131
|
}
|
|
134
132
|
|
|
133
|
+
const watchForRel = (soul, item, ctx, i, request, cb, on = true) => {
|
|
134
|
+
let timer = null
|
|
135
|
+
const handler = () => {
|
|
136
|
+
// Bail out if the context was removed by off().
|
|
137
|
+
if (!allctx.has(ctxid)) {
|
|
138
|
+
wire.off({"#": soul, ".": item}, handler)
|
|
139
|
+
if (timer) clearTimeout(timer)
|
|
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) {
|
|
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)
|
|
158
|
+
wire.off({"#": soul, ".": item}, handler)
|
|
159
|
+
ctx.chain[i].soul = id
|
|
160
|
+
allctx.set(ctxid, {...ctx})
|
|
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
|
+
}
|
|
166
|
+
},
|
|
167
|
+
{...request._opt, secure: !!(ctx.user || opt.secure)},
|
|
168
|
+
)
|
|
169
|
+
}
|
|
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
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
135
182
|
const resolve = (request, cb) => {
|
|
136
183
|
if (!request) {
|
|
137
184
|
console.log("error resolve request parameter required")
|
|
@@ -145,6 +192,7 @@ const Holster = opt => {
|
|
|
145
192
|
|
|
146
193
|
let found = false
|
|
147
194
|
const ctx = allctx.get(ctxid)
|
|
195
|
+
if (!ctx) return {item: null, soul: null}
|
|
148
196
|
for (var i = 1; i < ctx.chain.length; i++) {
|
|
149
197
|
if (ctx.chain[i].soul !== null) continue
|
|
150
198
|
|
|
@@ -201,8 +249,10 @@ const Holster = opt => {
|
|
|
201
249
|
api(ctxid).put(request.put, cb)
|
|
202
250
|
})
|
|
203
251
|
} else if (on) {
|
|
204
|
-
//
|
|
205
|
-
|
|
252
|
+
// Item is not a rel yet — watch the parent soul for when it
|
|
253
|
+
// becomes one, then retry chain resolution.
|
|
254
|
+
watchForRel(soul, item, ctx, i, request, cb)
|
|
255
|
+
if (request._get) cb(null)
|
|
206
256
|
} else if (off) {
|
|
207
257
|
// Allow stop listening to a node that doesn't exist.
|
|
208
258
|
if (cb) cb(null)
|
|
@@ -225,11 +275,26 @@ const Holster = opt => {
|
|
|
225
275
|
api(ctxid).put(request.put, cb)
|
|
226
276
|
})
|
|
227
277
|
} else {
|
|
228
|
-
|
|
229
|
-
|
|
278
|
+
if (on) {
|
|
279
|
+
// Node doesn't exist yet — watch for it to appear.
|
|
280
|
+
watchForRel(soul, item, ctx, i, request, cb)
|
|
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
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
// Soul doesn't exist or no get — return null.
|
|
293
|
+
if (cb) cb(null)
|
|
294
|
+
}
|
|
230
295
|
}
|
|
231
296
|
},
|
|
232
|
-
{...request._opt, secure: ctx.user || opt.secure},
|
|
297
|
+
{...request._opt, secure: !!(ctx.user || opt.secure)},
|
|
233
298
|
)
|
|
234
299
|
// Callback has been passed to next soul lookup or called above, so
|
|
235
300
|
// return false as the calling code should not continue.
|
|
@@ -374,7 +439,7 @@ const Holster = opt => {
|
|
|
374
439
|
|
|
375
440
|
// Resolve the current context before putting data. (Note that set is
|
|
376
441
|
// not passed to resolve because it's already been applied above.)
|
|
377
|
-
const {item, soul} = resolve({put: data}, _ack)
|
|
442
|
+
const {item, soul} = resolve({put: data, _opt: {put: true}}, _ack)
|
|
378
443
|
if (!soul) return
|
|
379
444
|
|
|
380
445
|
if (result === true) {
|
|
@@ -443,7 +508,7 @@ const Holster = opt => {
|
|
|
443
508
|
wire.put(g, _ack)
|
|
444
509
|
})
|
|
445
510
|
},
|
|
446
|
-
{secure: ctx.user || opt.secure},
|
|
511
|
+
{secure: !!(ctx.user || opt.secure), put: true},
|
|
447
512
|
)
|
|
448
513
|
return
|
|
449
514
|
}
|
|
@@ -483,7 +548,7 @@ const Holster = opt => {
|
|
|
483
548
|
} else {
|
|
484
549
|
const _ctxid = utils.text.random()
|
|
485
550
|
const chain = [{item: item, soul: soul}]
|
|
486
|
-
// Pass on the previous
|
|
551
|
+
// Pass on the previous ctx's callback and user flag here.
|
|
487
552
|
allctx.set(_ctxid, {
|
|
488
553
|
chain: chain,
|
|
489
554
|
user: ctx.user,
|
|
@@ -521,27 +586,18 @@ const Holster = opt => {
|
|
|
521
586
|
return
|
|
522
587
|
}
|
|
523
588
|
|
|
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)
|
|
589
|
+
// Only write the primitive update keys. Rels created by the
|
|
590
|
+
// recursive puts above are already in the in-memory graph.
|
|
591
|
+
const updateNode = {}
|
|
592
|
+
update.forEach(key => {
|
|
593
|
+
updateNode[key] = data[key]
|
|
542
594
|
})
|
|
595
|
+
const g = await graph(id, updateNode, ctx.user, _ack)
|
|
596
|
+
if (g === null) return
|
|
597
|
+
|
|
598
|
+
wire.put(g, _ack)
|
|
543
599
|
},
|
|
544
|
-
{secure: ctx.user || opt.secure},
|
|
600
|
+
{secure: !!(ctx.user || opt.secure), put: true},
|
|
545
601
|
)
|
|
546
602
|
}
|
|
547
603
|
attemptRead()
|
|
@@ -579,13 +635,28 @@ const Holster = opt => {
|
|
|
579
635
|
})
|
|
580
636
|
// Map the user's callback because it can also be passed to off,
|
|
581
637
|
// so need a reference to it to compare them.
|
|
582
|
-
//
|
|
638
|
+
// Shared retry state for this on() subscription.
|
|
639
|
+
let retryCount = 0
|
|
640
|
+
const maxRetries = 5
|
|
641
|
+
const retryDelay = 1000 // Start with 1 second
|
|
642
|
+
let retryTimer = null
|
|
643
|
+
|
|
583
644
|
map.set(cb, () => {
|
|
645
|
+
// Bail out if off() has already cleaned up this context — the mapped
|
|
646
|
+
// callback can fire twice (once from wire.on and once from the _get
|
|
647
|
+
// immediate-read path) and the second call after async context
|
|
648
|
+
// cleanup would crash in resolve(). Matches the guard in watchForRel.
|
|
649
|
+
if (!allctx.has(ctxid)) return
|
|
650
|
+
// Cancel any pending retry — the persistent listener firing means we
|
|
651
|
+
// have an update and should not call cb twice.
|
|
652
|
+
clearTimeout(retryTimer)
|
|
653
|
+
retryTimer = null
|
|
584
654
|
// When listener fires, re-check if the item is now a rel.
|
|
585
655
|
wire.get(
|
|
586
656
|
{"#": soul, ".": item},
|
|
587
657
|
msg => {
|
|
588
|
-
const
|
|
658
|
+
const node = msg.put && msg.put[soul]
|
|
659
|
+
const current = node && node[item]
|
|
589
660
|
const id = utils.rel.is(current)
|
|
590
661
|
if (id) {
|
|
591
662
|
// It's a rel, read the related node. It might not be in the
|
|
@@ -597,9 +668,11 @@ const Holster = opt => {
|
|
|
597
668
|
chain: [{item: null, soul: id}],
|
|
598
669
|
user: ctx ? ctx.user : null,
|
|
599
670
|
})
|
|
600
|
-
api(_ctxid).next(null, res,
|
|
671
|
+
api(_ctxid).next(null, res, retries === 0 ?
|
|
672
|
+
utils.obj.put(_opt, "fast", true) : _opt)
|
|
601
673
|
})
|
|
602
674
|
if (data !== null || retries >= 5) {
|
|
675
|
+
retryCount = 0 // Reset retry counter on success
|
|
603
676
|
cb(data)
|
|
604
677
|
} else {
|
|
605
678
|
// Data not ready, retry after delay
|
|
@@ -609,11 +682,50 @@ const Holster = opt => {
|
|
|
609
682
|
}
|
|
610
683
|
attemptRead()
|
|
611
684
|
} else {
|
|
612
|
-
// It's a direct property or null
|
|
613
|
-
|
|
685
|
+
// It's a direct property or null.
|
|
686
|
+
const nodeExists = !!node
|
|
687
|
+
|
|
688
|
+
if (nodeExists) {
|
|
689
|
+
// Node exists — return the value and reset retry state.
|
|
690
|
+
retryCount = 0
|
|
691
|
+
cb(current !== undefined ? current : null)
|
|
692
|
+
} else if (retryCount < maxRetries) {
|
|
693
|
+
// No data yet — keep polling until we get a response or
|
|
694
|
+
// exhaust retries. on() with _get can return null on a cache
|
|
695
|
+
// miss and never fire again if the data doesn't change while
|
|
696
|
+
// the listener is attached, so we must poll for initial load.
|
|
697
|
+
const retry = () => {
|
|
698
|
+
const delay =
|
|
699
|
+
Math.min(retryDelay * Math.pow(2, retryCount), 30000)
|
|
700
|
+
retryCount++
|
|
701
|
+
retryTimer = setTimeout(() => {
|
|
702
|
+
wire.get(
|
|
703
|
+
{"#": soul, ".": item},
|
|
704
|
+
msg => {
|
|
705
|
+
const retryNode = msg.put && msg.put[soul]
|
|
706
|
+
if (retryNode) {
|
|
707
|
+
retryCount = 0
|
|
708
|
+
retryTimer = null
|
|
709
|
+
const retryValue = retryNode[item]
|
|
710
|
+
cb(retryValue !== undefined ? retryValue : null)
|
|
711
|
+
} else if (retryCount < maxRetries) {
|
|
712
|
+
retry()
|
|
713
|
+
} else {
|
|
714
|
+
retryTimer = null
|
|
715
|
+
cb(null)
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
{..._opt, secure: !!(ctx.user || opt.secure)},
|
|
719
|
+
)
|
|
720
|
+
}, delay)
|
|
721
|
+
}
|
|
722
|
+
retry()
|
|
723
|
+
} else {
|
|
724
|
+
cb(null)
|
|
725
|
+
}
|
|
614
726
|
}
|
|
615
727
|
},
|
|
616
|
-
{..._opt, secure: ctx.user || opt.secure},
|
|
728
|
+
{..._opt, secure: !!(ctx.user || opt.secure)},
|
|
617
729
|
)
|
|
618
730
|
})
|
|
619
731
|
|
|
@@ -653,13 +765,14 @@ const Holster = opt => {
|
|
|
653
765
|
let relLex
|
|
654
766
|
if (lex) relLex = utils.obj.put(initialLex, "#", id)
|
|
655
767
|
else relLex = {"#": id, ".": null}
|
|
656
|
-
wire.on(relLex, map.get(cb),
|
|
768
|
+
wire.on(relLex, map.get(cb), false, _opt)
|
|
769
|
+
if (_get) map.get(cb)()
|
|
657
770
|
} else if (_get) {
|
|
658
771
|
// Not a rel, but _get was requested, so trigger callback.
|
|
659
772
|
map.get(cb)()
|
|
660
773
|
}
|
|
661
774
|
},
|
|
662
|
-
{..._opt, secure: ctx.user || opt.secure},
|
|
775
|
+
{..._opt, secure: !!(ctx.user || opt.secure)},
|
|
663
776
|
)
|
|
664
777
|
},
|
|
665
778
|
off: cb => {
|
|
@@ -673,9 +786,10 @@ const Holster = opt => {
|
|
|
673
786
|
const {item, soul} = resolve({off: true}, cb)
|
|
674
787
|
if (!soul) return
|
|
675
788
|
|
|
676
|
-
// Remove listener immediately from the original soul to avoid race
|
|
677
|
-
// The listener may be on a related soul if item is a rel,
|
|
678
|
-
// from the original soul first since that's where it's
|
|
789
|
+
// Remove listener immediately from the original soul to avoid a race
|
|
790
|
+
// condition. The listener may be on a related soul if item is a rel,
|
|
791
|
+
// but we remove from the original soul first since that's where it's
|
|
792
|
+
//initially registered.
|
|
679
793
|
wire.off({"#": soul}, map.get(cb))
|
|
680
794
|
|
|
681
795
|
// 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.
|