@far-world-labs/verblets 0.1.4 → 0.2.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 (46) hide show
  1. package/.github/workflows/ci.yml +38 -43
  2. package/.vitest.config.examples.js +4 -0
  3. package/DEVELOPING.md +1 -1
  4. package/package.json +9 -9
  5. package/scripts/clear-redis.js +74 -0
  6. package/src/chains/conversation/README.md +26 -0
  7. package/src/chains/conversation/index.examples.js +398 -0
  8. package/src/chains/conversation/index.js +126 -0
  9. package/src/chains/conversation/index.spec.js +148 -0
  10. package/src/chains/conversation/turn-policies.js +93 -0
  11. package/src/chains/conversation/turn-policies.md +123 -0
  12. package/src/chains/conversation/turn-policies.spec.js +135 -0
  13. package/src/chains/expect/index.js +34 -0
  14. package/src/chains/intersections/README.md +20 -6
  15. package/src/chains/intersections/index.examples.js +9 -8
  16. package/src/chains/intersections/index.js +39 -187
  17. package/src/chains/llm-logger/README.md +291 -133
  18. package/src/chains/llm-logger/index.js +451 -65
  19. package/src/chains/llm-logger/index.spec.js +85 -24
  20. package/src/chains/llm-logger/schema.json +105 -0
  21. package/src/chains/set-interval/index.examples.js +34 -6
  22. package/src/chains/set-interval/index.js +53 -32
  23. package/src/chains/themes/index.js +2 -2
  24. package/src/constants/common.js +7 -1
  25. package/src/constants/models.js +21 -9
  26. package/src/index.js +14 -4
  27. package/src/lib/assert/README.md +84 -0
  28. package/src/lib/assert/index.js +50 -0
  29. package/src/lib/ring-buffer/README.md +50 -428
  30. package/src/lib/ring-buffer/index.js +148 -987
  31. package/src/lib/ring-buffer/index.spec.js +388 -0
  32. package/src/verblets/conversation-turn/README.md +33 -0
  33. package/src/verblets/conversation-turn/index.examples.js +218 -0
  34. package/src/verblets/conversation-turn/index.js +68 -0
  35. package/src/verblets/conversation-turn/index.spec.js +77 -0
  36. package/src/verblets/conversation-turn-multi/README.md +31 -0
  37. package/src/verblets/conversation-turn-multi/index.examples.js +160 -0
  38. package/src/verblets/conversation-turn-multi/index.js +104 -0
  39. package/src/verblets/conversation-turn-multi/index.spec.js +63 -0
  40. package/src/verblets/intent/index.examples.js +1 -1
  41. package/src/verblets/intersection/index.js +46 -5
  42. package/src/verblets/people-list/README.md +28 -0
  43. package/src/verblets/people-list/index.examples.js +184 -0
  44. package/src/verblets/people-list/index.js +44 -0
  45. package/src/verblets/people-list/index.spec.js +49 -0
  46. package/scripts/version-bump.js +0 -33
@@ -1,1074 +1,235 @@
1
1
  /**
2
- * Ring Buffer - Memory-efficient circular buffer for data ingestion and processing
3
- *
4
- * A generic ring buffer (circular buffer) that automatically evicts oldest entries when full.
5
- * Designed for high-throughput data ingestion, lane-based processing, and batch operations
6
- * where memory efficiency and fast access patterns are critical.
7
- *
8
- * Features:
9
- * - Automatic memory management with configurable size limits
10
- * - Multiple cursor support for concurrent processing
11
- * - Lane-based filtering and processing with independent flush loops
12
- * - Batch operations optimized for various workflows
13
- * - Slice operations with ID-based and index-based access
14
- * - Statistics and analysis helpers
15
- * - Iterator support for streaming operations
16
- * - Generic data support (not limited to logs)
2
+ * High-performance ring buffer with single writer, multiple async readers.
3
+ * Uses double-buffer technique to eliminate wraparound logic.
4
+ * Optimized for performance over safety - assumes buffer is sized appropriately.
17
5
  */
18
-
19
- /**
20
- * @typedef {Object} RingBufferEntry
21
- * @property {number} id - Unique incrementing identifier
22
- * @property {Date} timestamp - When entry was added
23
- * @property {any} data - The actual data payload
24
- * @property {Map<string, any>} [meta] - Optional metadata
25
- */
26
-
27
- /**
28
- * @typedef {Object} DataEntry
29
- * @property {number} id - Unique incrementing identifier
30
- * @property {Date} ts - When entry was created
31
- * @property {any} raw - The raw data payload
32
- * @property {Object} variables - Extracted/computed variables
33
- * @property {Object} context - Context information (file, line, etc.)
34
- * @property {string[]} tags - Classification tags/biomarkers
35
- * @property {Map<string, any>} meta - Additional metadata
36
- */
37
-
38
- /**
39
- * @typedef {Object} ProcessingLane
40
- * @property {string} id - Lane identifier
41
- * @property {Function} [filter] - Filter function (entry) => boolean
42
- * @property {Function} writer - Output writer function (lines: string[]) => void
43
- * @property {Function} [batchHandler] - Batch processing function
44
- * @property {RingBufferEntry[]} buffer - Pending entries for this lane
45
- * @property {boolean} flushActive - Whether flush loop is active
46
- * @property {string} [cursorName] - Associated cursor name for tracking
47
- * @property {Object} [config] - Lane-specific configuration
48
- */
49
-
50
- /**
51
- * @typedef {Object} RingBufferCursor
52
- * @property {string} name - Cursor identifier
53
- * @property {number} position - Current position (entry ID)
54
- * @property {Date} lastMoved - When cursor was last updated
55
- * @property {Map<string, any>} meta - Cursor-specific metadata
56
- */
57
-
58
- /**
59
- * @typedef {Object} RingBufferStats
60
- * @property {number} size - Current number of entries
61
- * @property {number} capacity - Maximum capacity
62
- * @property {number} totalAdded - Total entries added (including evicted)
63
- * @property {number} totalEvicted - Total entries evicted
64
- * @property {number} oldestId - ID of oldest entry in buffer
65
- * @property {number} newestId - ID of newest entry in buffer
66
- * @property {Date} oldestTimestamp - Timestamp of oldest entry
67
- * @property {Date} newestTimestamp - Timestamp of newest entry
68
- * @property {number} cursors - Number of active cursors
69
- * @property {number} lanes - Number of active processing lanes
70
- */
71
-
72
- /**
73
- * @typedef {Object} StableBatch
74
- * @property {string} batchId - Unique identifier for the batch
75
- * @property {number} batchIndex - Index of the batch in the sequence
76
- * @property {number} startId - Starting entry ID (inclusive)
77
- * @property {number} endId - Ending entry ID (exclusive)
78
- * @property {number} size - Number of entries in the batch
79
- * @property {Date} timestamp - When the batch definition was created
80
- */
81
-
82
- /**
83
- * @typedef {Object} StableBatchResult
84
- * @property {string} batchId - Unique identifier for the batch
85
- * @property {StableBatch} batchDef - The batch definition
86
- * @property {any} result - Result from processing the batch
87
- * @property {boolean} skipped - Whether the batch was skipped
88
- * @property {number} entriesProcessed - Number of entries processed
89
- * @property {number} attempts - Number of processing attempts
90
- */
91
-
92
- /**
93
- * @typedef {Object} BatchCursorResult
94
- * @property {boolean} done - Whether iteration is complete
95
- * @property {RingBufferEntry[]} entries - Entries in the current batch
96
- * @property {StableBatch|null} batchDef - Batch definition (null if done)
97
- * @property {boolean} [hasMore] - Whether more batches are available
98
- */
99
-
100
- /**
101
- * @typedef {Object} BatchCursor
102
- * @property {string} cursorName - Name of the cursor
103
- * @property {number} batchSize - Size of each batch
104
- * @property {string} batchIdPrefix - Prefix for batch IDs
105
- * @property {Function} next - Get next batch: (moveCursor?: boolean) => BatchCursorResult
106
- * @property {Function} reset - Reset cursor: (position?: number) => void
107
- * @property {Function} getStatus - Get cursor status: () => Object
108
- */
109
-
110
- /**
111
- * @typedef {Object} BatchHandler
112
- * @property {Function} process - Process batch: (params: {head: number, cursor: number, entries: RingBufferEntry[], batch: RingBufferEntry[]}) => Promise<string[][]> | undefined
113
- */
114
-
115
6
  export default class RingBuffer {
116
- constructor(maxSize = 10000) {
7
+ constructor(maxSize = 1000) {
117
8
  this.maxSize = maxSize;
118
- this.entries = [];
119
- this.nextId = 1;
120
- this.totalAdded = 0;
121
- this.totalEvicted = 0;
122
- this.cursors = new Map();
123
- this.lanes = new Map(); // Processing lanes
124
- }
9
+ this.writeIndex = 0;
10
+ this.sequence = 0;
11
+ this.nextReaderId = 0;
125
12
 
126
- /**
127
- * Add entry to buffer
128
- * @param {any} data - Data to store
129
- * @param {Map<string, any>} [meta] - Entry metadata
130
- * @returns {RingBufferEntry} The created entry
131
- */
132
- push(data, meta = new Map()) {
133
- const entry = {
134
- id: this.nextId++,
135
- ts: new Date(),
136
- data,
137
- meta: new Map(meta),
138
- };
139
-
140
- // Handle eviction if buffer is full
141
- if (this.entries.length >= this.maxSize) {
142
- const evicted = this.entries.shift();
143
- this.totalEvicted++;
144
-
145
- // Update cursors that point to evicted entries
146
- for (const cursor of this.cursors.values()) {
147
- if (cursor.position <= evicted.id) {
148
- cursor.position = evicted.id + 1;
149
- }
150
- }
151
- }
13
+ // Double buffer: second half mirrors first half for wraparound-free reads
14
+ this.buffer = new Array(maxSize * 2);
152
15
 
153
- this.entries.push(entry);
154
- this.totalAdded++;
16
+ // Reader tracking - Map for O(1) lookups
17
+ this.readers = new Map(); // readerId -> lastReadSequence
155
18
 
156
- // Process through lanes
157
- this._processLanes(entry);
158
-
159
- return entry;
19
+ // Notification system for blocking reads
20
+ this.waitingReaders = new Set(); // Set of { resolve, readerId, batchSize? }
160
21
  }
161
22
 
162
23
  /**
163
- * Ingest structured data entry (PEC-01 compatible)
164
- * @param {string} raw - Raw log data
165
- * @param {Object} [options] - Structured data options
166
- * @param {Object} [options.variables] - Variable data
167
- * @param {Object} [options.context] - Context information
168
- * @param {string[]} [options.tags] - Tags array
169
- * @param {Map} [options.meta] - Additional metadata
170
- * @returns {DataEntry} The created structured entry
24
+ * Register a new reader.
25
+ * @returns {string} Reader ID
171
26
  */
172
- ingest(raw, options = {}) {
173
- const entry = {
174
- id: this.nextId++,
175
- ts: new Date(),
176
- raw,
177
- variables: options.variables || {},
178
- context: options.context || { filePath: '', line: 0 },
179
- tags: options.tags || [],
180
- meta: options.meta || new Map(),
181
- };
182
-
183
- // Handle eviction if buffer is full
184
- if (this.entries.length >= this.maxSize) {
185
- const evicted = this.entries.shift();
186
- this.totalEvicted++;
187
-
188
- // Update cursors that point to evicted entries
189
- for (const cursor of this.cursors.values()) {
190
- if (cursor.position <= evicted.id) {
191
- cursor.position = evicted.id + 1;
192
- }
193
- }
194
- }
195
-
196
- this.entries.push(entry);
197
- this.totalAdded++;
198
-
199
- // Process through lanes
200
- this._processLanes(entry);
201
-
202
- return entry;
27
+ registerReader() {
28
+ const readerId = `r${this.nextReaderId++}`;
29
+ this.readers.set(readerId, -1);
30
+ return readerId;
203
31
  }
204
32
 
205
33
  /**
206
- * Register a processing lane
207
- * @param {ProcessingLane} lane - Lane configuration
208
- * @returns {ProcessingLane} The registered lane
34
+ * Unregister a reader.
35
+ * @param {string} readerId
209
36
  */
210
- addLane(lane) {
211
- const laneConfig = {
212
- id: lane.id,
213
- filter: lane.filter,
214
- writer: lane.writer,
215
- batchHandler: lane.batchHandler,
216
- buffer: [],
217
- flushActive: false,
218
- cursorName: lane.cursorName || `lane-${lane.id}`,
219
- config: lane.config || {},
220
- ...lane,
221
- };
222
-
223
- this.lanes.set(lane.id, laneConfig);
224
-
225
- // Create cursor for this lane if specified
226
- if (laneConfig.cursorName) {
227
- this.setCursor(laneConfig.cursorName, this.nextId - 1);
228
- }
229
-
230
- return laneConfig;
231
- }
232
-
233
- /**
234
- * Remove a processing lane
235
- * @param {string} laneId - Lane identifier
236
- * @returns {boolean} True if lane was removed
237
- */
238
- removeLane(laneId) {
239
- const lane = this.lanes.get(laneId);
240
- if (lane && lane.cursorName) {
241
- this.removeCursor(lane.cursorName);
242
- }
243
- return this.lanes.delete(laneId);
244
- }
245
-
246
- /**
247
- * Get processing lane by ID
248
- * @param {string} laneId - Lane identifier
249
- * @returns {ProcessingLane|undefined} The lane configuration
250
- */
251
- getLane(laneId) {
252
- return this.lanes.get(laneId);
253
- }
254
-
255
- /**
256
- * Get all processing lanes
257
- * @returns {ProcessingLane[]} Array of all lanes
258
- */
259
- getAllLanes() {
260
- return Array.from(this.lanes.values());
261
- }
262
-
263
- /**
264
- * Process entry through all registered lanes
265
- * @private
266
- * @param {RingBufferEntry|DataEntry} entry - Entry to process
267
- */
268
- _processLanes(entry) {
269
- for (const lane of this.lanes.values()) {
270
- // Apply filter if specified
271
- if (!lane.filter || lane.filter(entry)) {
272
- lane.buffer.push(entry);
273
-
274
- // Check if we should trigger flush
275
- const batchSize = lane.config.batchSize || 10;
276
- if (lane.buffer.length >= batchSize && !lane.flushActive) {
277
- // Use setTimeout to avoid blocking
278
- setTimeout(() => this._flushLane(lane), 0);
37
+ unregisterReader(readerId) {
38
+ this.readers.delete(readerId);
39
+ // Remove any waiting operations for this reader
40
+ for (const waiter of this.waitingReaders) {
41
+ if (waiter.readerId === readerId) {
42
+ if (waiter.batchSize) {
43
+ waiter.resolve({ data: [], startOffset: 0, lastOffset: -1 });
44
+ } else {
45
+ waiter.resolve({ data: null, offset: -1 });
279
46
  }
47
+ this.waitingReaders.delete(waiter);
280
48
  }
281
49
  }
282
50
  }
283
51
 
284
52
  /**
285
- * Flush a processing lane
286
- * @private
287
- * @param {ProcessingLane} lane - Lane to flush
53
+ * Write data to buffer (single writer only).
54
+ * @param {any} data
55
+ * @returns {number} Sequence number
288
56
  */
289
- async _flushLane(lane) {
290
- if (lane.flushActive) return;
57
+ write(data) {
58
+ const seq = this.sequence++;
59
+ const idx = this.writeIndex;
291
60
 
292
- lane.flushActive = true;
61
+ // Write to both positions (main and mirror)
62
+ this.buffer[idx] = data;
63
+ this.buffer[idx + this.maxSize] = data;
293
64
 
294
- try {
295
- while (lane.buffer.length > 0) {
296
- const batchSize = lane.config.batchSize || 10;
297
- const batch = lane.buffer.slice(0, Math.min(batchSize, lane.buffer.length));
65
+ this.writeIndex = (this.writeIndex + 1) % this.maxSize;
298
66
 
299
- if (batch.length === 0) break;
300
-
301
- const cursor = batch[batch.length - 1].id;
302
- let result = batch; // Default to the batch itself
303
-
304
- // Use batch handler if available
305
- if (lane.batchHandler) {
306
- const params = {
307
- head: this.nextId - 1,
308
- cursor,
309
- entries: this.entries,
310
- batch,
311
- };
312
-
313
- try {
314
- result = await lane.batchHandler.process(params);
315
- } catch (error) {
316
- console.error(`Lane ${lane.id} batch handler error:`, error);
317
- // Skip this batch on error
318
- lane.buffer.splice(0, batch.length);
319
- continue;
320
- }
321
- }
67
+ // Notify waiting readers
68
+ this._notifyWaiters(seq);
322
69
 
323
- // Write results
324
- if (lane.writer && result) {
325
- const lines = Array.isArray(result) ? result.flat() : [String(result)];
326
- if (lines.length > 0) {
327
- try {
328
- await lane.writer(lines);
329
- } catch (error) {
330
- console.error(`Lane ${lane.id} writer error:`, error);
331
- }
332
- }
333
- }
334
-
335
- // Remove processed entries from buffer
336
- lane.buffer.splice(0, batch.length);
337
-
338
- // Update cursor if specified
339
- if (lane.cursorName) {
340
- this.moveCursor(lane.cursorName, cursor);
341
- }
342
-
343
- // If batch was smaller than requested size, we're done
344
- if (batch.length < batchSize) {
345
- break;
346
- }
347
- }
348
- } finally {
349
- lane.flushActive = false;
350
- }
70
+ return seq;
351
71
  }
352
72
 
353
73
  /**
354
- * Manually trigger flush for a specific lane
355
- * @param {string} laneId - Lane identifier
356
- * @returns {Promise<void>}
74
+ * Read latest data for a reader (blocks until available).
75
+ * @param {string} readerId
76
+ * @returns {Promise<{data: any, offset: number}>}
357
77
  */
358
- async flushLane(laneId) {
359
- const lane = this.lanes.get(laneId);
360
- if (lane) {
361
- await this._flushLane(lane);
78
+ read(readerId) {
79
+ const lastSeq = this.readers.get(readerId);
80
+ if (lastSeq === undefined) {
81
+ throw new Error(`Reader ${readerId} not registered`);
362
82
  }
363
- }
364
83
 
365
- /**
366
- * Manually trigger flush for all lanes
367
- * @returns {Promise<void>}
368
- */
369
- async flushAllLanes() {
370
- const flushPromises = Array.from(this.lanes.values()).map((lane) => this._flushLane(lane));
371
- await Promise.all(flushPromises);
372
- }
373
-
374
- /**
375
- * Add multiple entries in batch
376
- * @param {any[]} dataArray - Array of data to add
377
- * @param {Map<string, any>} [sharedMeta] - Metadata to apply to all entries
378
- * @returns {RingBufferEntry[]} Array of created entries
379
- */
380
- pushBatch(dataArray, sharedMeta = new Map()) {
381
- const entries = [];
382
- for (const data of dataArray) {
383
- entries.push(this.push(data, new Map(sharedMeta)));
84
+ // Check if new data is available
85
+ if (this.sequence > lastSeq + 1) {
86
+ const nextSeq = lastSeq + 1;
87
+ const data = this._getDataAtSequence(nextSeq);
88
+ this.readers.set(readerId, nextSeq);
89
+ return Promise.resolve({ data, offset: nextSeq });
384
90
  }
385
- return entries;
386
- }
387
-
388
- /**
389
- * Get all entries in buffer (oldest → newest)
390
- * @returns {RingBufferEntry[]} All entries
391
- */
392
- all() {
393
- return [...this.entries];
394
- }
395
-
396
- /**
397
- * Get entries within ID range
398
- * @param {number} startId - Starting ID (inclusive)
399
- * @param {number} [endId] - Ending ID (exclusive)
400
- * @returns {RingBufferEntry[]} Filtered entries
401
- */
402
- slice(startId, endId) {
403
- return this.entries.filter((entry) => {
404
- return entry.id >= startId && (endId === undefined || entry.id < endId);
405
- });
406
- }
407
-
408
- /**
409
- * Get entries within index range (like Array.slice)
410
- * @param {number} [start=0] - Start index
411
- * @param {number} [end] - End index
412
- * @returns {RingBufferEntry[]} Sliced entries
413
- */
414
- sliceByIndex(start = 0, end) {
415
- return this.entries.slice(start, end);
416
- }
417
91
 
418
- /**
419
- * Get entries within time range
420
- * @param {Date} startTime - Start time (inclusive)
421
- * @param {Date} [endTime] - End time (exclusive)
422
- * @returns {RingBufferEntry[]} Filtered entries
423
- */
424
- sliceByTime(startTime, endTime) {
425
- return this.entries.filter((entry) => {
426
- const timestamp = entry.timestamp || entry.ts;
427
- return timestamp >= startTime && (endTime === undefined || timestamp < endTime);
92
+ // No data available, wait for it
93
+ return new Promise((resolve) => {
94
+ this.waitingReaders.add({ resolve, readerId });
428
95
  });
429
96
  }
430
97
 
431
98
  /**
432
- * Get the most recent N entries
433
- * @param {number} count - Number of entries to retrieve
434
- * @returns {RingBufferEntry[]} Most recent entries (oldest → newest)
435
- */
436
- tail(count) {
437
- return this.entries.slice(-count);
438
- }
439
-
440
- /**
441
- * Get the oldest N entries
442
- * @param {number} count - Number of entries to retrieve
443
- * @returns {RingBufferEntry[]} Oldest entries
444
- */
445
- head(count) {
446
- return this.entries.slice(0, count);
447
- }
448
-
449
- /**
450
- * Find entries matching a predicate
451
- * @param {Function} predicate - Function to test entries (entry) => boolean
452
- * @returns {RingBufferEntry[]} Matching entries
453
- */
454
- filter(predicate) {
455
- return this.entries.filter(predicate);
456
- }
457
-
458
- /**
459
- * Find first entry matching predicate
460
- * @param {Function} predicate - Function to test entries
461
- * @returns {RingBufferEntry|undefined} First matching entry
462
- */
463
- find(predicate) {
464
- return this.entries.find(predicate);
465
- }
466
-
467
- /**
468
- * Transform entries using a mapping function
469
- * @param {Function} mapper - Function to transform entries (entry) => any
470
- * @returns {any[]} Transformed results
471
- */
472
- map(mapper) {
473
- return this.entries.map(mapper);
474
- }
475
-
476
- /**
477
- * Reduce entries to a single value
478
- * @param {Function} reducer - Reducer function (acc, entry, index) => any
479
- * @param {any} initialValue - Initial accumulator value
480
- * @returns {any} Reduced value
481
- */
482
- reduce(reducer, initialValue) {
483
- return this.entries.reduce(reducer, initialValue);
484
- }
485
-
486
- /**
487
- * Create or update a cursor for tracking position
488
- * @param {string} name - Cursor name
489
- * @param {number} [position] - Position to set (defaults to current newest)
490
- * @returns {RingBufferCursor} The cursor
491
- */
492
- setCursor(name, position) {
493
- const currentPosition =
494
- position !== undefined
495
- ? position
496
- : this.entries.length > 0
497
- ? this.entries[this.entries.length - 1].id
498
- : 0;
499
-
500
- const cursor = {
501
- name,
502
- position: currentPosition,
503
- lastMoved: new Date(),
504
- meta: new Map(),
505
- };
506
-
507
- this.cursors.set(name, cursor);
508
- return cursor;
509
- }
510
-
511
- /**
512
- * Get cursor by name
513
- * @param {string} name - Cursor name
514
- * @returns {RingBufferCursor|undefined} The cursor
99
+ * Read batch of data (blocks until batch is full).
100
+ * @param {string} readerId
101
+ * @param {number} batchSize
102
+ * @returns {Promise<{data: any[], startOffset: number, lastOffset: number}>}
515
103
  */
516
- getCursor(name) {
517
- return this.cursors.get(name);
518
- }
519
-
520
- /**
521
- * Move cursor to new position
522
- * @param {string} name - Cursor name
523
- * @param {number} position - New position
524
- * @returns {RingBufferCursor|undefined} Updated cursor
525
- */
526
- moveCursor(name, position) {
527
- const cursor = this.cursors.get(name);
528
- if (cursor) {
529
- cursor.position = position;
530
- cursor.lastMoved = new Date();
531
- return cursor;
532
- }
533
- return undefined;
534
- }
535
-
536
- /**
537
- * Get entries since cursor position
538
- * @param {string} cursorName - Cursor name
539
- * @param {boolean} [moveCursor=true] - Whether to move cursor to end
540
- * @returns {RingBufferEntry[]} Entries since cursor
541
- */
542
- getSinceCursor(cursorName, moveCursor = true) {
543
- const cursor = this.cursors.get(cursorName);
544
- if (!cursor) {
545
- return [];
104
+ readBatch(readerId, batchSize) {
105
+ const lastSeq = this.readers.get(readerId);
106
+ if (lastSeq === undefined) {
107
+ throw new Error(`Reader ${readerId} not registered`);
546
108
  }
547
109
 
548
- const entries = this.entries.filter((entry) => entry.id > cursor.position);
110
+ const availableCount = this.sequence - (lastSeq + 1);
549
111
 
550
- if (moveCursor && entries.length > 0) {
551
- cursor.position = entries[entries.length - 1].id;
552
- cursor.lastMoved = new Date();
112
+ if (availableCount >= batchSize) {
113
+ // Batch is ready
114
+ const result = this._readBatchSync(readerId, batchSize);
115
+ return Promise.resolve(result);
553
116
  }
554
117
 
555
- return entries;
118
+ // Wait for full batch
119
+ return new Promise((resolve) => {
120
+ this.waitingReaders.add({ resolve, readerId, batchSize });
121
+ });
556
122
  }
557
123
 
558
124
  /**
559
- * Process entries in batches with a callback
560
- * @param {number} batchSize - Size of each batch
561
- * @param {Function} processor - Function to process each batch (batch, batchIndex) => Promise|any
562
- * @param {Object} [options] - Processing options
563
- * @param {number} [options.startIndex=0] - Index to start from
564
- * @param {number} [options.endIndex] - Index to end at
565
- * @param {boolean} [options.parallel=false] - Process batches in parallel
566
- * @returns {Promise<any[]>} Results from each batch
125
+ * Get data at specific sequence (uses double buffer for wraparound-free access).
126
+ * @private
567
127
  */
568
- async processBatches(batchSize, processor, options = {}) {
569
- const { startIndex = 0, endIndex = this.entries.length, parallel = false } = options;
570
- const results = [];
571
- const batches = [];
572
-
573
- // Create batches
574
- for (let i = startIndex; i < endIndex; i += batchSize) {
575
- const batch = this.entries.slice(i, Math.min(i + batchSize, endIndex));
576
- batches.push({ batch, batchIndex: Math.floor(i / batchSize) });
577
- }
578
-
579
- if (parallel) {
580
- // Process all batches in parallel
581
- const promises = batches.map(({ batch, batchIndex }) => processor(batch, batchIndex));
582
- results.push(...(await Promise.all(promises)));
583
- } else {
584
- // Process batches sequentially
585
- for (const { batch, batchIndex } of batches) {
586
- const result = await processor(batch, batchIndex);
587
- results.push(result);
588
- }
589
- }
590
-
591
- return results;
128
+ _getDataAtSequence(seq) {
129
+ const bufferPos = seq % this.maxSize;
130
+ return this.buffer[bufferPos];
592
131
  }
593
132
 
594
133
  /**
595
- * Create stable batch definitions based on entry IDs (not indices)
596
- * These batches remain consistent even if the ring buffer window changes
597
- * @param {number} batchSize - Size of each batch
598
- * @param {Object} [options] - Batch creation options
599
- * @param {number} [options.startId] - Starting entry ID (defaults to oldest)
600
- * @param {number} [options.endId] - Ending entry ID (defaults to newest)
601
- * @param {string} [options.batchIdPrefix='batch'] - Prefix for batch IDs
602
- * @returns {StableBatch[]} Array of stable batch definitions
134
+ * Read batch synchronously when data is available.
135
+ * @private
603
136
  */
604
- createStableBatches(batchSize, options = {}) {
605
- const {
606
- startId = this.entries.length > 0 ? this.entries[0].id : 0,
607
- endId = this.entries.length > 0 ? this.entries[this.entries.length - 1].id + 1 : 0,
608
- batchIdPrefix = 'batch',
609
- } = options;
610
-
611
- const batches = [];
612
- let currentId = startId;
613
- let batchIndex = 0;
614
-
615
- while (currentId < endId) {
616
- const batchEndId = Math.min(currentId + batchSize, endId);
617
- batches.push({
618
- batchId: `${batchIdPrefix}_${batchIndex}`,
619
- batchIndex,
620
- startId: currentId,
621
- endId: batchEndId,
622
- size: batchEndId - currentId,
623
- timestamp: new Date(),
624
- });
137
+ _readBatchSync(readerId, batchSize) {
138
+ const lastSeq = this.readers.get(readerId);
139
+ const startSeq = lastSeq + 1;
140
+ const startPos = startSeq % this.maxSize;
625
141
 
626
- currentId = batchEndId;
627
- batchIndex++;
628
- }
629
-
630
- return batches;
631
- }
142
+ // Use double buffer to avoid wraparound logic
143
+ const batch = this.buffer.slice(startPos, startPos + batchSize);
632
144
 
633
- /**
634
- * Get entries for a stable batch definition
635
- * @param {StableBatch} batchDef - Stable batch definition
636
- * @returns {RingBufferEntry[]} Entries in the batch (may be empty if evicted)
637
- */
638
- getBatchEntries(batchDef) {
639
- return this.slice(batchDef.startId, batchDef.endId);
145
+ this.readers.set(readerId, lastSeq + batchSize);
146
+ return { data: batch, startOffset: startSeq, lastOffset: lastSeq + batchSize };
640
147
  }
641
148
 
642
149
  /**
643
- * Process stable batches with retry support and cursor tracking
644
- * @param {number} batchSize - Size of each batch
645
- * @param {Function} processor - Function to process each batch (entries, batchDef, context) => Promise|any
646
- * @param {Object} [options] - Processing options
647
- * @param {string} [options.cursorName] - Cursor name for tracking progress
648
- * @param {number} [options.startId] - Starting entry ID
649
- * @param {number} [options.endId] - Ending entry ID
650
- * @param {boolean} [options.parallel=false] - Process batches in parallel
651
- * @param {number} [options.maxRetries=3] - Maximum retry attempts per batch
652
- * @param {number} [options.retryDelay] - Function to calculate retry delay (attempt) => ms
653
- * @param {Function} [options.onBatchStart] - Callback when batch starts (batchDef) => void
654
- * @param {Function} [options.onBatchComplete] - Callback when batch completes (batchDef, result) => void
655
- * @param {Function} [options.onBatchError] - Callback when batch fails (batchDef, error, attempt) => void
656
- * @param {boolean} [options.skipMissingEntries=false] - Skip batches with no available entries
657
- * @returns {Promise<StableBatchResult[]>} Results from each batch
150
+ * Notify waiting readers when new data arrives.
151
+ * @private
658
152
  */
659
- async processStableBatches(batchSize, processor, options = {}) {
660
- const {
661
- startId = this.entries.length > 0 ? this.entries[0].id : 1,
662
- endId = this.nextId,
663
- cursorName,
664
- parallel = false,
665
- maxRetries = 0,
666
- retryDelay = 100,
667
- skipMissingEntries = false,
668
- batchIdPrefix = 'batch',
669
- } = options;
670
-
671
- // Create stable batch definitions
672
- const batches = this.createStableBatches(batchSize, {
673
- startId,
674
- endId,
675
- batchIdPrefix,
676
- });
677
-
678
- // Filter batches based on cursor position if provided
679
- let batchesToProcess = batches;
680
- if (cursorName) {
681
- let cursor = this.getCursor(cursorName);
682
- if (!cursor) {
683
- // Create cursor if it doesn't exist
684
- cursor = this.setCursor(cursorName, startId - 1);
685
- }
686
- batchesToProcess = batches.filter((batch) => batch.startId > cursor.position);
687
- }
688
-
689
- const processBatchWithRetry = async (batchDef) => {
690
- let attempts = 0;
691
- let lastError;
153
+ _notifyWaiters(newSeq) {
154
+ const toRemove = [];
692
155
 
693
- // maxRetries means: 1 initial attempt + maxRetries additional attempts
694
- const maxAttempts = maxRetries + 1;
156
+ for (const waiter of this.waitingReaders) {
157
+ const { resolve, readerId, batchSize } = waiter;
158
+ const lastSeq = this.readers.get(readerId);
695
159
 
696
- while (attempts < maxAttempts) {
697
- attempts++;
698
- try {
699
- const entries = this.getBatchEntries(batchDef);
160
+ if (lastSeq === undefined) continue; // Reader was unregistered
700
161
 
701
- // Skip if no entries and skipMissingEntries is true
702
- if (entries.length === 0 && skipMissingEntries) {
703
- return {
704
- batchId: batchDef.batchId,
705
- batchDef,
706
- result: null,
707
- skipped: true,
708
- entriesProcessed: 0,
709
- attempts,
710
- };
711
- }
712
-
713
- const result = await processor(entries, batchDef, {
714
- head: this.nextId - 1,
715
- cursor: batchDef.endId - 1,
716
- entries: this.entries,
717
- });
718
-
719
- // Update cursor if provided
720
- if (cursorName) {
721
- this.moveCursor(cursorName, batchDef.endId - 1);
722
- }
723
-
724
- return {
725
- batchId: batchDef.batchId,
726
- batchDef,
727
- result,
728
- skipped: false,
729
- entriesProcessed: entries.length,
730
- attempts,
731
- };
732
- } catch (error) {
733
- lastError = error;
734
- // Only delay if we have more attempts left
735
- if (attempts < maxAttempts) {
736
- await new Promise((resolve) => setTimeout(resolve, retryDelay * attempts));
737
- }
162
+ if (batchSize) {
163
+ // Batch reader
164
+ const availableCount = newSeq - lastSeq;
165
+ if (availableCount >= batchSize) {
166
+ const result = this._readBatchSync(readerId, batchSize);
167
+ resolve(result);
168
+ toRemove.push(waiter);
169
+ }
170
+ } else {
171
+ // Single reader
172
+ if (newSeq > lastSeq) {
173
+ const nextSeq = lastSeq + 1;
174
+ const data = this._getDataAtSequence(nextSeq);
175
+ this.readers.set(readerId, nextSeq);
176
+ resolve({ data, offset: nextSeq });
177
+ toRemove.push(waiter);
738
178
  }
739
179
  }
740
-
741
- // If we get here, all retries failed
742
- return {
743
- batchId: batchDef.batchId,
744
- batchDef,
745
- result: null,
746
- skipped: false,
747
- entriesProcessed: 0,
748
- attempts,
749
- error: lastError,
750
- };
751
- };
752
-
753
- // Process batches
754
- if (parallel) {
755
- return await Promise.all(batchesToProcess.map(processBatchWithRetry));
756
- } else {
757
- const results = [];
758
- for (const batchDef of batchesToProcess) {
759
- results.push(await processBatchWithRetry(batchDef));
760
- }
761
- return results;
762
180
  }
763
- }
764
-
765
- /**
766
- * Create a batch cursor for iterating over stable batches
767
- * @param {string} cursorName - Name for the batch cursor
768
- * @param {number} batchSize - Size of each batch
769
- * @param {Object} [options] - Cursor options
770
- * @param {number} [options.startId] - Starting entry ID
771
- * @param {string} [options.batchIdPrefix] - Prefix for batch IDs
772
- * @returns {BatchCursor} Batch cursor for iteration
773
- */
774
- createBatchCursor(cursorName, batchSize, options = {}) {
775
- const { startId, batchIdPrefix = 'batch' } = options;
776
181
 
777
- // Set initial cursor position
778
- const initialPosition =
779
- startId !== undefined ? startId : this.entries.length > 0 ? this.entries[0].id : 0;
780
-
781
- this.setCursor(cursorName, initialPosition);
782
-
783
- return {
784
- cursorName,
785
- batchSize,
786
- batchIdPrefix,
787
-
788
- /**
789
- * Get the next batch of entries
790
- * @param {boolean} [moveCursor=true] - Whether to advance the cursor
791
- * @returns {BatchCursorResult} Next batch result
792
- */
793
- next: (moveCursor = true) => {
794
- const cursor = this.getCursor(cursorName);
795
- if (!cursor) {
796
- return { done: true, entries: [], batchDef: null };
797
- }
798
-
799
- const startId = cursor.position;
800
- const endId = startId + batchSize;
801
- const entries = this.slice(startId, endId);
802
-
803
- if (entries.length === 0) {
804
- return { done: true, entries: [], batchDef: null };
805
- }
806
-
807
- const batchDef = {
808
- batchId: `${batchIdPrefix}_${Math.floor(startId / batchSize)}`,
809
- startId,
810
- endId: startId + entries.length,
811
- size: entries.length,
812
- timestamp: new Date(),
813
- };
814
-
815
- if (moveCursor) {
816
- this.moveCursor(cursorName, batchDef.endId);
817
- }
818
-
819
- return {
820
- done: false,
821
- entries,
822
- batchDef,
823
- hasMore: this.entries.some((entry) => entry.id >= batchDef.endId),
824
- };
825
- },
826
-
827
- /**
828
- * Reset cursor to beginning or specific position
829
- * @param {number} [position] - Position to reset to
830
- */
831
- reset: (position) => {
832
- const resetPos =
833
- position !== undefined ? position : this.entries.length > 0 ? this.entries[0].id : 0;
834
- this.moveCursor(cursorName, resetPos);
835
- },
836
-
837
- /**
838
- * Get cursor status
839
- * @returns {Object} Cursor status information
840
- */
841
- getStatus: () => {
842
- const cursor = this.getCursor(cursorName);
843
- const stats = this.getStats();
844
- return {
845
- cursorName,
846
- position: cursor?.position || 0,
847
- lastMoved: cursor?.lastMoved,
848
- batchSize,
849
- remainingEntries: Math.max(0, stats.newestId - (cursor?.position || 0)),
850
- bufferSize: stats.size,
851
- };
852
- },
853
- };
182
+ // Remove resolved waiters
183
+ for (const waiter of toRemove) {
184
+ this.waitingReaders.delete(waiter);
185
+ }
854
186
  }
855
187
 
856
188
  /**
857
- * Create multiple synchronized batch cursors that iterate over the same batches
858
- * Useful for parallel processing where different workers need identical batch boundaries
859
- * @param {string[]} cursorNames - Names for the batch cursors
860
- * @param {number} batchSize - Size of each batch
861
- * @param {Object} [options] - Cursor options
862
- * @returns {Object} Map of cursor names to BatchCursor objects
189
+ * Get available data count for a reader.
190
+ * @param {string} readerId
191
+ * @returns {number}
863
192
  */
864
- createSynchronizedBatchCursors(cursorNames, batchSize, options = {}) {
865
- const cursors = {};
866
-
867
- // Create stable batch definitions first
868
- const batchDefs = this.createStableBatches(batchSize, options);
869
-
870
- // Create cursors with identical starting positions
871
- const startPosition =
872
- options.startId !== undefined
873
- ? options.startId
874
- : this.entries.length > 0
875
- ? this.entries[0].id
876
- : 0;
877
-
878
- for (const cursorName of cursorNames) {
879
- this.setCursor(cursorName, startPosition);
880
-
881
- cursors[cursorName] = {
882
- cursorName,
883
- batchSize,
884
- batchDefs,
885
- currentBatchIndex: 0,
886
-
887
- /**
888
- * Get the next batch using stable batch definitions
889
- * @param {boolean} [moveCursor=true] - Whether to advance the cursor
890
- * @returns {BatchCursorResult} Next batch result
891
- */
892
- next: (moveCursor = true) => {
893
- const cursor = this.getCursor(cursorName);
894
- if (!cursor || cursors[cursorName].currentBatchIndex >= batchDefs.length) {
895
- return { done: true, entries: [], batchDef: null };
896
- }
897
-
898
- const batchDef = batchDefs[cursors[cursorName].currentBatchIndex];
899
- const entries = this.getBatchEntries(batchDef);
900
-
901
- if (moveCursor) {
902
- this.moveCursor(cursorName, batchDef.endId - 1);
903
- cursors[cursorName].currentBatchIndex++;
904
- }
905
-
906
- return {
907
- done: false,
908
- entries,
909
- batchDef,
910
- hasMore: cursors[cursorName].currentBatchIndex < batchDefs.length - 1,
911
- };
912
- },
913
-
914
- /**
915
- * Reset cursor to beginning or specific batch
916
- * @param {number} [batchIndex=0] - Batch index to reset to
917
- */
918
- reset: (batchIndex = 0) => {
919
- cursors[cursorName].currentBatchIndex = Math.max(
920
- 0,
921
- Math.min(batchIndex, batchDefs.length - 1)
922
- );
923
- const batchDef = batchDefs[cursors[cursorName].currentBatchIndex];
924
- if (batchDef) {
925
- this.moveCursor(cursorName, batchDef.startId);
926
- }
927
- },
928
-
929
- /**
930
- * Get cursor status
931
- * @returns {Object} Cursor status information
932
- */
933
- getStatus: () => {
934
- const cursor = this.getCursor(cursorName);
935
- return {
936
- cursorName,
937
- position: cursor?.position || 0,
938
- lastMoved: cursor?.lastMoved,
939
- batchSize,
940
- currentBatchIndex: cursors[cursorName].currentBatchIndex,
941
- totalBatches: batchDefs.length,
942
- remainingBatches: batchDefs.length - cursors[cursorName].currentBatchIndex,
943
- };
944
- },
945
- };
946
- }
947
-
948
- return cursors;
193
+ getAvailableCount(readerId) {
194
+ const lastSeq = this.readers.get(readerId);
195
+ return lastSeq === undefined ? 0 : Math.max(0, this.sequence - (lastSeq + 1));
949
196
  }
950
197
 
951
198
  /**
952
- * Get buffer statistics
953
- * @returns {RingBufferStats} Current statistics
199
+ * Get buffer statistics.
200
+ * @returns {object}
954
201
  */
955
202
  getStats() {
956
- const size = this.entries.length;
957
- const oldest = size > 0 ? this.entries[0] : null;
958
- const newest = size > 0 ? this.entries[size - 1] : null;
959
-
960
203
  return {
961
- size,
962
- capacity: this.maxSize,
963
- totalAdded: this.totalAdded,
964
- totalEvicted: this.totalEvicted,
965
- oldestId: oldest?.id || 0,
966
- newestId: newest?.id || 0,
967
- oldestTimestamp: oldest?.timestamp || oldest?.ts || null,
968
- newestTimestamp: newest?.timestamp || newest?.ts || null,
969
- cursors: this.cursors.size,
970
- lanes: this.lanes.size,
204
+ maxSize: this.maxSize,
205
+ writeIndex: this.writeIndex,
206
+ sequence: this.sequence,
207
+ registeredReaders: this.readers.size,
208
+ waitingReaders: this.waitingReaders.size,
971
209
  };
972
210
  }
973
211
 
974
212
  /**
975
- * Clear all entries and reset counters
976
- * @param {boolean} [keepCursors=false] - Whether to keep cursor positions
977
- * @param {boolean} [keepLanes=false] - Whether to keep processing lanes
213
+ * Clear buffer and reset state.
978
214
  */
979
- clear(keepCursors = false, keepLanes = false) {
980
- this.entries = [];
981
- this.totalAdded = 0;
982
- this.totalEvicted = 0;
215
+ clear() {
216
+ this.writeIndex = 0;
217
+ this.sequence = 0;
218
+ this.buffer.fill(undefined);
983
219
 
984
- if (!keepCursors) {
985
- this.cursors.clear();
220
+ // Reset reader sequences
221
+ for (const readerId of this.readers.keys()) {
222
+ this.readers.set(readerId, -1);
986
223
  }
987
224
 
988
- if (!keepLanes) {
989
- this.lanes.clear();
990
- } else {
991
- // Clear lane buffers but keep lane configurations
992
- for (const lane of this.lanes.values()) {
993
- lane.buffer = [];
994
- lane.flushActive = false;
225
+ // Resolve all waiting readers with empty results
226
+ for (const waiter of this.waitingReaders) {
227
+ if (waiter.batchSize) {
228
+ waiter.resolve({ data: [], startOffset: 0, lastOffset: -1 });
229
+ } else {
230
+ waiter.resolve({ data: null, offset: -1 });
995
231
  }
996
232
  }
997
- }
998
-
999
- /**
1000
- * Remove cursor
1001
- * @param {string} name - Cursor name to remove
1002
- * @returns {boolean} True if cursor was removed
1003
- */
1004
- removeCursor(name) {
1005
- return this.cursors.delete(name);
1006
- }
1007
-
1008
- /**
1009
- * Get all cursor names
1010
- * @returns {string[]} Array of cursor names
1011
- */
1012
- getCursorNames() {
1013
- return Array.from(this.cursors.keys());
1014
- }
1015
-
1016
- /**
1017
- * Check if buffer is full
1018
- * @returns {boolean} True if at capacity
1019
- */
1020
- isFull() {
1021
- return this.entries.length >= this.maxSize;
1022
- }
1023
-
1024
- /**
1025
- * Check if buffer is empty
1026
- * @returns {boolean} True if empty
1027
- */
1028
- isEmpty() {
1029
- return this.entries.length === 0;
1030
- }
1031
-
1032
- /**
1033
- * Get current size
1034
- * @returns {number} Number of entries
1035
- */
1036
- size() {
1037
- return this.entries.length;
1038
- }
1039
-
1040
- /**
1041
- * Get capacity
1042
- * @returns {number} Maximum capacity
1043
- */
1044
- capacity() {
1045
- return this.maxSize;
1046
- }
1047
-
1048
- /**
1049
- * Iterator support - allows for...of loops
1050
- * @returns {Iterator<RingBufferEntry>} Iterator over entries
1051
- */
1052
- *[Symbol.iterator]() {
1053
- for (const entry of this.entries) {
1054
- yield entry;
1055
- }
1056
- }
1057
-
1058
- /**
1059
- * Create a new ring buffer from this one with filtered entries
1060
- * @param {Function} predicate - Filter function
1061
- * @param {number} [maxSize] - Size of new buffer (defaults to same)
1062
- * @returns {RingBuffer} New filtered ring buffer
1063
- */
1064
- createFiltered(predicate, maxSize = this.maxSize) {
1065
- const newBuffer = new RingBuffer(maxSize);
1066
- const filtered = this.entries.filter(predicate);
1067
-
1068
- for (const entry of filtered) {
1069
- newBuffer.push(entry.data, entry.meta);
1070
- }
1071
-
1072
- return newBuffer;
233
+ this.waitingReaders.clear();
1073
234
  }
1074
235
  }