@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.
Files changed (67) 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 +23 -5
  6. package/src/get.js +11 -19
  7. package/src/ham.js +17 -47
  8. package/src/holster.js +106 -28
  9. package/src/sea.js +0 -52
  10. package/src/store.js +7 -7
  11. package/src/wire.js +8 -2
  12. package/ts/src/array.ts +78 -0
  13. package/ts/src/buffer.ts +441 -0
  14. package/ts/src/dup.ts +49 -0
  15. package/ts/src/get.ts +83 -0
  16. package/ts/src/ham.ts +254 -0
  17. package/ts/src/holster.ts +931 -0
  18. package/ts/src/index.ts +35 -0
  19. package/ts/src/radisk.ts +585 -0
  20. package/ts/src/radix.ts +157 -0
  21. package/ts/src/schemas.ts +519 -0
  22. package/ts/src/sea-utils.ts +103 -0
  23. package/ts/src/sea.ts +388 -0
  24. package/ts/src/store.ts +321 -0
  25. package/ts/src/user.ts +381 -0
  26. package/ts/src/utils.ts +232 -0
  27. package/ts/src/wire.ts +829 -0
  28. package/.dockerignore +0 -9
  29. package/.github/workflows/node.js.yml +0 -31
  30. package/.github/workflows/npm-publish.yml +0 -33
  31. package/Dockerfile +0 -20
  32. package/examples/index.html +0 -33
  33. package/examples/index.js +0 -49
  34. package/examples/user.html +0 -31
  35. package/examples/user.js +0 -93
  36. package/examples/wire.html +0 -27
  37. package/examples/wire.js +0 -55
  38. package/test/holster.get.test.js +0 -94
  39. package/test/holster.lex.test.js +0 -196
  40. package/test/holster.off.test.js +0 -53
  41. package/test/holster.on.test.js +0 -264
  42. package/test/holster.put.test.js +0 -358
  43. package/test/holster.secure.test.js +0 -202
  44. package/test/holster.user.get.test.js +0 -114
  45. package/test/holster.user.lex.test.js +0 -149
  46. package/test/holster.user.off.test.js +0 -73
  47. package/test/holster.user.on.test.js +0 -300
  48. package/test/holster.user.put.test.js +0 -463
  49. package/test/kitty/! +0 -9
  50. package/test/names.js +0 -225
  51. package/test/radisk.cache.test.js +0 -213
  52. package/test/radisk.test.js +0 -331
  53. package/test/radix.test.js +0 -117
  54. package/test/sea.test.js +0 -284
  55. package/test/split.test.js +0 -317
  56. package/test/store.test.js +0 -316
  57. package/test/system/basic.test.js +0 -29
  58. package/test/system/chained-on.test.js +0 -41
  59. package/test/system/concurrent-listeners.test.js +0 -125
  60. package/test/system/immediate-read.test.js +0 -30
  61. package/test/system/listener-before-data.test.js +0 -51
  62. package/test/system/promise-wrapper.test.js +0 -47
  63. package/test/system/user-chained-on.test.js +0 -60
  64. package/test/system/user-concurrent-listeners.test.js +0 -142
  65. package/test/system/user-listener-before-data.test.js +0 -66
  66. package/test/user.test.js +0 -173
  67. 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.34",
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
- "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",
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
- 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,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
- 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 {
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
- if (graph[soul]._["s"][state]) {
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
- // 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.
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
- // Allow listening to a node that doesn't exist.
205
- cb(null)
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
- // Allow querying a node that doesn't exist.
229
- if (cb) cb(null)
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
- // 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)
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
- // Create a new context for each listener invocation to avoid mutation
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 current = msg.put && msg.put[soul] && msg.put[soul][item]
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
- cb(current !== undefined ? current : null)
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), _get, _opt)
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 = () => console.log("error getting keys for", dir)
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
- const state = value[1]
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
 
@@ -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
+