@mblaney/holster 2.0.1 → 2.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mblaney/holster",
3
- "version": "2.0.1",
3
+ "version": "2.1.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",
package/src/ham.js CHANGED
@@ -35,7 +35,14 @@ const Ham = (state, currentState, value, currentValue, signed = false) => {
35
35
  return {incoming: true}
36
36
  }
37
37
 
38
- Ham.mix = async (change, graph, hasNode, secure, listen) => {
38
+ Ham.mix = async (
39
+ change,
40
+ graph,
41
+ hasNode,
42
+ secure,
43
+ listen,
44
+ maxGraphSize = MAX_GRAPH_SIZE,
45
+ ) => {
39
46
  if (!change || typeof change !== "object") {
40
47
  throw new TypeError("change must be an object")
41
48
  }
@@ -216,7 +223,7 @@ Ham.mix = async (change, graph, hasNode, secure, listen) => {
216
223
  }
217
224
 
218
225
  const souls = Object.keys(graph)
219
- if (souls.length > MAX_GRAPH_SIZE) {
226
+ if (souls.length > maxGraphSize) {
220
227
  // Sort by oldest state timestamp and remove oldest entries
221
228
  const soulsByAge = souls
222
229
  .map(soul => {
@@ -225,7 +232,7 @@ Ham.mix = async (change, graph, hasNode, secure, listen) => {
225
232
  return {soul, maxState}
226
233
  })
227
234
  .sort((a, b) => a.maxState - b.maxState)
228
- const remove = soulsByAge.slice(0, souls.length - MAX_GRAPH_SIZE)
235
+ const remove = soulsByAge.slice(0, souls.length - maxGraphSize)
229
236
  remove.forEach(({soul}) => {
230
237
  delete graph[soul]
231
238
  if (hasNode) hasNode.delete(soul)
package/src/holster.js CHANGED
@@ -64,28 +64,30 @@ 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
- await Promise.all(Object.keys(msg.put[soul]).map(async key => {
68
- const id = utils.rel.is(msg.put[soul][key])
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,
67
+ await Promise.all(
68
+ Object.keys(msg.put[soul]).map(async key => {
69
+ const id = utils.rel.is(msg.put[soul][key])
70
+ if (!id) return
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)
77
80
  })
78
- api(_ctxid).next(null, res, _opt)
79
- })
80
- if (data !== null || retries >= 5) {
81
- return data
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)
82
87
  }
83
- // Data not ready, retry after delay
84
- await new Promise(resolve => setTimeout(resolve, 50))
85
- return attemptRead(retries + 1)
86
- }
87
- msg.put[soul][key] = await attemptRead()
88
- }))
88
+ msg.put[soul][key] = await attemptRead()
89
+ }),
90
+ )
89
91
  ack(msg.put[soul])
90
92
  } else {
91
93
  // No data callback.
@@ -281,7 +283,11 @@ const Holster = opt => {
281
283
  if (request._get) cb(null)
282
284
  } else if (get && node) {
283
285
  const sv = node._ && node._[">"]
284
- if (sv && Object.keys(sv).length > 0 && typeof sv[item] === "undefined") {
286
+ if (
287
+ sv &&
288
+ Object.keys(sv).length > 0 &&
289
+ typeof sv[item] === "undefined"
290
+ ) {
285
291
  // State vector has other properties but not this one — never written.
286
292
  if (cb) cb(null)
287
293
  } else {
@@ -666,8 +672,11 @@ const Holster = opt => {
666
672
  chain: [{item: null, soul: id}],
667
673
  user: ctx ? ctx.user : null,
668
674
  })
669
- api(_ctxid).next(null, res, retries === 0 ?
670
- utils.obj.put(_opt, "fast", true) : _opt)
675
+ api(_ctxid).next(
676
+ null,
677
+ res,
678
+ retries === 0 ? utils.obj.put(_opt, "fast", true) : _opt,
679
+ )
671
680
  })
672
681
  if (data !== null || retries >= 5) {
673
682
  retryCount = 0 // Reset retry counter on success
@@ -693,8 +702,10 @@ const Holster = opt => {
693
702
  // miss and never fire again if the data doesn't change while
694
703
  // the listener is attached, so we must poll for initial load.
695
704
  const retry = () => {
696
- const delay =
697
- Math.min(retryDelay * Math.pow(2, retryCount), 30000)
705
+ const delay = Math.min(
706
+ retryDelay * Math.pow(2, retryCount),
707
+ 30000,
708
+ )
698
709
  retryCount++
699
710
  retryTimer = setTimeout(() => {
700
711
  wire.get(
package/src/radisk.js CHANGED
@@ -34,7 +34,7 @@ const Radisk = opt => {
34
34
  if (!opt.write) opt.write = 1 // Wait time before write in milliseconds
35
35
  if (!opt.size) opt.size = 1024 * 1024 // File size on disk, default 1MB
36
36
  if (!opt.memoryLimit) opt.memoryLimit = 500 // Memory limit in MB
37
- if (typeof opt.cache === "undefined") opt.cache = true
37
+ if (typeof opt.cache === "undefined") opt.cache = true // Used in tests
38
38
 
39
39
  if (!opt.store) {
40
40
  opt.log(
@@ -55,16 +55,6 @@ const Radisk = opt => {
55
55
  return
56
56
  }
57
57
 
58
- // Performance logging
59
- const perfLog = (operation, startTime, key, size) => {
60
- const duration = Date.now() - startTime
61
- if (duration > 3000) {
62
- console.log(
63
- `[RADISK-SLOW] ${operation}: ${duration}ms for ${key}${size ? ` (${size} bytes)` : ""}`,
64
- )
65
- }
66
- }
67
-
68
58
  // Memory monitoring and cache cleanup
69
59
  const checkMemoryUsage = () => {
70
60
  if (typeof process === "undefined" || !process.memoryUsage) return
@@ -129,7 +119,6 @@ const Radisk = opt => {
129
119
  }
130
120
 
131
121
  pendingReads.set(key, [cb])
132
- const readStart = Date.now()
133
122
  // If radisk.read genuinely hangs (fd leak, stuck I/O), recover
134
123
  // callbacks as a cache miss so callers can fall through to the wire path.
135
124
  // 30s is long enough to distinguish a hang from a slow-but-healthy read.
@@ -142,7 +131,6 @@ const Radisk = opt => {
142
131
  }, 30000)
143
132
  return radisk.read(key, (err, result) => {
144
133
  clearTimeout(timeout)
145
- perfLog("read", readStart, key)
146
134
  const callbacks = pendingReads.get(key) || []
147
135
  pendingReads.delete(key)
148
136
  callbacks.forEach(callback => callback(err, result))
@@ -175,7 +163,6 @@ const Radisk = opt => {
175
163
  return (radisk.thrash.more = true)
176
164
  }
177
165
 
178
- const thrashStart = Date.now()
179
166
  clearTimeout(radisk.batch.timeout)
180
167
  radisk.thrash.more = false
181
168
  radisk.thrash.ing = true
@@ -191,7 +178,6 @@ const Radisk = opt => {
191
178
  // file needs to be split.
192
179
  if (++i > 1) return
193
180
 
194
- perfLog("thrash", thrashStart, `batch-${batch.ed}`)
195
181
  if (err) opt.log(err)
196
182
  batch.acks.forEach(cb => cb(err))
197
183
  radisk.thrash.at = null
@@ -279,7 +265,7 @@ const Radisk = opt => {
279
265
  limit: "",
280
266
  done: false,
281
267
  count: 0,
282
- halfKey: "", // soul ID at the ~opt.size/2 mark for a balanced split
268
+ halfKey: "", // soul ID at the ~opt.size/2 mark for a balanced split
283
269
  halfOffset: 0, // write.text.length before that entry
284
270
  each: (value, key, k, pre) => {
285
271
  // each is called for all keys, but stop adding to write.text when
@@ -309,8 +295,7 @@ const Radisk = opt => {
309
295
  ) {
310
296
  const fullKey = pre.join("") + k
311
297
  const end = fullKey.indexOf(enq)
312
- write.halfKey =
313
- end === -1 ? fullKey : fullKey.substring(0, end)
298
+ write.halfKey = end === -1 ? fullKey : fullKey.substring(0, end)
314
299
  write.halfOffset = write.text.length
315
300
  }
316
301
 
@@ -322,8 +307,7 @@ const Radisk = opt => {
322
307
  const fullKey = pre.join("") + k
323
308
  const end = fullKey.indexOf(enq)
324
309
  write.limit =
325
- write.halfKey ||
326
- (end === -1 ? fullKey : fullKey.substring(0, end))
310
+ write.halfKey || (end === -1 ? fullKey : fullKey.substring(0, end))
327
311
  // Cannot split if the key is the same as the current file name.
328
312
  if (write.limit !== file) {
329
313
  // Truncate to the balanced split point — text after halfOffset is
@@ -350,7 +334,6 @@ const Radisk = opt => {
350
334
  Radix.map(rad, write.each, true)
351
335
  // There is always accumulated write.text to store once write.each has
352
336
  // finished.
353
- const writeStart = Date.now()
354
337
  opt.store.put(file, write.text, err => {
355
338
  // If a split occurred the cached radix tree still contains entries that
356
339
  // were moved to the sub-file. Invalidate so the next save.mix re-reads
@@ -359,7 +342,6 @@ const Radisk = opt => {
359
342
  cache.delete(file)
360
343
  fileListCache = null
361
344
  }
362
- perfLog("file-write", writeStart, file, write.text.length)
363
345
  cb(err)
364
346
  })
365
347
  }
@@ -371,17 +353,26 @@ const Radisk = opt => {
371
353
 
372
354
  // Binary search for largest filename <= soul in a sorted array.
373
355
  const findFile = files => {
374
- let lo = 0, hi = files.length - 1, result = null
356
+ let lo = 0,
357
+ hi = files.length - 1,
358
+ result = null
375
359
  while (lo <= hi) {
376
360
  const mid = (lo + hi) >> 1
377
- if (files[mid] <= soul) { result = files[mid]; lo = mid + 1 }
378
- else { hi = mid - 1 }
361
+ if (files[mid] <= soul) {
362
+ result = files[mid]
363
+ lo = mid + 1
364
+ } else {
365
+ hi = mid - 1
366
+ }
379
367
  }
380
368
  return result
381
369
  }
382
370
 
383
371
  const read = file => {
384
- if (!file) { cb("no file found", u); return }
372
+ if (!file) {
373
+ cb("no file found", u)
374
+ return
375
+ }
385
376
  if (opt.cache && cache.has(file)) {
386
377
  return cb(u, cache.get(file)(key))
387
378
  }
@@ -396,12 +387,19 @@ const Radisk = opt => {
396
387
  }
397
388
 
398
389
  const now = Date.now()
399
- if (opt.cache && fileListCache && now - fileListCacheTime < FILE_LIST_CACHE_TTL) {
390
+ if (
391
+ opt.cache &&
392
+ fileListCache &&
393
+ now - fileListCacheTime < FILE_LIST_CACHE_TTL
394
+ ) {
400
395
  read(findFile(fileListCache, soul))
401
396
  } else {
402
397
  const files = []
403
398
  opt.store.list(file => {
404
- if (file) { files.push(file); return }
399
+ if (file) {
400
+ files.push(file)
401
+ return
402
+ }
405
403
  files.sort()
406
404
  if (opt.cache) {
407
405
  fileListCache = files
package/src/store.js CHANGED
@@ -197,7 +197,7 @@ const Store = opt => {
197
197
  const radisk = Radisk(opt)
198
198
 
199
199
  return {
200
- get: (lex, cb, _opt) => {
200
+ get: (lex, cb) => {
201
201
  if (!lex || !utils.obj.is(lex)) {
202
202
  cb("lex required")
203
203
  return
@@ -207,41 +207,54 @@ const Store = opt => {
207
207
  return
208
208
  }
209
209
 
210
- if (!_opt) _opt = {}
211
-
212
210
  var soul = lex["#"]
213
- var key = !_opt.secure && typeof lex["."] === "string" ? lex["."] : ""
211
+ var key = typeof lex["."] === "string" ? lex["."] : ""
214
212
  var node
215
213
  var signatures = {}
216
- const each = (value, key) => {
217
- // Always include userPublicKey for verification, regardless of filter
218
- if (key !== utils.userPublicKey && !utils.match(lex["."], key)) return
214
+ const each = (value, k) => {
215
+ if (k !== utils.userPublicKey && !utils.match(lex["."], k)) return
219
216
 
220
217
  if (!node) node = {_: {"#": soul, ">": {}}}
221
- node[key] = value[0]
222
- node._[">"][key] = value[1]
218
+ node[k] = value[0]
219
+ node._[">"][k] = value[1]
223
220
  // If signature is present, store it in _["s"]
224
221
  if (value.length === 3 && value[2]) {
225
222
  signatures[value[1]] = value[2]
226
223
  }
227
224
  }
228
225
 
226
+ // When doing a targeted property lookup, also fetch userPublicKey in
227
+ // parallel so ham.js can verify signatures regardless of secure mode.
228
+ // Full-soul scans (key="") already surface it via Radix.map.
229
+ const needsPubKey = key !== "" && key !== utils.userPublicKey
230
+ let pending = needsPubKey ? 2 : 1
231
+ let cbErr = null
232
+ const finish = err => {
233
+ if (err && !cbErr) cbErr = err
234
+ if (--pending > 0) return
235
+ const graph = {[soul]: node}
236
+ if (node && Object.keys(signatures).length > 0) {
237
+ node._["s"] = signatures
238
+ }
239
+ cb(cbErr, graph)
240
+ }
241
+
229
242
  radisk(soul + enq + key, (err, value) => {
230
- let graph
231
243
  if (utils.obj.is(value)) {
232
244
  Radix.map(value, each)
233
245
  if (!node) each(value, key)
234
- graph = {[soul]: node}
235
246
  } else if (value) {
236
247
  each(value, key)
237
- graph = {[soul]: node}
238
248
  }
239
- // Only add _["s"] if we found any signatures
240
- if (graph && graph[soul] && Object.keys(signatures).length > 0) {
241
- graph[soul]._["s"] = signatures
242
- }
243
- cb(err, graph)
249
+ finish(err)
244
250
  })
251
+
252
+ if (needsPubKey) {
253
+ radisk(soul + enq + utils.userPublicKey, (err, value) => {
254
+ if (value && !utils.obj.is(value)) each(value, utils.userPublicKey)
255
+ finish(err)
256
+ })
257
+ }
245
258
  },
246
259
  put: (graph, cb) => {
247
260
  if (!graph) {
package/src/user.js CHANGED
@@ -28,54 +28,51 @@ const User = (opt, wire) => {
28
28
  }
29
29
 
30
30
  const pub = pubs.shift()
31
- wire.get(
32
- {"#": pub, ".": ["auth", "pub", "epub"]},
33
- async msg => {
34
- if (msg.err) {
35
- done(`error getting ${pub}: ${msg.err}`)
36
- return
37
- }
31
+ wire.get({"#": pub, ".": ["auth", "pub", "epub"]}, async msg => {
32
+ if (msg.err) {
33
+ done(`error getting ${pub}: ${msg.err}`)
34
+ return
35
+ }
38
36
 
39
- const data = msg.put && msg.put[pub]
40
- if (!data || !data.auth) return next()
37
+ const data = msg.put && msg.put[pub]
38
+ if (!data || !data.auth) return next()
41
39
 
42
- const auth = JSON.parse(data.auth)
43
- const work = await SEA.work(password, auth.salt)
44
- const dec = await SEA.decrypt(auth.enc, work)
45
- if (!dec) return next()
40
+ const auth = JSON.parse(data.auth)
41
+ const work = await SEA.work(password, auth.salt)
42
+ const dec = await SEA.decrypt(auth.enc, work)
43
+ if (!dec) return next()
46
44
 
47
- user.is = {
48
- username: username,
49
- pub: data.pub,
50
- epub: data.epub,
51
- priv: dec.priv,
52
- epriv: dec.epriv,
45
+ user.is = {
46
+ username: username,
47
+ pub: data.pub,
48
+ epub: data.epub,
49
+ priv: dec.priv,
50
+ epriv: dec.epriv,
51
+ }
52
+ if (newPassword !== "") {
53
+ // Encrypt private key using new password and a new salt.
54
+ const salt = utils.text.random(64)
55
+ const work = await SEA.work(newPassword, salt)
56
+ const enc = await SEA.encrypt(dec, work)
57
+ // Only update the auth property with the new password encryption.
58
+ const update = {
59
+ auth: JSON.stringify({enc: enc, salt: salt}),
53
60
  }
54
- if (newPassword !== "") {
55
- // Encrypt private key using new password and a new salt.
56
- const salt = utils.text.random(64)
57
- const work = await SEA.work(newPassword, salt)
58
- const enc = await SEA.encrypt(dec, work)
59
- // Only update the auth property with the new password encryption.
60
- const update = {
61
- auth: JSON.stringify({enc: enc, salt: salt}),
61
+ const timestamp = Date.now()
62
+ const sig = await SEA.signTimestamp(timestamp, user.is)
63
+ const graph = utils.graph(pub, update, sig, data.pub, timestamp)
64
+ wire.put(graph, err => {
65
+ if (err) {
66
+ done(`error putting ${update} on ${pub}: ${err}`)
67
+ } else {
68
+ done(null)
62
69
  }
63
- const timestamp = Date.now()
64
- const sig = await SEA.signTimestamp(timestamp, user.is)
65
- const graph = utils.graph(pub, update, sig, data.pub, timestamp)
66
- wire.put(graph, err => {
67
- if (err) {
68
- done(`error putting ${update} on ${pub}: ${err}`)
69
- } else {
70
- done(null)
71
- }
72
- })
73
- return
74
- }
70
+ })
71
+ return
72
+ }
75
73
 
76
- done(null)
77
- },
78
- )
74
+ done(null)
75
+ })
79
76
  }
80
77
 
81
78
  if (retries > 9) {
@@ -84,24 +81,21 @@ const User = (opt, wire) => {
84
81
  }
85
82
 
86
83
  const soul = "~@" + username
87
- wire.get(
88
- {"#": soul},
89
- async msg => {
90
- if (msg.err) {
91
- done(`error getting ${soul}: ${msg.err}`)
92
- return
93
- }
84
+ wire.get({"#": soul}, async msg => {
85
+ if (msg.err) {
86
+ done(`error getting ${soul}: ${msg.err}`)
87
+ return
88
+ }
94
89
 
95
- const data = msg.put && msg.put[soul]
96
- if (!data) return retry(username)
90
+ const data = msg.put && msg.put[soul]
91
+ if (!data) return retry(username)
97
92
 
98
- delete msg.put[soul]._
99
- // Usernames aren't guaranteed to be unique, so store the list and then
100
- // try each of them until one is successful with the given password.
101
- pubs = Object.keys(data)
102
- next()
103
- },
104
- )
93
+ delete msg.put[soul]._
94
+ // Usernames aren't guaranteed to be unique, so store the list and then
95
+ // try each of them until one is successful with the given password.
96
+ pubs = Object.keys(data)
97
+ next()
98
+ })
105
99
  }
106
100
 
107
101
  const user = {
@@ -129,58 +123,55 @@ const User = (opt, wire) => {
129
123
  creating = true
130
124
 
131
125
  const soul = "~@" + username
132
- wire.get(
133
- {"#": soul},
134
- async msg => {
135
- if (msg.err) {
136
- creating = false
137
- ack(`error getting ${soul}: ${msg.err}`)
138
- return
139
- }
126
+ wire.get({"#": soul}, async msg => {
127
+ if (msg.err) {
128
+ creating = false
129
+ ack(`error getting ${soul}: ${msg.err}`)
130
+ return
131
+ }
140
132
 
141
- if (msg.put && msg.put[soul]) {
142
- creating = false
143
- ack("Username already exists")
144
- return
145
- }
133
+ if (msg.put && msg.put[soul]) {
134
+ creating = false
135
+ ack("Username already exists")
136
+ return
137
+ }
146
138
 
147
- const salt = utils.text.random(64)
148
- const work = await SEA.work(password, salt)
149
- const pair = await SEA.pair()
150
- const priv = {priv: pair.priv, epriv: pair.epriv}
151
- const enc = await SEA.encrypt(priv, work)
152
- const data = {
153
- username: username,
154
- pub: pair.pub,
155
- epub: pair.epub,
156
- auth: JSON.stringify({enc: enc, salt: salt}),
139
+ const salt = utils.text.random(64)
140
+ const work = await SEA.work(password, salt)
141
+ const pair = await SEA.pair()
142
+ const priv = {priv: pair.priv, epriv: pair.epriv}
143
+ const enc = await SEA.encrypt(priv, work)
144
+ const data = {
145
+ username: username,
146
+ pub: pair.pub,
147
+ epub: pair.epub,
148
+ auth: JSON.stringify({enc: enc, salt: salt}),
149
+ }
150
+
151
+ const pub = "~" + pair.pub
152
+ // Sign the timestamp for new account
153
+ const timestamp = Date.now()
154
+ const sig = await SEA.signTimestamp(timestamp, pair)
155
+ const graph = utils.graph(pub, data, sig, pair.pub, timestamp)
156
+ wire.put(graph, err => {
157
+ creating = false
158
+ if (err) {
159
+ ack(`error putting ${data} on ${pub}: ${err}`)
160
+ return
157
161
  }
158
162
 
159
- const pub = "~" + pair.pub
160
- // Sign the timestamp for new account
161
- const timestamp = Date.now()
162
- const sig = await SEA.signTimestamp(timestamp, pair)
163
- const graph = utils.graph(pub, data, sig, pair.pub, timestamp)
164
- wire.put(graph, err => {
165
- creating = false
163
+ const rel = {[pub]: {"#": pub}}
164
+ wire.put(utils.graph(soul, rel), err => {
166
165
  if (err) {
167
- ack(`error putting ${data} on ${pub}: ${err}`)
166
+ ack(`error putting ${rel} on ${soul}: ${err}`)
168
167
  return
169
168
  }
170
169
 
171
- const rel = {[pub]: {"#": pub}}
172
- wire.put(utils.graph(soul, rel), err => {
173
- if (err) {
174
- ack(`error putting ${rel} on ${soul}: ${err}`)
175
- return
176
- }
177
-
178
- // Return null on success.
179
- if (cb) cb(null)
180
- })
170
+ // Return null on success.
171
+ if (cb) cb(null)
181
172
  })
182
- },
183
- )
173
+ })
174
+ })
184
175
  },
185
176
  auth: (username, password, cb) => {
186
177
  const ack = err => {