@studnicky/sample-buffer
Fixed-capacity circular buffer for numeric samples with percentile calculation.
Install
pnpm add @studnicky/sample-bufferUsage
Create a SampleBuffer with a fixed capacity, push numeric samples into it, and read back percentiles. When full, the oldest sample is evicted to make room for each new one:
import { SampleBuffer } from '../src/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); // 0Try it
Builder
SampleBuffer.builder().withCapacity(5).build() constructs the buffer through the fluent builder. Press Execute to fill the capacity-5 buffer, compute p50 and p95, then push two more samples past capacity (oldest two are evicted, length holds at 5) and clear.
/** builder-sample-buffer — construct a SampleBuffer via the fluent builder API. Run: npx tsx packages/sample-buffer/examples/builder-sample-buffer.ts */
import assert from 'node:assert/strict';
import { SampleBuffer } from '../src/index.js';
// #region usage
// Build a SampleBuffer with capacity 5 via the fluent builder.
const buffer = SampleBuffer.builder().withCapacity(5).build();
console.log('builder construction: SampleBuffer.builder().withCapacity(5).build()');
// Push 5 samples to fill the buffer.
buffer.push(10);
buffer.push(20);
buffer.push(30);
buffer.push(40);
buffer.push(50);
console.log('length after 5 pushes:', buffer.length);
console.log('isFull after 5 pushes:', buffer.isFull);
console.log('percentile(50):', buffer.percentile(50));
console.log('percentile(95):', buffer.percentile(95));
// Push 2 more samples past capacity — oldest two (10, 20) are evicted; length stays 5.
buffer.push(60);
buffer.push(70);
console.log('length after 2 overflow pushes:', buffer.length);
// Clear the buffer — length resets to 0.
buffer.clear();
console.log('length after clear():', buffer.length);
// #endregion usage
assert.equal(buffer.length, 0);
// Re-build to test the post-fill assertions cleanly.
const buf2 = SampleBuffer.builder().withCapacity(5).build();
buf2.push(10);
buf2.push(20);
buf2.push(30);
buf2.push(40);
buf2.push(50);
assert.equal(buf2.length, 5);
assert.equal(buf2.isFull, true);
const p50 = buf2.percentile(50);
assert.notEqual(p50, undefined);
assert(p50 !== undefined && p50 >= 10 && p50 <= 50);
const p95 = buf2.percentile(95);
assert.notEqual(p95, undefined);
buf2.push(60);
buf2.push(70);
assert.equal(buf2.length, 5);
buf2.clear();
assert.equal(buf2.length, 0);
console.log('builder-sample-buffer: all assertions passed');
Lifecycle hooks
TracedSampleBuffer subclasses SampleBuffer and overrides seven hooks: onOverflow, onEvict, onPush, onComputeStart, onComputeComplete, onPercentile, and onClear. With capacity=3 and 5 pushes, watch two overflow+eviction pairs fire. The first percentile(50) triggers computeStart and computeComplete; the second call is a cache hit so those hooks do not fire again.
/** observedSampleBuffer — trace all lifecycle hooks while filling a buffer past capacity and computing a percentile. Run: npx tsx examples/observedSampleBuffer.ts */
import assert from 'node:assert/strict';
// #region usage
import { SampleBuffer } from '../src/index.js';
class TracedSampleBuffer extends SampleBuffer {
readonly overflowLog: number[] = [];
readonly evictLog: number[] = [];
readonly pushLog: { 'evicted': boolean; 'value': number }[] = [];
readonly computeStartLog: number[] = [];
readonly computeCompleteLog: { 'length': number; 'sorted': readonly number[] }[] = [];
readonly percentileLog: { 'pct': number; 'result': number }[] = [];
clearCount = 0;
protected override onOverflow(value: number): void {
console.log(`[sample-buffer] overflow value=${String(value)} capacity=${String(this.capacity)}`);
this.overflowLog.push(value);
}
protected override onEvict(oldValue: number): void {
console.log(`[sample-buffer] evict oldValue=${String(oldValue)}`);
this.evictLog.push(oldValue);
}
protected override onPush(value: number, evicted: boolean): void {
console.log(`[sample-buffer] push value=${String(value)} evicted=${String(evicted)} length=${String(this.length)}`);
this.pushLog.push({ 'evicted': evicted, 'value': value });
}
protected override onComputeStart(length: number): void {
console.log(`[sample-buffer] computeStart length=${String(length)}`);
this.computeStartLog.push(length);
}
protected override onComputeComplete(length: number, sorted: readonly number[]): void {
console.log(`[sample-buffer] computeComplete length=${String(length)} sorted=[${sorted.join(',')}]`);
this.computeCompleteLog.push({ 'length': length, 'sorted': sorted });
}
protected override onPercentile(pct: number, result: number): void {
console.log(`[sample-buffer] percentile pct=${String(pct)} result=${String(result)}`);
this.percentileLog.push({ 'pct': pct, 'result': result });
}
protected override onClear(): void {
console.log(`[sample-buffer] clear length=${String(this.length)}`);
this.clearCount++;
}
}
const buf = TracedSampleBuffer.create({ 'capacity': 3 });
// Fill the buffer (3 pushes, no overflow)
buf.push(10);
buf.push(20);
buf.push(30);
// Push past capacity — triggers overflow + eviction
buf.push(40); // evicts 10
buf.push(50); // evicts 20
// Compute a percentile (triggers computeStart + computeComplete + percentile hook)
const p50 = buf.percentile(50);
// Second call uses cache — no computeStart/computeComplete
const p50Cached = buf.percentile(50);
// Clear
buf.clear();
// #endregion usage
// Assertions
assert.ok(p50 !== undefined, 'p50 should be defined');
assert.ok(p50Cached !== undefined, 'p50Cached should be defined');
assert.equal(p50, p50Cached, 'cached result should match');
// 5 pushes total
assert.equal(buf.pushLog.length, 5, 'push hook fired 5 times');
// 2 overflows (pushes 4 and 5 onto a full buffer)
assert.equal(buf.overflowLog.length, 2, 'overflow hook fired 2 times');
assert.equal(buf.overflowLog[0], 40, 'first overflow value is 40');
assert.equal(buf.overflowLog[1], 50, 'second overflow value is 50');
// 2 evictions match 2 overflows
assert.equal(buf.evictLog.length, 2, 'evict hook fired 2 times');
assert.equal(buf.evictLog[0], 10, 'first eviction was 10');
assert.equal(buf.evictLog[1], 20, 'second eviction was 20');
// computeStart fires once (second percentile() is a cache hit)
assert.equal(buf.computeStartLog.length, 1, 'computeStart fires once per cache miss');
// computeComplete fires once
assert.equal(buf.computeCompleteLog.length, 1, 'computeComplete fires once per cache miss');
// percentile fires twice (both calls)
assert.equal(buf.percentileLog.length, 2, 'percentile hook fires on every non-empty call');
// clear fires once
assert.equal(buf.clearCount, 1, 'clear hook fires');
console.log('observedSampleBuffer: all assertions passed');
Subpath exports
| Subpath | Contents |
|---|---|
@studnicky/sample-buffer | SampleBuffer |
@studnicky/sample-buffer/sample-buffer | SampleBuffer (direct subpath) |
@studnicky/sample-buffer/interfaces | SampleBufferInterface |
@studnicky/sample-buffer/constants | Default capacity constants |
Extending
Subclass SampleBuffer and override any lifecycle hook to observe buffer events. All hooks are no-ops by default; only override what you need:
import { SampleBuffer } from '../src/index.js';
class EvictionLog extends SampleBuffer {
readonly evicted: number[] = [];
protected override onEvict(oldValue: number): void {
this.evicted.push(oldValue);
}
}
const log = EvictionLog.create({ 'capacity': 3 });
// First three fill the buffer — no evictions
log.push(1);
log.push(2);
log.push(3);
// Next two each trigger an eviction
log.push(4); // evicts 1
log.push(5); // evicts 2
console.log('evicted:', log.evicted); // [1, 2]Observability hooks
Subclass SampleBuffer and override any hook to observe the full push/evict/compute lifecycle. All hooks fire synchronously, after state mutation, with no try/catch.
| Hook | When it fires | Args |
|---|---|---|
onOverflow(value) | Push onto a full buffer, before eviction | value: number — the incoming sample |
onEvict(oldValue) | Full-buffer push path, after overflow, before overwrite | oldValue: number — the sample being replaced |
onPush(value, evicted) | End of push(), after length/head updated | value: number, evicted: boolean |
onComputeStart(length) | Start of buildSortedSamples() — cache miss in percentile() | length: number — samples about to be sorted |
onComputeComplete(length, sorted) | End of buildSortedSamples(), after sort | length: number, sorted: readonly number[] |
onPercentile(pct, result) | Before returning from percentile(), non-empty buffer only | pct: number, result: number |
onClear() | Start of clear(), before state reset | none |
import { SampleBuffer } from '../src/index.js';
class TracedSampleBuffer extends SampleBuffer {
readonly overflowLog: number[] = [];
readonly evictLog: number[] = [];
readonly pushLog: { 'evicted': boolean; 'value': number }[] = [];
readonly computeStartLog: number[] = [];
readonly computeCompleteLog: { 'length': number; 'sorted': readonly number[] }[] = [];
readonly percentileLog: { 'pct': number; 'result': number }[] = [];
clearCount = 0;
protected override onOverflow(value: number): void {
console.log(`[sample-buffer] overflow value=${String(value)} capacity=${String(this.capacity)}`);
this.overflowLog.push(value);
}
protected override onEvict(oldValue: number): void {
console.log(`[sample-buffer] evict oldValue=${String(oldValue)}`);
this.evictLog.push(oldValue);
}
protected override onPush(value: number, evicted: boolean): void {
console.log(`[sample-buffer] push value=${String(value)} evicted=${String(evicted)} length=${String(this.length)}`);
this.pushLog.push({ 'evicted': evicted, 'value': value });
}
protected override onComputeStart(length: number): void {
console.log(`[sample-buffer] computeStart length=${String(length)}`);
this.computeStartLog.push(length);
}
protected override onComputeComplete(length: number, sorted: readonly number[]): void {
console.log(`[sample-buffer] computeComplete length=${String(length)} sorted=[${sorted.join(',')}]`);
this.computeCompleteLog.push({ 'length': length, 'sorted': sorted });
}
protected override onPercentile(pct: number, result: number): void {
console.log(`[sample-buffer] percentile pct=${String(pct)} result=${String(result)}`);
this.percentileLog.push({ 'pct': pct, 'result': result });
}
protected override onClear(): void {
console.log(`[sample-buffer] clear length=${String(this.length)}`);
this.clearCount++;
}
}
const buf = TracedSampleBuffer.create({ 'capacity': 3 });
// Fill the buffer (3 pushes, no overflow)
buf.push(10);
buf.push(20);
buf.push(30);
// Push past capacity — triggers overflow + eviction
buf.push(40); // evicts 10
buf.push(50); // evicts 20
// Compute a percentile (triggers computeStart + computeComplete + percentile hook)
const p50 = buf.percentile(50);
// Second call uses cache — no computeStart/computeComplete
const p50Cached = buf.percentile(50);
// Clear
buf.clear();The base class never calls any logger or metrics library. All hooks are no-ops by default.