Skip to content

@studnicky/circular-buffer ​

Generic ring buffer with O(1) push and shift operations.

What it is ​

@studnicky/circular-buffer is a composable fixed-capacity sequence primitive. It keeps insertion and removal predictable and offers a numeric sliding-window specialization; it does not model catalogue, inventory, or order workflows.

What it is for ​

Use it when Northstar Books needs a bounded recent-event queue, such as the latest catalogue edits, or a numeric window for local operational measurements. The application chooses the events, capacity, overflow policy, and interpretation of the retained values.

Northstar Books examples ​

  • Observed numeric sample buffer maps recent book-search latency samples to a bounded numeric window. It proves that Northstar can calculate percentiles from a fixed local sample without retaining an unbounded stream.
  • Observed ring buffer — lifecycle hook trace maps a rolling queue of recent catalogue changes to explicit overflow, eviction, insertion, removal, and growth events. It proves the application can observe capacity behavior while retaining domain ownership outside the buffer.

Install ​

bash
pnpm add @studnicky/circular-buffer

Usage ​

Northstar wants to keep the three most recent catalogue edits on hand without that list growing forever — a fixed-capacity ring is exactly the shape for that. Push a fourth item onto a capacity-3 CircularBuffer and the oldest one — the first pushed — gets evicted automatically, so length never exceeds capacity. The example below pushes four numbers into a ring that only holds three, then shifts everything back out to confirm the survivors are the three most recent, in the order they arrived.

ts
import { CircularBuffer } from '../src/index.js';

// Fixed-capacity ring: capacity 3, overflow defaults to 'overwrite'
const buffer = CircularBuffer.create<number>({ 'capacity': 3 });

buffer.push(1);
buffer.push(2);
buffer.push(3);
buffer.push(4); // buffer is full — 1 is evicted, ring holds [2, 3, 4]

console.log(`length after 4 pushes into capacity-3 ring: ${buffer.length}`);
console.log(`shift: ${buffer.shift()}`); // 2 — oldest surviving item
console.log(`shift: ${buffer.shift()}`); // 3
console.log(`shift: ${buffer.shift()}`); // 4
console.log(`length after drain: ${buffer.length}`);

Numeric samples ​

Measuring book-search latency means watching a rolling window of recent timings and asking questions like "what's the p95?" — without holding on to every sample ever recorded. SampleBuffer is a fixed-capacity numeric sliding window with its own sorted-cache percentile calculation, built on a separate seven-hook protocol rather than inheriting the generic ring-buffer's hooks; import it from its own canonical subpath. The example below fills a five-sample buffer, reads back its median and 95th percentile, pushes two more values to evict the oldest two, and clears it.

ts
import { SampleBuffer } from '../src/samples/index.js';

const buffer = SampleBuffer.create({ 'capacity': 5 });

// Fill to capacity
buffer.push(10);
buffer.push(20);
buffer.push(30);
buffer.push(40);
buffer.push(50);

console.log('length:', buffer.length); // 5
console.log('isFull:', buffer.isFull); // true

// Percentile on a full buffer
const median = buffer.percentile(50);
const p95 = buffer.percentile(95);

console.log('p50:', median);
console.log('p95:', p95);

// Push beyond capacity — oldest samples are evicted
buffer.push(60); // evicts 10
buffer.push(70); // evicts 20

console.log('length after overflow:', buffer.length); // still 5

// Clear
buffer.clear();
console.log('length after clear:', buffer.length); // 0
Loading example…

Try it ​

Lifecycle hooks ​

TracingBuffer subclasses CircularBuffer and overrides all five lifecycle hooks — onOverflow, onEvict, onPush, onShift, and onGrow — to print every capacity decision as it happens. Two scenarios run side by side: an overwrite-mode ring at capacity 3 takes five pushes and visibly overflows and evicts twice, while a grow-mode ring at capacity 2 takes three pushes and doubles its own capacity to 4 instead of ever evicting anything.

Loading example…

Public API ​

Import CircularBuffer and CircularBufferError from @studnicky/circular-buffer/node; import CircularBufferOptionsEntity and CircularBufferStateEntity from @studnicky/circular-buffer/entities; and import CircularBufferInterface from @studnicky/circular-buffer/interfaces. Construct a ring through CircularBuffer.create({ capacity, overflow }). CircularBufferInterface.length composes the schema-derived field owned by CircularBufferStateEntity. Runtime operations use the /node entrypoint; schemas and contracts remain at /entities and /interfaces. Storage constants are implementation details.

Extending ​

CircularBuffer is an ordinary class, so adding domain-specific behavior is just a subclass away. The example below builds two: an EvictTracker that overrides onEvict to record exactly which item gets dropped when a capacity-2 overwrite ring takes a third push, and a GrowTracker that overrides onGrow to catch the moment a capacity-2 grow-mode ring doubles itself to 4 instead of evicting anything.

ts
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, EvictTracker<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, GrowTracker<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 ​

Every meaningful change to a CircularBuffer — an overflow, an eviction, a push, a shift, a grow — has its own protected hook ready to override, so Northstar can watch what's happening without wiring the buffer itself to any particular logger or metrics library.

HookWhen it firesArgs
onOverflow(item)Push onto a full buffer in overwrite mode, before the oldest item is evicteditem: T — the incoming item
onEvict(item)Push onto a full buffer in overwrite mode, after overflow is detected, before the slot is overwrittenitem: 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
ts
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, TracingBuffer<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, TracingBuffer<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.

Source on GitHub

Entities ​

@studnicky/circular-buffer/entities exports every schema namespace in src/entities.

typescript
import { CircularBufferOptionsEntity } from "@studnicky/circular-buffer/entities";

Interfaces ​

@studnicky/circular-buffer/interfaces exports every TypeScript interface in src/interfaces, including configuration and state contracts.

typescript
import type { CircularBufferInterface } from "@studnicky/circular-buffer/interfaces";

Public entrypoints ​

Import pathUse it when
@studnicky/circular-buffer/nodeA Northstar Books server or worker needs generic ring-buffer runtime operations and errors.
@studnicky/circular-buffer/browserA Northstar Books browser bundle needs the same generic ring-buffer primitive; it is a runtime alternative to /node, not a separate queue product.
@studnicky/circular-buffer/samplesA consumer needs the numeric sliding-window and percentile primitive for measurements such as search latency.
@studnicky/circular-buffer/entitiesA consumer needs buffer and sample configuration or state schemas as contracts at its boundary.
@studnicky/circular-buffer/interfacesA consumer needs the typed buffer or sample contracts while composing its own workflow.

Exports ​

SymbolPurposeImport path
CircularBufferProvides circular buffer functionality.@studnicky/circular-buffer/node
CircularBufferErrorRepresents circular buffer failures.@studnicky/circular-buffer/node
CircularBufferOptionsEntityDefines circular buffer configuration.@studnicky/circular-buffer/entities
CircularBufferStateEntityDefines circular buffer state.@studnicky/circular-buffer/entities
CircularBufferInterfaceDefines the circular buffer contract.@studnicky/circular-buffer/interfaces
SampleBufferProvides fixed-capacity numeric samples and percentile calculation.@studnicky/circular-buffer/samples
SampleBufferErrorRepresents sample-buffer construction failures.@studnicky/circular-buffer/samples
SampleBufferOptionsEntityDefines sample-buffer construction input.@studnicky/circular-buffer/entities
SampleBufferStateEntityDefines sample-buffer observable state.@studnicky/circular-buffer/entities
SampleBufferInterfaceDefines the sample-buffer contract.@studnicky/circular-buffer/interfaces