@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.
Files changed (72) hide show
  1. package/build/holster.js +3 -3
  2. package/build/holster.js.map +3 -3
  3. package/dist/holster.js +4 -0
  4. package/dist/holster.js.map +7 -0
  5. package/package.json +22 -5
  6. package/src/dup.js +4 -5
  7. package/src/get.js +10 -19
  8. package/src/ham.js +22 -49
  9. package/src/holster.js +172 -58
  10. package/src/index.js +1 -1
  11. package/src/radisk.js +89 -86
  12. package/src/sea-utils.js +1 -1
  13. package/src/sea.js +0 -52
  14. package/src/store.js +8 -8
  15. package/src/user.js +0 -4
  16. package/src/wire.js +209 -79
  17. package/ts/src/array.ts +78 -0
  18. package/ts/src/buffer.ts +441 -0
  19. package/ts/src/dup.ts +38 -0
  20. package/ts/src/get.ts +83 -0
  21. package/ts/src/ham.ts +258 -0
  22. package/ts/src/holster.ts +968 -0
  23. package/ts/src/index.ts +35 -0
  24. package/ts/src/radisk.ts +591 -0
  25. package/ts/src/radix.ts +157 -0
  26. package/ts/src/schemas.ts +515 -0
  27. package/ts/src/sea-utils.ts +103 -0
  28. package/ts/src/sea.ts +388 -0
  29. package/ts/src/store.ts +321 -0
  30. package/ts/src/user.ts +377 -0
  31. package/ts/src/utils.ts +232 -0
  32. package/ts/src/wire.ts +961 -0
  33. package/.dockerignore +0 -9
  34. package/.github/workflows/node.js.yml +0 -31
  35. package/.github/workflows/npm-publish.yml +0 -33
  36. package/Dockerfile +0 -20
  37. package/examples/index.html +0 -33
  38. package/examples/index.js +0 -49
  39. package/examples/user.html +0 -31
  40. package/examples/user.js +0 -93
  41. package/examples/wire.html +0 -27
  42. package/examples/wire.js +0 -55
  43. package/test/holster.get.test.js +0 -94
  44. package/test/holster.lex.test.js +0 -196
  45. package/test/holster.off.test.js +0 -53
  46. package/test/holster.on.test.js +0 -264
  47. package/test/holster.put.test.js +0 -358
  48. package/test/holster.secure.test.js +0 -202
  49. package/test/holster.user.get.test.js +0 -114
  50. package/test/holster.user.lex.test.js +0 -149
  51. package/test/holster.user.off.test.js +0 -73
  52. package/test/holster.user.on.test.js +0 -300
  53. package/test/holster.user.put.test.js +0 -463
  54. package/test/kitty/! +0 -9
  55. package/test/names.js +0 -225
  56. package/test/radisk.cache.test.js +0 -213
  57. package/test/radisk.test.js +0 -331
  58. package/test/radix.test.js +0 -117
  59. package/test/sea.test.js +0 -284
  60. package/test/split.test.js +0 -317
  61. package/test/store.test.js +0 -316
  62. package/test/system/basic.test.js +0 -29
  63. package/test/system/chained-on.test.js +0 -41
  64. package/test/system/concurrent-listeners.test.js +0 -125
  65. package/test/system/immediate-read.test.js +0 -30
  66. package/test/system/listener-before-data.test.js +0 -51
  67. package/test/system/promise-wrapper.test.js +0 -47
  68. package/test/system/user-chained-on.test.js +0 -60
  69. package/test/system/user-concurrent-listeners.test.js +0 -142
  70. package/test/system/user-listener-before-data.test.js +0 -66
  71. package/test/user.test.js +0 -173
  72. package/test/wire.test.js +0 -158
package/package.json CHANGED
@@ -1,25 +1,42 @@
1
1
  {
2
2
  "name": "@mblaney/holster",
3
- "version": "1.0.35",
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
- "build": "esbuild src/holster.js --bundle --format=esm --sourcemap \"--external:node:*\" \"--external:ws\" --minify --outdir=build"
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 = maxAge => {
2
- // Allow maxAge to be passed in as tests wait on the setTimeout.
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
- if (graph[soul]._["s"][state]) {
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
- if (graph[soul]._["s"][pkState]) {
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
- if (graph[soul]._["s"][state]) {
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
- // Check if this timestamp is for properties that already have newer
106
- // versions in graph.
107
- const hasNewerInGraph = incomingTimestamps.some(([prop, ts]) => {
108
- if (Number(ts) === asNumber) {
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
- // Skip verification if graph has a newer timestamp for property.
112
- if (graphState > asNumber) {
113
- return true
114
- }
115
- }
116
- return false
117
- })
116
+ return graphState > asNumber
117
+ })
118
118
 
119
- if (hasNewerInGraph) {
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
- if (node._["s"][state]) {
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
- if (node._["s"][state]) {
222
- graph[soul]._["s"][state] = now[soul]._["s"][state] =
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}) => delete graph[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
- for (const key of Object.keys(msg.put[soul])) {
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
- // Retry with delays if we get null
71
- const attemptRead = async (retries = 0) => {
72
- const data = await new Promise(res => {
73
- const _ctxid = utils.text.random()
74
- const ctx = allctx.get(ctxid)
75
- allctx.set(_ctxid, {
76
- chain: [{item: null, soul: id}],
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
- if (data !== null || retries >= 5) {
82
- return data
83
- }
84
- // Data not ready, retry after delay
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
- msg.put[soul][key] = await attemptRead()
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
- // Allow listening to a node that doesn't exist.
205
- cb(null)
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
- // Allow querying a node that doesn't exist.
229
- if (cb) cb(null)
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 context's callback and user flag here.
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
- // The nested objects created above will also have rels on the
525
- // parent object, so fetch the node so that the rest of the
526
- // updates can be added.
527
- wire.get({"#": id}, async msg => {
528
- if (msg.err) {
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
- // Create a new context for each listener invocation to avoid mutation
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 current = msg.put && msg.put[soul] && msg.put[soul][item]
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, _opt)
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
- cb(current !== undefined ? current : null)
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), _get, _opt)
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 condition.
677
- // The listener may be on a related soul if item is a rel, but we remove
678
- // from the original soul first since that's where it's initially registered.
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.