@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/src/radisk.js CHANGED
@@ -34,7 +34,6 @@ 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 (!opt.readTimeout) opt.readTimeout = 1000 // Read timeout in milliseconds
38
37
  if (typeof opt.cache === "undefined") opt.cache = true
39
38
 
40
39
  if (!opt.store) {
@@ -124,37 +123,29 @@ const Radisk = opt => {
124
123
  }
125
124
  }
126
125
 
127
- // Check if there's already a pending read for this key
128
126
  if (pendingReads.has(key)) {
129
- // Add callback to existing pending read
130
- pendingReads.get(key).callbacks.push(cb)
127
+ pendingReads.get(key).push(cb)
131
128
  return
132
129
  }
133
130
 
134
- // Start new read and create callback queue with fired flag
135
- const pending = {callbacks: [cb], fired: false, timeoutId: null}
136
- pendingReads.set(key, pending)
137
-
138
- // Set a timeout to fire callbacks if read takes too long
139
- pending.timeoutId = setTimeout(() => {
140
- if (!pending.fired) {
141
- pending.fired = true
142
- pendingReads.delete(key)
143
- const err = new Error("radisk read timeout")
144
- pending.callbacks.forEach(callback => callback(err, undefined))
145
- }
146
- }, opt.readTimeout)
147
-
131
+ pendingReads.set(key, [cb])
148
132
  const readStart = Date.now()
133
+ // If radisk.read genuinely hangs (fd leak, stuck I/O), recover
134
+ // callbacks as a cache miss so callers can fall through to the wire path.
135
+ // 30s is long enough to distinguish a hang from a slow-but-healthy read.
136
+ const timeout = setTimeout(() => {
137
+ const callbacks = pendingReads.get(key)
138
+ if (!callbacks) return
139
+ opt.log(`radisk read hang detected for key: ${key}`)
140
+ pendingReads.delete(key)
141
+ callbacks.forEach(callback => callback(null, null))
142
+ }, 30000)
149
143
  return radisk.read(key, (err, result) => {
150
- clearTimeout(pending.timeoutId)
144
+ clearTimeout(timeout)
151
145
  perfLog("read", readStart, key)
152
- // Execute all pending callbacks for this key if timeout hasn't fired
153
- if (!pending.fired) {
154
- pending.fired = true
155
- pendingReads.delete(key)
156
- pending.callbacks.forEach(callback => callback(err, result))
157
- }
146
+ const callbacks = pendingReads.get(key) || []
147
+ pendingReads.delete(key)
148
+ callbacks.forEach(callback => callback(err, result))
158
149
  })
159
150
  }
160
151
 
@@ -288,6 +279,8 @@ const Radisk = opt => {
288
279
  limit: "",
289
280
  done: false,
290
281
  count: 0,
282
+ halfKey: "", // soul ID at the ~opt.size/2 mark for a balanced split
283
+ halfOffset: 0, // write.text.length before that entry
291
284
  each: (value, key, k, pre) => {
292
285
  // each is called for all keys, but stop adding to write.text when
293
286
  // write.slice is called, then the current contents of write.text
@@ -298,17 +291,46 @@ const Radisk = opt => {
298
291
  value = typeof value === "undefined" ? "" : "=" + Radisk.encode(value)
299
292
  const enc =
300
293
  Radisk.encode(pre.length) + "#" + Radisk.encode(k) + value + "\n"
301
- // Cannot split the file if only have one entry to write. Also don't
302
- // start a split if in the middle of writing a node (pre > 0).
294
+ // Cannot split the file if only have one entry to write. Allow splitting
295
+ // at depth > 0 only when k itself contains enq, meaning k is a full
296
+ // "soulId\x05property" key, not merely a property suffix of a shared
297
+ // soul prefix. Use the full key (pre + k) to extract the soul boundary.
298
+ const validBoundary =
299
+ pre.length === 0 || (pre.length === 1 && k.includes(enq))
300
+
301
+ // Record the first valid split boundary past the halfway mark so a
302
+ // full overflow can split here instead of at the tail, keeping both
303
+ // halves near opt.size/2 rather than leaving the file always full.
304
+ if (
305
+ !write.halfKey &&
306
+ write.count > 1 &&
307
+ write.text.length > opt.size / 2 &&
308
+ validBoundary
309
+ ) {
310
+ const fullKey = pre.join("") + k
311
+ const end = fullKey.indexOf(enq)
312
+ write.halfKey =
313
+ end === -1 ? fullKey : fullKey.substring(0, end)
314
+ write.halfOffset = write.text.length
315
+ }
316
+
303
317
  if (
304
318
  write.count > 1 &&
305
- pre.length === 0 &&
306
- write.text.length + enc.length > opt.size
319
+ write.text.length + enc.length > opt.size &&
320
+ validBoundary
307
321
  ) {
308
- const end = k.indexOf(enq)
309
- write.limit = end === -1 ? k : k.substring(0, end)
310
- // Cannot split if they key is the same as the current file name.
322
+ const fullKey = pre.join("") + k
323
+ const end = fullKey.indexOf(enq)
324
+ write.limit =
325
+ write.halfKey ||
326
+ (end === -1 ? fullKey : fullKey.substring(0, end))
327
+ // Cannot split if the key is the same as the current file name.
311
328
  if (write.limit !== file) {
329
+ // Truncate to the balanced split point — text after halfOffset is
330
+ // handled by the write.slice call below, which reads from rad.
331
+ if (write.halfKey) {
332
+ write.text = write.text.substring(0, write.halfOffset)
333
+ }
312
334
  write.done = true
313
335
  write.sub = Radix()
314
336
  Radix.map(rad, write.slice)
@@ -330,6 +352,13 @@ const Radisk = opt => {
330
352
  // finished.
331
353
  const writeStart = Date.now()
332
354
  opt.store.put(file, write.text, err => {
355
+ // If a split occurred the cached radix tree still contains entries that
356
+ // were moved to the sub-file. Invalidate so the next save.mix re-reads
357
+ // from disk rather than re-writing those entries back to this file.
358
+ if (write.done) {
359
+ cache.delete(file)
360
+ fileListCache = null
361
+ }
333
362
  perfLog("file-write", writeStart, file, write.text.length)
334
363
  cb(err)
335
364
  })
@@ -340,71 +369,45 @@ const Radisk = opt => {
340
369
  const end = key.indexOf(enq)
341
370
  const soul = end === -1 ? key : key.substring(0, end)
342
371
 
343
- const read = {
344
- lex: file => {
345
- // store.list should call lex without a file last, which means all file
346
- // names were compared to soul, so the current read.file is ok to use.
347
- if (!file) {
348
- if (!read.file) {
349
- cb("no file found", u)
350
- return
351
- }
352
-
353
- // Check multi-file cache first
354
- if (opt.cache && cache.has(read.file)) {
355
- const cachedRadix = cache.get(read.file)
356
- read.value = cachedRadix(key)
357
- // Return cached result (defined or undefined) since in-memory
358
- // radix tree is authoritative
359
- return cb(u, read.value)
360
- }
361
-
362
- radisk.parse(read.file, read.it)
363
- return
364
- }
365
-
366
- // Want the filename closest to soul.
367
- if (file > soul || file < read.file) return
372
+ // Binary search for largest filename <= soul in a sorted array.
373
+ const findFile = files => {
374
+ let lo = 0, hi = files.length - 1, result = null
375
+ while (lo <= hi) {
376
+ const mid = (lo + hi) >> 1
377
+ if (files[mid] <= soul) { result = files[mid]; lo = mid + 1 }
378
+ else { hi = mid - 1 }
379
+ }
380
+ return result
381
+ }
368
382
 
369
- read.file = file
370
- },
371
- it: (err, disk) => {
383
+ const read = file => {
384
+ if (!file) { cb("no file found", u); return }
385
+ if (opt.cache && cache.has(file)) {
386
+ return cb(u, cache.get(file)(key))
387
+ }
388
+ radisk.parse(file, (err, disk) => {
372
389
  if (err) opt.log(err)
373
- if (disk) {
374
- if (opt.cache) {
375
- cache.set(read.file, disk)
376
- checkMemoryUsage() // Check memory usage after adding to cache
377
- }
378
- read.value = disk(key)
390
+ if (disk && opt.cache) {
391
+ cache.set(file, disk)
392
+ checkMemoryUsage()
379
393
  }
380
- cb(err, read.value)
381
- },
394
+ cb(err, disk ? disk(key) : u)
395
+ })
382
396
  }
397
+
383
398
  const now = Date.now()
384
- if (
385
- opt.cache &&
386
- fileListCache &&
387
- now - fileListCacheTime < FILE_LIST_CACHE_TTL
388
- ) {
389
- // Use cached file list
390
- fileListCache.forEach(file => read.lex(file))
391
- read.lex() // Signal end of list
399
+ if (opt.cache && fileListCache && now - fileListCacheTime < FILE_LIST_CACHE_TTL) {
400
+ read(findFile(fileListCache, soul))
392
401
  } else {
393
- // Refresh cache
394
402
  const files = []
395
- const originalLex = read.lex
396
- read.lex = file => {
397
- if (file) files.push(file)
398
- return originalLex(file)
399
- }
400
-
401
403
  opt.store.list(file => {
402
- read.lex(file)
403
- if (!file && opt.cache) {
404
- // End of list - cache the results
404
+ if (file) { files.push(file); return }
405
+ files.sort()
406
+ if (opt.cache) {
405
407
  fileListCache = files
406
408
  fileListCacheTime = now
407
409
  }
410
+ read(findFile(files, soul))
408
411
  })
409
412
  }
410
413
  }
package/src/sea-utils.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import SafeBuffer from "./buffer.js"
2
2
 
3
- const isNode = typeof document === "undefined"
3
+ const isNode = typeof process !== "undefined" && process.versions?.node != null
4
4
  const crypto = isNode
5
5
  ? (await import(/*webpackIgnore: true*/ "node:crypto")).webcrypto
6
6
  : globalThis.crypto
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
@@ -2,7 +2,7 @@ import Radisk from "./radisk.js"
2
2
  import Radix from "./radix.js"
3
3
  import * as utils from "./utils.js"
4
4
 
5
- const isNode = typeof document === "undefined"
5
+ const isNode = typeof process !== "undefined" && process.versions?.node != null
6
6
  const fs = isNode ? await import(/*webpackIgnore: true*/ "node:fs") : undefined
7
7
 
8
8
  // ASCII character for enquiry.
@@ -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/user.js CHANGED
@@ -5,7 +5,6 @@ import {userPublicKey, userSignature} from "./utils.js"
5
5
 
6
6
  const User = (opt, wire) => {
7
7
  if (!wire) wire = Wire(opt)
8
- const wait = opt?.wait ?? 5000
9
8
  let pubs = []
10
9
  let creating = false
11
10
  let authing = false
@@ -76,7 +75,6 @@ const User = (opt, wire) => {
76
75
 
77
76
  done(null)
78
77
  },
79
- {wait: wait},
80
78
  )
81
79
  }
82
80
 
@@ -103,7 +101,6 @@ const User = (opt, wire) => {
103
101
  pubs = Object.keys(data)
104
102
  next()
105
103
  },
106
- {wait: wait},
107
104
  )
108
105
  }
109
106
 
@@ -183,7 +180,6 @@ const User = (opt, wire) => {
183
180
  })
184
181
  })
185
182
  },
186
- {wait: wait},
187
183
  )
188
184
  },
189
185
  auth: (username, password, cb) => {