@studnicky/circular-buffer
Generic ring buffer with O(1) push and shift operations.
Install
pnpm add @studnicky/circular-bufferUsage
Fixed-capacity ring buffer. When the buffer is full, the oldest item is evicted and the new item takes its slot. Length stays at capacity:
import { CircularBuffer } from '../src/index.js';
// Fixed-capacity ring: capacity 3, overflow defaults to 'overwrite'
const buf = CircularBuffer.create<number>({ 'capacity': 3 });
buf.push(1);
buf.push(2);
buf.push(3);
buf.push(4); // buffer is full — 1 is evicted, ring holds [2, 3, 4]
console.log(`length after 4 pushes into capacity-3 ring: ${buf.length}`);
console.log(`shift: ${buf.shift()}`); // 2 — oldest surviving item
console.log(`shift: ${buf.shift()}`); // 3
console.log(`shift: ${buf.shift()}`); // 4
console.log(`length after drain: ${buf.length}`);Try it
Builder
CircularBuffer.builder().withCapacity(n).withOverflow('overwrite' | 'grow').build() constructs the ring through the fluent builder. Press Execute to see both overflow strategies: an overwrite-mode capacity-3 ring evicts the oldest item on overflow (drains to [2, 3, 4]), while a grow-mode capacity-2 ring doubles to capacity 4 instead of dropping anything.
/** builder-circular-buffer — construct a CircularBuffer via the fluent builder API. Run: npx tsx packages/circular-buffer/examples/builder-circular-buffer.ts */
import assert from 'node:assert/strict';
import { CircularBuffer } from '../src/index.js';
// #region usage
class BuilderCircularBufferExample {
static run(): void {
// Build a capacity-3 ring in overwrite mode.
// On overflow, the oldest item is evicted.
const overwriteRing = CircularBuffer.builder<number>()
.withCapacity(3)
.withOverflow('overwrite')
.build();
console.log('overwrite ring — initial length:', overwriteRing.length);
overwriteRing.push(1);
overwriteRing.push(2);
overwriteRing.push(3);
console.log('overwrite ring — after push(1,2,3), length:', overwriteRing.length);
overwriteRing.push(4); // evicts 1
console.log('overwrite ring — after push(4) (evicts 1), length:', overwriteRing.length);
const overwriteDrained: number[] = [];
let overwriteItem = overwriteRing.shift();
while (overwriteItem !== undefined) {
overwriteDrained.push(overwriteItem);
overwriteItem = overwriteRing.shift();
}
console.log('overwrite ring — drained:', overwriteDrained);
// Build a capacity-2 ring in grow mode.
// On overflow, capacity doubles instead of evicting items.
const growRing = CircularBuffer.builder<number>()
.withCapacity(2)
.withOverflow('grow')
.build();
console.log('grow ring — initial length:', growRing.length);
growRing.push(10);
growRing.push(20);
growRing.push(30); // triggers grow: capacity doubles to 4, nothing evicted
console.log('grow ring — after push(10,20,30), length:', growRing.length);
const growDrained: number[] = [];
let growItem = growRing.shift();
while (growItem !== undefined) {
growDrained.push(growItem);
growItem = growRing.shift();
}
console.log('grow ring — drained:', growDrained);
assert.deepEqual(overwriteDrained, [2, 3, 4]);
assert.equal(growDrained.length, 3);
assert.deepEqual(growDrained, [10, 20, 30]);
console.log('builder-circular-buffer: all assertions passed');
}
}
BuilderCircularBufferExample.run();
// #endregion usage
Lifecycle hooks
TracingBuffer subclasses CircularBuffer and overrides five hooks: onOverflow, onEvict, onPush, onShift, and onGrow. Two scenarios run: an overwrite-mode ring (capacity 3, 5 pushes — watch 2 overflow and 2 eviction events) and a grow-mode ring (capacity 2, 3 pushes — watch the buffer double to capacity 4 instead of evicting).
/** observedCircularBuffer — trace all lifecycle hooks via console.log. Run: npx tsx examples/observedCircularBuffer.ts */
import assert from 'node:assert/strict';
// #region usage
import { CircularBuffer } from '../src/index.js';
class TracingBuffer<T> extends CircularBuffer<T> {
readonly overflowItems: T[] = [];
readonly evictItems: T[] = [];
readonly growEvents: { 'newCapacity': number; 'oldCapacity': number }[] = [];
readonly pushItems: T[] = [];
readonly shiftItems: T[] = [];
protected override onOverflow(item: T): void {
console.log(`[circular-buffer] overflow incoming=${String(item)}`);
this.overflowItems.push(item);
}
protected override onEvict(item: T): void {
console.log(`[circular-buffer] evict item=${String(item)}`);
this.evictItems.push(item);
}
protected override onGrow(oldCapacity: number, newCapacity: number): void {
console.log(`[circular-buffer] grow ${oldCapacity} → ${newCapacity}`);
this.growEvents.push({ 'newCapacity': newCapacity, 'oldCapacity': oldCapacity });
}
protected override onPush(item: T): void {
console.log(`[circular-buffer] push item=${String(item)} length=${this.length}`);
this.pushItems.push(item);
}
protected override onShift(item: T): void {
console.log(`[circular-buffer] shift item=${String(item)} length=${this.length}`);
this.shiftItems.push(item);
}
}
// Scenario 1: capacity-3 overwrite buffer, push 5 items (2 overflows/evictions)
console.log('--- overwrite mode (capacity 3) ---');
const ring = TracingBuffer.create<number>({ 'capacity': 3 });
ring.push(1);
ring.push(2);
ring.push(3);
ring.push(4); // overflow: evicts 1
ring.push(5); // overflow: evicts 2
console.log('shifting all remaining items:');
while (ring.length > 0) { ring.shift(); }
// Scenario 2: capacity-2 grow-mode buffer
console.log('--- grow mode (capacity 2) ---');
const growing = TracingBuffer.create<number>({ 'capacity': 2, 'overflow': 'grow' });
growing.push(10);
growing.push(20);
growing.push(30); // triggers grow 2 → 4
console.log('observedCircularBuffer: scenarios complete');
// #endregion usage
// Assertions (outside the #region — these verify hook counts, not demo output)
// overwrite ring: 5 pushes total, 2 overflows, 2 evictions, 3 shifts
assert.strictEqual(ring.pushItems.length, 5);
assert.strictEqual(ring.overflowItems.length, 2);
assert.deepStrictEqual(ring.overflowItems, [4, 5]);
assert.strictEqual(ring.evictItems.length, 2);
assert.deepStrictEqual(ring.evictItems, [1, 2]);
assert.strictEqual(ring.shiftItems.length, 3);
assert.strictEqual(ring.growEvents.length, 0);
// grow buffer: 3 pushes, 1 grow event, no overflow, no evictions
assert.strictEqual(growing.pushItems.length, 3);
assert.strictEqual(growing.growEvents.length, 1);
assert.strictEqual(growing.growEvents[0]?.oldCapacity, 2);
assert.strictEqual(growing.growEvents[0]?.newCapacity, 4);
assert.strictEqual(growing.overflowItems.length, 0);
assert.strictEqual(growing.evictItems.length, 0);
console.log('observedCircularBuffer: all assertions passed');
Subpath exports
| Subpath | Contents |
|---|---|
@studnicky/circular-buffer | CircularBuffer, CircularBufferBuilder, CircularBufferOptionsEntity |
@studnicky/circular-buffer/circular-buffer | CircularBuffer, CircularBufferBuilder (direct subpath) |
@studnicky/circular-buffer/interfaces | CircularBufferInterface |
@studnicky/circular-buffer/constants | Buffer configuration constants |
Extending
CircularBuffer is a class; subclass it to add domain-specific behavior. Override the protected hooks onEvict, onGrow, onPush, and onShift to observe lifecycle events without coupling business logic to the buffer internals:
import { CircularBuffer } from '../src/index.js';
// Default overwrite ring — observe evictions
class EvictTracker<T> extends CircularBuffer<T> {
readonly evictedItems: T[] = [];
protected override onEvict(item: T): void {
this.evictedItems.push(item);
}
}
const ring = EvictTracker.create<string>({ 'capacity': 2 });
ring.push('a');
ring.push('b');
ring.push('c'); // 'a' is evicted
console.log(`evicted items: ${ring.evictedItems.join(', ')}`);
console.log(`ring length: ${ring.length}`);
// Opt-in grow mode — observe capacity changes
class GrowTracker<T> extends CircularBuffer<T> {
readonly growEvents: number[] = [];
protected override onGrow(_old: number, newCap: number): void {
this.growEvents.push(newCap);
}
}
const growing = GrowTracker.create<string>({ 'capacity': 2, 'overflow': 'grow' });
growing.push('a');
growing.push('b');
growing.push('c'); // triggers grow: capacity 2 → 4
console.log(`grow events: ${growing.growEvents.length}`);
console.log(`new capacity after grow: ${growing.growEvents[0]}`);Observability hooks
Override any protected hook to observe lifecycle events without coupling to a logger or metrics library.
| Hook | When it fires | Args |
|---|---|---|
onOverflow(item) | Push onto a full buffer in overwrite mode, before the oldest item is evicted | item: T — the incoming item |
onEvict(item) | Push onto a full buffer in overwrite mode, after overflow is detected, before the slot is overwritten | item: T — the item being dropped |
onPush(item) | End of push(), after the item is inserted and length updated (fires in both modes) | item: T — the item pushed |
onShift(item) | Inside shift(), before returning the item (not called on empty buffer) | item: T — the item being removed |
onGrow(oldCapacity, newCapacity) | End of grow(), after the buffer has been resized (grow mode only) | oldCapacity: number, newCapacity: number |
import { CircularBuffer } from '../src/index.js';
class TracingBuffer<T> extends CircularBuffer<T> {
readonly overflowItems: T[] = [];
readonly evictItems: T[] = [];
readonly growEvents: { 'newCapacity': number; 'oldCapacity': number }[] = [];
readonly pushItems: T[] = [];
readonly shiftItems: T[] = [];
protected override onOverflow(item: T): void {
console.log(`[circular-buffer] overflow incoming=${String(item)}`);
this.overflowItems.push(item);
}
protected override onEvict(item: T): void {
console.log(`[circular-buffer] evict item=${String(item)}`);
this.evictItems.push(item);
}
protected override onGrow(oldCapacity: number, newCapacity: number): void {
console.log(`[circular-buffer] grow ${oldCapacity} → ${newCapacity}`);
this.growEvents.push({ 'newCapacity': newCapacity, 'oldCapacity': oldCapacity });
}
protected override onPush(item: T): void {
console.log(`[circular-buffer] push item=${String(item)} length=${this.length}`);
this.pushItems.push(item);
}
protected override onShift(item: T): void {
console.log(`[circular-buffer] shift item=${String(item)} length=${this.length}`);
this.shiftItems.push(item);
}
}
// Scenario 1: capacity-3 overwrite buffer, push 5 items (2 overflows/evictions)
console.log('--- overwrite mode (capacity 3) ---');
const ring = TracingBuffer.create<number>({ 'capacity': 3 });
ring.push(1);
ring.push(2);
ring.push(3);
ring.push(4); // overflow: evicts 1
ring.push(5); // overflow: evicts 2
console.log('shifting all remaining items:');
while (ring.length > 0) { ring.shift(); }
// Scenario 2: capacity-2 grow-mode buffer
console.log('--- grow mode (capacity 2) ---');
const growing = TracingBuffer.create<number>({ 'capacity': 2, 'overflow': 'grow' });
growing.push(10);
growing.push(20);
growing.push(30); // triggers grow 2 → 4
console.log('observedCircularBuffer: scenarios complete');The base class never calls any logger or metrics library. All hooks are no-ops by default.