@mblaney/holster 1.0.36 → 2.0.1

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/ts/src/holster.ts CHANGED
@@ -123,28 +123,28 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
123
123
  delete (msg.put[soul] as { _?: unknown })._
124
124
  delete (msg.put[soul] as Record<string, unknown>)[utils.userPublicKey]
125
125
  delete (msg.put[soul] as Record<string, unknown>)[utils.userSignature]
126
- for (const key of Object.keys(msg.put[soul]!)) {
127
- const id = utils.rel.is(msg.put[soul]![key] as GraphValue)
128
- if (id) {
129
- const attemptRead = async (retries = 0): Promise<unknown> => {
130
- const data = await new Promise<unknown>(res => {
131
- const _ctxid = utils.text.random()
132
- const ctx = allctx.get(ctxid!)
133
- allctx.set(_ctxid, {
134
- chain: [{ item: null, soul: id }],
135
- user: ctx ? ctx.user : null,
136
- })
137
- api(_ctxid).next(null as never, res as never, _opt)
126
+ const node = msg.put[soul]!
127
+ await Promise.all(Object.keys(node).map(async key => {
128
+ const id = utils.rel.is(node[key] as GraphValue)
129
+ if (!id) return
130
+ const attemptRead = async (retries = 0): Promise<unknown> => {
131
+ const data = await new Promise<unknown>(res => {
132
+ const _ctxid = utils.text.random()
133
+ const ctx = allctx.get(ctxid!)
134
+ allctx.set(_ctxid, {
135
+ chain: [{ item: null, soul: id }],
136
+ user: ctx ? ctx.user : null,
138
137
  })
139
- if (data !== null || retries >= 5) {
140
- return data
141
- }
142
- await new Promise(resolve => setTimeout(resolve, 50))
143
- return attemptRead(retries + 1)
138
+ api(_ctxid).next(null as never, res as never, _opt)
139
+ })
140
+ if (data !== null || retries >= 5) {
141
+ return data
144
142
  }
145
- msg.put[soul]![key] = (await attemptRead()) as GraphValue
143
+ await new Promise(resolve => setTimeout(resolve, 50))
144
+ return attemptRead(retries + 1)
146
145
  }
147
- }
146
+ node[key] = (await attemptRead()) as GraphValue
147
+ }))
148
148
  ack(msg.put[soul])
149
149
  } else {
150
150
  ack(null)
@@ -192,13 +192,16 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
192
192
  item: string,
193
193
  ctx: ApiContext,
194
194
  i: number,
195
- request: { on?: LexFilter; _get?: boolean; _opt?: WireOptions },
196
- cb: (data: unknown) => void
195
+ request: { on?: LexFilter; get?: LexFilter; _get?: boolean; _opt?: WireOptions },
196
+ cb: (data: unknown) => void,
197
+ on = true
197
198
  ): void => {
199
+ let timer: ReturnType<typeof setTimeout> | null = null
198
200
  const handler = (): void => {
199
201
  // Bail out if the context was removed by off().
200
202
  if (!allctx.has(ctxid!)) {
201
203
  wire.off({ "#": soul, ".": item }, handler)
204
+ if (timer) clearTimeout(timer)
202
205
  return
203
206
  }
204
207
  wire.get(
@@ -206,16 +209,39 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
206
209
  msg => {
207
210
  const node = msg.put && msg.put[soul]
208
211
  const id = utils.rel.is(node && node[item] as GraphValue)
209
- if (!id) return
212
+ if (!id) {
213
+ // If it's a plain value (not a rel) and this is a get, deliver it.
214
+ // node._ guards against timeout null-acks which lack metadata.
215
+ if (!on && node && node._ && typeof node[item] !== "undefined") {
216
+ if (timer) clearTimeout(timer)
217
+ wire.off({ "#": soul, ".": item }, handler)
218
+ cb(node[item])
219
+ }
220
+ return
221
+ }
222
+ if (timer) clearTimeout(timer)
210
223
  wire.off({ "#": soul, ".": item }, handler)
211
224
  ctx.chain[i]!.soul = id
212
225
  allctx.set(ctxid!, { ...ctx })
213
- api(ctxid).on(request.on!, cb, request._get, request._opt)
226
+ if (on) {
227
+ api(ctxid).on(request.on!, cb, request._get, request._opt)
228
+ } else {
229
+ api(ctxid).next(null as never, request.get as never, cb as never, request._opt)
230
+ }
214
231
  },
215
232
  { ...request._opt, secure: (typeof ctx.user === "boolean" ? ctx.user : !!ctx.user) || options.secure }
216
233
  )
217
234
  }
218
235
  wire.on({ "#": soul, ".": item }, handler, false, request._opt)
236
+ // Time out after the same total duration as the on() retry loop
237
+ // (1+2+4+8+16 = 31s) so callers are not blocked forever if the node
238
+ // genuinely doesn't exist.
239
+ if (!on) {
240
+ timer = setTimeout(() => {
241
+ wire.off({ "#": soul, ".": item }, handler)
242
+ if (cb) cb(null)
243
+ }, 31000)
244
+ }
219
245
  }
220
246
 
221
247
  const resolve = (
@@ -240,7 +266,8 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
240
266
  const off = typeof request.off !== "undefined"
241
267
 
242
268
  let found = false
243
- const ctx = allctx.get(ctxid!)!
269
+ const ctx = allctx.get(ctxid!)
270
+ if (!ctx) return {item: null, soul: null}
244
271
  for (let i = 1; i < ctx.chain.length; i++) {
245
272
  if (ctx.chain[i]!.soul !== null) continue
246
273
  found = true
@@ -321,7 +348,17 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
321
348
  // Node doesn't exist yet — watch for it to appear.
322
349
  watchForRel(soul!, item!, ctx, i, request, cb!)
323
350
  if (request._get) cb!(null)
351
+ } else if (get && node) {
352
+ const sv = (node as Record<string, unknown> & {_?: {">": Record<string, unknown>}})._?.[">"]
353
+ if (sv && Object.keys(sv).length > 0 && typeof sv[item!] === "undefined") {
354
+ // State vector has other properties but not this one — never written.
355
+ if (cb) cb(null)
356
+ } else {
357
+ // Empty state vector or item is tracked — may arrive via push.
358
+ watchForRel(soul!, item!, ctx, i, request, cb!, false)
359
+ }
324
360
  } else {
361
+ // Soul doesn't exist or no get — return null.
325
362
  if (cb) cb(null)
326
363
  }
327
364
  }
@@ -484,8 +521,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
484
521
  const ctx = allctx.get(ctxid!)
485
522
  if (!ctx) return
486
523
 
487
- if (!ctx.cb) {
488
- if (!callback) return
524
+ if (!ctx.cb && callback) {
489
525
  ctx.cb = callback as never
490
526
  callback = undefined
491
527
  }
@@ -499,7 +535,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
499
535
  return
500
536
  }
501
537
 
502
- const resolved = resolve({ put: data }, _ack as never)
538
+ const resolved = resolve({ put: data, _opt: {put: true} }, _ack as never)
503
539
  if (!resolved) return
504
540
 
505
541
  const { item, soul } = resolved
@@ -566,7 +602,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
566
602
  wire.put(g, _ack as never)
567
603
  })
568
604
  },
569
- { secure: (typeof ctx.user === "boolean" ? ctx.user : !!ctx.user) || options.secure }
605
+ { secure: (typeof ctx.user === "boolean" ? ctx.user : !!ctx.user) || options.secure, put: true }
570
606
  )
571
607
  return
572
608
  }
@@ -650,7 +686,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
650
686
 
651
687
  wire.put(g, _ack as never)
652
688
  },
653
- { secure: (typeof ctx.user === "boolean" ? ctx.user : !!ctx.user) || options.secure }
689
+ { secure: (typeof ctx.user === "boolean" ? ctx.user : !!ctx.user) || options.secure, put: true }
654
690
  )
655
691
  }
656
692
  attemptRead()
@@ -808,10 +844,7 @@ const Holster = (opt?: HolsterOptions | string | string[]): HolsterAPI => {
808
844
  const id = utils.rel.is(current as GraphValue)
809
845
  if (id) {
810
846
  wire.off(initialLex as never, map.get(callback)!)
811
- const relLex = (lexFilter
812
- ? utils.obj.put(initialLex as never, "#", id)
813
- : { "#": id, ".": null }) as Lex
814
- wire.on(relLex, map.get(callback)!, false, opts)
847
+ wire.on({ "#": id, ".": null } as never, map.get(callback)!, false, opts)
815
848
  if (_get) map.get(callback)!()
816
849
  } else if (_get) {
817
850
  // Not a rel, but _get was requested, so trigger callback.
package/ts/src/radisk.ts CHANGED
@@ -48,11 +48,7 @@ const Radisk = (opt: RadiskOptions) => {
48
48
 
49
49
  const pendingReads = new Map<
50
50
  string,
51
- {
52
- callbacks: Array<(err?: string, result?: EncodedValue | Record<string, EncodedValue>) => void>
53
- fired: boolean
54
- timeoutId: NodeJS.Timeout | null
55
- }
51
+ Array<(err?: string | null, result?: EncodedValue | Record<string, EncodedValue>) => void>
56
52
  >()
57
53
 
58
54
  let lastMemoryCheck = 0
@@ -66,7 +62,6 @@ const Radisk = (opt: RadiskOptions) => {
66
62
  write: opt.write || 1,
67
63
  size: opt.size || 1024 * 1024,
68
64
  memoryLimit: opt.memoryLimit || 500,
69
- readTimeout: opt.readTimeout || 1000,
70
65
  cache: opt.cache !== undefined ? opt.cache : true,
71
66
  store: opt.store,
72
67
  }
@@ -89,7 +84,7 @@ const Radisk = (opt: RadiskOptions) => {
89
84
 
90
85
  const perfLog = (operation: string, startTime: number, key: string, size?: number): void => {
91
86
  const duration = Date.now() - startTime
92
- if (duration > 2000) {
87
+ if (duration > 3000) {
93
88
  console.log(
94
89
  `[RADISK-SLOW] ${operation}: ${duration}ms for ${key}${size ? ` (${size} bytes)` : ""}`
95
90
  )
@@ -143,35 +138,25 @@ const Radisk = (opt: RadiskOptions) => {
143
138
  }
144
139
 
145
140
  if (pendingReads.has(key)) {
146
- pendingReads.get(key)!.callbacks.push(callback)
141
+ pendingReads.get(key)!.push(callback)
147
142
  return
148
143
  }
149
144
 
150
- const pending: {
151
- callbacks: Array<(err?: string | null, value?: EncodedValue | Record<string, EncodedValue>) => void>
152
- fired: boolean
153
- timeoutId: NodeJS.Timeout | null
154
- } = { callbacks: [callback], fired: false, timeoutId: null }
155
- pendingReads.set(key, pending)
156
-
157
- pending.timeoutId = setTimeout(() => {
158
- if (!pending.fired) {
159
- pending.fired = true
160
- pendingReads.delete(key)
161
- const err = "radisk read timeout"
162
- pending.callbacks.forEach(cb => cb(err, undefined))
163
- }
164
- }, options.readTimeout)
165
-
145
+ pendingReads.set(key, [callback])
166
146
  const readStart = Date.now()
147
+ const timeout = setTimeout(() => {
148
+ const callbacks = pendingReads.get(key)
149
+ if (!callbacks) return
150
+ options.log(`radisk read hang detected for key: ${key}`)
151
+ pendingReads.delete(key)
152
+ callbacks.forEach(cb => cb(null, null as unknown as undefined))
153
+ }, 30000)
167
154
  return radisk.read(key, (err, result) => {
168
- if (pending.timeoutId) clearTimeout(pending.timeoutId)
155
+ clearTimeout(timeout)
169
156
  perfLog("read", readStart, key)
170
- if (!pending.fired) {
171
- pending.fired = true
172
- pendingReads.delete(key)
173
- pending.callbacks.forEach(callback => callback(err, result))
174
- }
157
+ const callbacks = pendingReads.get(key) || []
158
+ pendingReads.delete(key)
159
+ callbacks.forEach(callback => callback(err, result))
175
160
  })
176
161
  }
177
162
 
@@ -288,22 +273,55 @@ const Radisk = (opt: RadiskOptions) => {
288
273
  done: false,
289
274
  count: 0,
290
275
  sub: undefined as RadixFunction | undefined,
291
- each: (value: EncodedValue, _key: string, k: string, pre: string[]): void | boolean => {
276
+ halfKey: "", // soul ID at the ~options.size/2 mark for a balanced split
277
+ halfOffset: 0, // write.text.length before that entry
278
+ each: (
279
+ value: EncodedValue,
280
+ _key: string,
281
+ k: string,
282
+ pre: string[],
283
+ ): void | boolean => {
292
284
  if (write.done) return
293
285
  write.count++
294
286
  const valueStr =
295
287
  typeof value === "undefined" ? "" : "=" + Radisk.encode(value)
296
288
  const enc =
297
289
  Radisk.encode(pre.length) + "#" + Radisk.encode(k) + valueStr + "\n"
290
+ const validBoundary =
291
+ pre.length === 0 || (pre.length === 1 && k.includes(enq))
292
+
293
+ // Record the first valid split boundary past the halfway mark so a
294
+ // full overflow can split here instead of at the tail, keeping both
295
+ // halves near options.size/2 rather than leaving the file always full.
296
+ if (
297
+ !write.halfKey &&
298
+ write.count > 1 &&
299
+ write.text.length > options.size / 2 &&
300
+ validBoundary
301
+ ) {
302
+ const fullKey = pre.join("") + k
303
+ const endIdx = fullKey.indexOf(enq)
304
+ write.halfKey =
305
+ endIdx === -1 ? fullKey : fullKey.substring(0, endIdx)
306
+ write.halfOffset = write.text.length
307
+ }
298
308
 
299
309
  if (
300
310
  write.count > 1 &&
301
- pre.length === 0 &&
302
- write.text.length + enc.length > options.size
311
+ write.text.length + enc.length > options.size &&
312
+ validBoundary
303
313
  ) {
304
- const endIdx = k.indexOf(enq)
305
- write.limit = endIdx === -1 ? k : k.substring(0, endIdx)
314
+ const fullKey = pre.join("") + k
315
+ const endIdx = fullKey.indexOf(enq)
316
+ write.limit =
317
+ write.halfKey ||
318
+ (endIdx === -1 ? fullKey : fullKey.substring(0, endIdx))
306
319
  if (write.limit !== file) {
320
+ // Truncate to the balanced split point — text after halfOffset is
321
+ // handled by the write.slice call below, which reads from rad.
322
+ if (write.halfKey) {
323
+ write.text = write.text.substring(0, write.halfOffset)
324
+ }
307
325
  write.done = true
308
326
  write.sub = Radix()
309
327
  Radix.map(rad, write.slice)
@@ -321,6 +339,13 @@ const Radisk = (opt: RadiskOptions) => {
321
339
  Radix.map(rad, write.each, true)
322
340
  const writeStart = Date.now()
323
341
  store.put(file, write.text, err => {
342
+ // If a split occurred the cached radix tree still contains entries that
343
+ // were moved to the sub-file. Invalidate so the next save.mix re-reads
344
+ // from disk rather than re-writing those entries back to this file.
345
+ if (write.done) {
346
+ cache.delete(file)
347
+ fileListCache = null
348
+ }
324
349
  perfLog("file-write", writeStart, file, write.text.length)
325
350
  cb(err)
326
351
  })
@@ -330,64 +355,45 @@ const Radisk = (opt: RadiskOptions) => {
330
355
  const endIdx = key.indexOf(enq)
331
356
  const soul = endIdx === -1 ? key : key.substring(0, endIdx)
332
357
 
333
- const read = {
334
- lex: (file?: string): void => {
335
- if (!file) {
336
- if (!read.file) {
337
- cb("no file found", undefined)
338
- return
339
- }
340
-
341
- if (options.cache && cache.has(read.file)) {
342
- const cachedRadix = cache.get(read.file)!
343
- read.value = cachedRadix(key) as EncodedValue | Record<string, EncodedValue> | undefined
344
- return cb(undefined, read.value)
345
- }
346
-
347
- radisk.parse(read.file, read.it)
348
- return
349
- }
358
+ const findFile = (files: string[]): string | null => {
359
+ let lo = 0, hi = files.length - 1, result: string | null = null
360
+ while (lo <= hi) {
361
+ const mid = (lo + hi) >> 1
362
+ const f = files[mid]!
363
+ if (f <= soul) { result = f; lo = mid + 1 }
364
+ else { hi = mid - 1 }
365
+ }
366
+ return result
367
+ }
350
368
 
351
- if (file > soul || file < (read.file || "")) return
352
- read.file = file
353
- },
354
- it: (err?: string | null, disk?: RadixFunction): void => {
369
+ const read = (file: string | null): void => {
370
+ if (!file) { cb("no file found", undefined); return }
371
+ if (options.cache && cache.has(file)) {
372
+ return cb(undefined, cache.get(file)!(key) as EncodedValue | Record<string, EncodedValue> | undefined)
373
+ }
374
+ radisk.parse(file, (err, disk) => {
355
375
  if (err) options.log(err)
356
- if (disk) {
357
- if (options.cache) {
358
- cache.set(read.file!, disk)
359
- checkMemoryUsage()
360
- }
361
- read.value = disk(key) as EncodedValue | Record<string, EncodedValue> | undefined
376
+ if (disk && options.cache) {
377
+ cache.set(file, disk)
378
+ checkMemoryUsage()
362
379
  }
363
- cb(err, read.value)
364
- },
365
- file: undefined as string | undefined,
366
- value: undefined as EncodedValue | Record<string, EncodedValue> | undefined,
380
+ cb(err, disk ? disk(key) as EncodedValue | Record<string, EncodedValue> | undefined : undefined)
381
+ })
367
382
  }
368
383
 
369
384
  const now = Date.now()
370
- if (
371
- options.cache &&
372
- fileListCache &&
373
- now - fileListCacheTime < FILE_LIST_CACHE_TTL
374
- ) {
375
- fileListCache.forEach(file => read.lex(file))
376
- read.lex()
385
+ if (options.cache && fileListCache && now - fileListCacheTime < FILE_LIST_CACHE_TTL) {
386
+ read(findFile(fileListCache))
377
387
  } else {
378
388
  const files: string[] = []
379
- const originalLex = read.lex
380
- read.lex = (file?: string): void => {
381
- if (file) files.push(file)
382
- return originalLex(file)
383
- }
384
-
385
389
  store.list((file?: string) => {
386
- read.lex(file)
387
- if (!file && options.cache) {
390
+ if (file) { files.push(file); return }
391
+ files.sort()
392
+ if (options.cache) {
388
393
  fileListCache = files
389
394
  fileListCacheTime = now
390
395
  }
396
+ read(findFile(files))
391
397
  })
392
398
  }
393
399
  }
package/ts/src/schemas.ts CHANGED
@@ -184,7 +184,7 @@ export const WireOptionsSchema = z
184
184
  .object({
185
185
  secure: z.boolean().optional(),
186
186
  fast: z.boolean().optional(),
187
- wait: z.number().optional(),
187
+ put: z.boolean().optional(),
188
188
  })
189
189
  .optional()
190
190
  export type WireOptions = z.infer<typeof WireOptionsSchema>
@@ -319,7 +319,6 @@ export const RadiskOptionsSchema = z.object({
319
319
  write: z.number().optional(),
320
320
  size: z.number().optional(),
321
321
  memoryLimit: z.number().optional(),
322
- readTimeout: z.number().optional(),
323
322
  cache: z.boolean().optional(),
324
323
  store: FileSystemInterfaceSchema.optional(),
325
324
  })
@@ -330,7 +329,6 @@ export interface RadiskOptions {
330
329
  write?: number
331
330
  size?: number
332
331
  memoryLimit?: number
333
- readTimeout?: number
334
332
  cache?: boolean
335
333
  store?: FileSystemInterface
336
334
  }
@@ -404,11 +402,9 @@ export const HolsterOptionsSchema = z
404
402
  port: z.number().optional(),
405
403
  server: z.any().optional(),
406
404
  wss: z.any().optional(),
407
- maxAge: z.number().optional(),
408
405
  maxConnections: z.number().optional(),
409
406
  maxMessageSize: z.number().optional(),
410
407
  maxQueueLength: z.number().optional(),
411
- wait: z.number().optional(),
412
408
  })
413
409
  .passthrough()
414
410
  export type HolsterOptions = z.infer<typeof HolsterOptionsSchema>
@@ -6,7 +6,7 @@
6
6
  import SafeBuffer from "./buffer.ts"
7
7
  import type { JWK } from "./schemas.ts"
8
8
 
9
- const isNode = typeof document === "undefined"
9
+ const isNode = typeof process !== "undefined" && process.versions?.node != null
10
10
 
11
11
  // Get crypto based on environment - dynamic import for Node.js compatibility
12
12
  const crypto: Crypto = isNode
package/ts/src/store.ts CHANGED
@@ -15,7 +15,7 @@ import type {
15
15
  EncodedValue,
16
16
  } from "./schemas.ts"
17
17
 
18
- const isNode = typeof document === "undefined"
18
+ const isNode = typeof process !== "undefined" && process.versions?.node != null
19
19
 
20
20
  // Dynamic import for Node.js fs module - won't execute in browser/service worker
21
21
  const fs = isNode
package/ts/src/user.ts CHANGED
@@ -38,7 +38,6 @@ export interface UserInterface {
38
38
  */
39
39
  const User = (opt: HolsterOptions | undefined, wire?: WireInterface): UserInterface => {
40
40
  if (!wire) wire = Wire(opt as HolsterOptions)
41
- const wait = opt?.wait ?? 5000
42
41
  let pubs: string[] = []
43
42
  let creating = false
44
43
  let authing = false
@@ -117,7 +116,6 @@ const User = (opt: HolsterOptions | undefined, wire?: WireInterface): UserInterf
117
116
 
118
117
  done(null)
119
118
  },
120
- { wait: wait }
121
119
  )
122
120
  }
123
121
 
@@ -142,7 +140,6 @@ const User = (opt: HolsterOptions | undefined, wire?: WireInterface): UserInterf
142
140
  pubs = Object.keys(data)
143
141
  next()
144
142
  },
145
- { wait: wait }
146
143
  )
147
144
  }
148
145
 
@@ -221,7 +218,6 @@ const User = (opt: HolsterOptions | undefined, wire?: WireInterface): UserInterf
221
218
  })
222
219
  })
223
220
  },
224
- { wait: wait }
225
221
  )
226
222
  },
227
223
  auth: (username, password, cb) => {