Skip to content

@studnicky/clock ​

Wall-clock and monotonic time primitives with injectable providers for deterministic testing.

What it is ​

@studnicky/clock is a composable time primitive with real and virtual providers, monotonic reads, and bounded timing events. It supplies time and timing contracts; it does not define a bookstore workflow or metrics product.

What it is for ​

Use it when Northstar Books needs consistent timestamps, deterministic tests for reservation windows, or a bounded timeline for one operation. Consumers select the clock, retain business rules, and decide where measurements go.

Northstar Books examples ​

  • Timing event timeline maps one book-search request to ordered timing events. It proves a caller can retain a bounded, clock-backed trace without coupling search to a telemetry vendor.
  • Timing lifecycle hooks maps search timing instrumentation to protected observation hooks. It proves Northstar can emit timing observations without changing the tracker's contract.
  • Clock lifecycle hooks maps a book-hold expiry calculation to real or virtual clock reads. It proves time-source behavior is observable and deterministic under a virtual counter.

Install ​

bash
pnpm add @studnicky/clock

Usage ​

Northstar's book-hold expiry logic needs a trustworthy clock in production and a fully controllable one in tests — and both should answer to the exact same interface. Build a Clock from any provider and call now() for epoch-milliseconds or hrtime() for a nanosecond bigint; either reading is monotonically clamped per instance, so it can never appear to run backward. The example below reads a real-time clock twice to confirm it never decreases, then drives a VirtualTimeCounter through two manual advances to show the virtual clock landing on exactly the millisecond values you told it to.

ts
import {
  Clock,
  RealTimeClockProvider,
  VirtualClockProvider,
  VirtualTimeCounter
} from '../src/index.js';

// --- RealTimeClockProvider ---

const realProvider = RealTimeClockProvider.create();
const realClock = Clock.create(realProvider);

const nowA = realClock.now();
const nowB = realClock.now();

console.log(`RealTimeClockProvider.now(): first=${nowA}, second=${nowB}`);

const hrtA = realClock.hrtime();
const hrtB = realClock.hrtime();

console.log(`RealTimeClockProvider.hrtime(): first=${hrtA}n, second=${hrtB}n`);

// --- VirtualTimeCounter + VirtualClockProvider ---

const counter = VirtualTimeCounter.create();
const virtualProvider = VirtualClockProvider.create(counter);
const virtualClock = Clock.create(virtualProvider);

const startMs = virtualClock.now();
console.log(`VirtualClock.now() at start: ${startMs}`);

counter.advance(500);
const after500 = virtualClock.now();
console.log(`VirtualClock.now() after advance(500): ${after500}`);

counter.advance(1000);
const after1000 = virtualClock.now();
console.log(`VirtualClock.now() after advance(1000): ${after1000}`);

Public API ​

Import Clock, RealTimeClockProvider, VirtualClockProvider, VirtualTimeCounter, ClockConversionError, and ClockError from @studnicky/clock/node; import ClockProviderInterface from @studnicky/clock/interfaces. Provider and counter option entities use @studnicky/clock/entities. Construct each stateful primitive through its create(...) method.

Timing ​

When a book-search request crawls, Northstar needs to know which stage of the pipeline actually took the time — not just that the whole thing was slow. Timing records a bounded timeline of component.operation events against an injected Clock, defaulting to real time but happy to take a VirtualClockProvider when a test needs deterministic elapsed values instead. The example records a plain query event alongside a start/complete/hit cache sequence, then reads the resulting map back to confirm every named key — including the overall durationMs — is present with a sane numeric value.

ts
import { TimingOptionsEntity } from '../src/timing/entities/index.js';
import { Timing, TIMING_STATUS, TimingEvent } from '../src/timing/index.js';

const timing = Timing.create(TimingOptionsEntity.create({ 'maximumEvents': 50 }));

// Record a plain component.operation event
timing.event(TimingEvent.create({ 'component': 'GraphAdapter', 'operation': 'query' }));

// Record component.operation.status events
timing.event(
  TimingEvent.create({ 'component': 'CacheService', 'operation': 'get', 'status': TIMING_STATUS.START })
);

timing.event(
  TimingEvent.create({
    'component': 'CacheService',
    'operation': 'get',
    'status': TIMING_STATUS.COMPLETE
  })
);

timing.event(
  TimingEvent.create({ 'component': 'CacheService', 'operation': 'get', 'status': TIMING_STATUS.HIT })
);

const events = timing.getEvents();

console.log('events:', events);

@studnicky/clock/timing exports Timing, TimingEvent, NoOpTiming, TIMING_STATUS, and TimingBuildError. Timing schemas are available from @studnicky/clock/timing/entities; the shared tracker contract is available from @studnicky/clock/timing/interfaces.

Timing lifecycle hooks ​

Watching a Timing tracker's internals — when it initializes, records, evicts, clears, or gets read — is a subclassing job, not a call to some external logger. Timing exposes protected onInitialize, onEvent, onEvict, onClear, and onGetEvents hooks that fire with the clock values already attached, leaving the choice of time source entirely to the host. The example below deliberately caps the tracker at three events so a fourth triggers eviction, clears it mid-run, and confirms every hook fired the expected number of times with the expected shapes.

ts
import type { TimingEventDataEntity } from '../src/timing/entities/index.js';

import { TimingOptionsEntity } from '../src/timing/entities/index.js';
import { Timing, TimingEvent } from '../src/timing/index.js';

class ObservedTiming extends Timing {
  // onInitialize fires inside super() before class field initializers run.
  // Use `declare` so TypeScript knows the type but emits no own-property
  // initializer that would reset the value after super() returns.
  declare initEvents: { 'startTime': bigint }[];
  recordedEvents: { 'data': TimingEventDataEntity.Type; 'timestamp': bigint }[] = [];
  evictedNames: string[] = [];
  clearCount = 0;
  getEventsCalls: { 'eventCount': number }[] = [];

  public constructor(options: TimingOptionsEntity.Type = TimingOptionsEntity.create()) {
    super(options);
  }

  protected override onInitialize(startTime: bigint): void {
    console.log(`[timing] initialize startTime=${startTime}`);
    // Bootstrap the array here because this fires before the field initializer.
    this.initEvents ??= [];
    this.initEvents.push({ 'startTime': startTime });
  }

  protected override onEvent(data: TimingEventDataEntity.Type, timestamp: bigint): void {
    console.log(`[timing] event name=${data.event} timestamp=${timestamp}`);
    this.recordedEvents.push({ 'data': data, 'timestamp': timestamp });
  }

  protected override onEvict(name: string): void {
    console.log(`[timing] evict name=${name}`);
    this.evictedNames.push(name);
  }

  protected override onClear(): void {
    console.log('[timing] clear');
    this.clearCount++;
  }

  protected override onGetEvents(eventCount: number): void {
    console.log(`[timing] getEvents eventCount=${eventCount}`);
    this.getEventsCalls.push({ 'eventCount': eventCount });
  }
}

// Create an ObservedTiming with a small maximumEvents to trigger eviction
const timing = new ObservedTiming(TimingOptionsEntity.create({ 'maximumEvents': 3 }));

// Record two events (cache: initialize + DbAdapter.query + CacheService.get = 3, at capacity)
timing.event(TimingEvent.create({ 'component': 'DbAdapter', 'operation': 'query' }));

timing.event(TimingEvent.create({ 'component': 'CacheService', 'operation': 'get' }));

// Call getEvents to trigger onGetEvents (3 entries in cache)
const snapshot = timing.getEvents();
console.log('snapshot keys:', [...snapshot.keys()]);

// Clear to trigger onClear
timing.clear();

// Fill cache to capacity (maximumEvents: 3) then overflow to trigger eviction
timing.event(TimingEvent.create({ 'component': 'DbAdapter', 'operation': 'insert' }));

timing.event(TimingEvent.create({ 'component': 'CacheService', 'operation': 'set' }));

timing.event(TimingEvent.create({ 'component': 'MetricsService', 'operation': 'flush' }));

// This 4th event overflows the cache — evicts DbAdapter.insert
timing.event(TimingEvent.create({ 'component': 'MetricsService', 'operation': 'emit' }));

// Final getEvents call
const final = timing.getEvents();
console.log('final keys:', [...final.keys()]);

Timing browser examples ​

Loading example…
Loading example…

Virtual time control ​

A test suite that actually sleeps to wait for timeouts is slow and flaky by design — VirtualTimeCounter and VirtualClockProvider let Northstar fast-forward time instead, deterministically, with zero real waiting. The example checks that hrtime() always agrees with now() scaled to nanoseconds, walks a counter through an irregular sequence of advances to prove it never reports time moving backward, and then shows two counters evolving completely independently before two clocks sharing one counter stay perfectly in sync.

ts
import {
  Clock,
  VirtualClockProvider,
  VirtualTimeCounter
} from '../src/index.js';
import { VirtualTimeFixture } from './fixtures/VirtualTimeFixture.js';

// --- hrtime matches epoch-ms * 1_000_000n ---

const counterA = VirtualTimeCounter.create();
const clockA = Clock.create(VirtualClockProvider.create(counterA));

counterA.advance(100);

const epochMs = clockA.now();
const ns = clockA.hrtime();

console.log(`epochMs=${epochMs}, hrtime=${ns}n (== ${epochMs} * 1_000_000n)`);

// --- Monotonicity after advances ---

const counterB = VirtualTimeCounter.create();
const clockB = Clock.create(VirtualClockProvider.create(counterB));

const readings: number[] = [];

const deltaLength = VirtualTimeFixture.Deltas.length;

for (let i = 0; i < deltaLength; i++) {
  counterB.advance(VirtualTimeFixture.Deltas[i] ?? 0);
  readings.push(clockB.now());
}

console.log(`monotonicity sequence: ${readings.join(' → ')}`);

// --- Two independent counters evolve independently ---

const counterX = VirtualTimeCounter.create({ 'startMs': 1000 });
const counterY = VirtualTimeCounter.create({ 'startMs': 2000 });
const clockX = Clock.create(VirtualClockProvider.create(counterX));
const clockY = Clock.create(VirtualClockProvider.create(counterY));

counterX.advance(500);
counterY.advance(100);

console.log(`independent counters: clockX.now()=${clockX.now()}, clockY.now()=${clockY.now()}`);

// --- Shared counter keeps multiple clocks in sync ---

const shared = VirtualTimeCounter.create();
const clockP = Clock.create(VirtualClockProvider.create(shared));
const clockQ = Clock.create(VirtualClockProvider.create(shared));

shared.advance(300);

console.log(`shared counter: clockP.now()=${clockP.now()}, clockQ.now()=${clockQ.now()}`);

Custom providers ​

Clock doesn't care where time comes from — it only needs something that implements two methods, now(): number and hrtime(): bigint. The example below builds a provider that always returns fixed values, another that counts up on every call, and then swaps between two fixed providers on the same Clock constructor to prove the behavior change lives entirely in the injected provider, never in Clock itself.

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

import { Clock } from '../src/index.js';

// --- Custom provider with fixed values ---

class FixedClockProvider implements ClockProviderInterface {
  readonly #epochMs: number;
  readonly #ns: bigint;

  public constructor(epochMs: number, ns: bigint) {
    this.#epochMs = epochMs;
    this.#ns = ns;
  }

  public hrtime(): bigint {
    return this.#ns;
  }

  public now(): number {
    return this.#epochMs;
  }
}

const fixedProvider = new FixedClockProvider(9999, 9_999_000_000n);
const clockFixed = Clock.create(fixedProvider);

console.log(`FixedClockProvider: now()=${clockFixed.now()}, hrtime()=${clockFixed.hrtime()}n`);

// --- Swapping provider changes what Clock returns ---

class CountingClockProvider implements ClockProviderInterface {
  #callCount: number;

  public constructor() {
    this.#callCount = 0;
  }

  public hrtime(): bigint {
    this.#callCount += 1;
    const result = BigInt(this.#callCount) * 1_000_000n;
    return result;
  }

  public now(): number {
    this.#callCount += 1;
    return this.#callCount;
  }

  public get callCount(): number {
    return this.#callCount;
  }
}

const countingProvider = new CountingClockProvider();
const clockCounting = Clock.create(countingProvider);

const firstNow = clockCounting.now();
const secondNow = clockCounting.now();

console.log(`CountingClockProvider: firstNow=${firstNow}, secondNow=${secondNow}`);

// --- DI seam: same Clock constructor, different behavior per provider ---

const clockA = Clock.create(new FixedClockProvider(1, 1_000_000n));
const clockB = Clock.create(new FixedClockProvider(2, 2_000_000n));

console.log(`DI swap: clockA.now()=${clockA.now()}, clockB.now()=${clockB.now()}`);

Observability hooks ​

Every read and every advance across Clock, RealTimeClockProvider, VirtualClockProvider, and VirtualTimeCounter fires its own protected hook, so Northstar can subclass any of them and add logging, metrics, or tracing without changing what the public API actually returns.

HookClassWhen it firesArgs
onNow(timestamp)ClockAfter each now() call, with the monotonically-clamped epoch-ms returned to the callertimestamp: number
onHrtime(value)ClockAfter each hrtime() call, with the monotonically-clamped nanosecond bigint returned to the callervalue: bigint
onNow(timestamp)RealTimeClockProviderAfter each now() call, with the final epoch-ms (raw + offset) returned to the callertimestamp: number
onHrtime(value)RealTimeClockProviderAfter each hrtime() call, with the final nanosecond bigint (performance.now() + offset) returned to the callervalue: bigint
onNow(timestamp)VirtualClockProviderAfter each now() call, with the virtual epoch-ms (clamped to 0 if negative) returned to the callertimestamp: number
onHrtime(value)VirtualClockProviderAfter each hrtime() call, with the virtual nanosecond bigint returned to the callervalue: bigint
onAdvance(deltaMs, nowMs)VirtualTimeCounterAfter each positive advance() call, with the applied delta and the resulting epoch-msdeltaMs: number, nowMs: number
onNowMs(value)VirtualTimeCounterAfter each nowMs() call, with the current virtual epoch-ms returned to the callervalue: number
ts
import { VirtualTimeCounterOptionsEntity } from '../src/entities/VirtualTimeCounterOptionsEntity.js';
import {
  Clock,
  VirtualClockProvider,
  VirtualTimeCounter
} from '../src/index.js';

class TracedCounter extends VirtualTimeCounter {
  public constructor(options: Parameters<typeof VirtualTimeCounter.create>[0] = {}) {
    super(VirtualTimeCounterOptionsEntity.intake(options));
  }

  protected override onAdvance(deltaMs: number, nowMs: number): void {
    console.log(`[clock] counter.advance  delta=${deltaMs}ms  nowMs=${nowMs}ms`);
  }

  protected override onNowMs(value: number): void {
    console.log(`[clock] counter.nowMs    value=${value}ms`);
  }
}

class TracedVirtualProvider extends VirtualClockProvider {
  public constructor(counter: Readonly<VirtualTimeCounter>) {
    super(counter);
  }

  protected override onNow(timestamp: number): void {
    console.log(`[clock] provider.now     timestamp=${timestamp}ms`);
  }

  protected override onHrtime(value: bigint): void {
    console.log(`[clock] provider.hrtime  value=${value}ns`);
  }
}

class TracedClock extends Clock {
  public constructor(provider: VirtualClockProvider) {
    super(provider);
  }

  protected override onNow(timestamp: number): void {
    console.log(`[clock] clock.now        timestamp=${timestamp}ms`);
  }

  protected override onHrtime(value: bigint): void {
    console.log(`[clock] clock.hrtime     value=${value}ns`);
  }
}

class ObservedClockExample {
  static run(): void {
    // Capture events for assertion
    const advanceEvents: { 'deltaMs': number; 'nowMs': number }[] = [];
    const nowMsEvents: number[] = [];
    const providerNowEvents: number[] = [];
    const clockNowEvents: number[] = [];
    const clockHrtimeEvents: bigint[] = [];

    class RecordingCounter extends TracedCounter {
      protected override onAdvance(deltaMs: number, nowMs: number): void {
        super.onAdvance(deltaMs, nowMs);
        advanceEvents.push({ 'deltaMs': deltaMs, 'nowMs': nowMs });
      }

      protected override onNowMs(value: number): void {
        super.onNowMs(value);
        nowMsEvents.push(value);
      }
    }

    class RecordingProvider extends TracedVirtualProvider {
      protected override onNow(timestamp: number): void {
        super.onNow(timestamp);
        providerNowEvents.push(timestamp);
      }
    }

    class RecordingClock extends TracedClock {
      protected override onNow(timestamp: number): void {
        super.onNow(timestamp);
        clockNowEvents.push(timestamp);
      }

      protected override onHrtime(value: bigint): void {
        super.onHrtime(value);
        clockHrtimeEvents.push(value);
      }
    }

    console.log('--- scenario: advance then read ---');

    const counter = new RecordingCounter({ 'startMs': 1000 });
    const provider = new RecordingProvider(counter);
    const clock = new RecordingClock(provider);

    const t0 = clock.now();
    counter.advance(500);
    const t1 = clock.now();
    counter.advance(250);
    const t2 = clock.now();
    const h0 = clock.hrtime();

    console.log(`\nReturned values: t0=${t0} t1=${t1} t2=${t2} h0=${h0}n`);

    // Assertions
    assert.equal(clockNowEvents.length, 3, 'clock.onNow fires 3 times');
    assert.equal(clockNowEvents[0], 1000, 'first now is 1000');
    assert.equal(clockNowEvents[1], 1500, 'second now is 1500 after +500');
    assert.equal(clockNowEvents[2], 1750, 'third now is 1750 after +250');

    assert.equal(clockHrtimeEvents.length, 1, 'clock.onHrtime fires once');
    assert.equal(clockHrtimeEvents[0], 1750n * 1_000_000n, 'hrtime matches final nowMs in ns');

    assert.equal(advanceEvents.length, 2, 'counter.onAdvance fires twice');
    assert.equal(advanceEvents[0]!.deltaMs, 500);
    assert.equal(advanceEvents[0]!.nowMs, 1500);
    assert.equal(advanceEvents[1]!.deltaMs, 250);
    assert.equal(advanceEvents[1]!.nowMs, 1750);

    assert.equal(providerNowEvents.length, 3, 'provider.onNow fires 3 times');

    console.log('\nobservedClock: all assertions passed');
  }
}

ObservedClockExample.run();

The base class never calls any logger or metrics library. All hooks are no-ops by default.

Extending ​

Override the protected onNow or onHrtime lifecycle hooks to observe clock reads. Inject a custom ClockProviderInterface when a consumer needs a different source; consumers continue to depend on Clock.

Try it ​

The examples below run directly in the browser against the published package.

Lifecycle hooks ​

This demo stacks three layers of tracing — a counter, a provider, and a clock, each subclassed to record its own hook firings — then drives one scenario: read now(), advance 500ms, read again, advance 250ms, read a third time, then read hrtime() once. Watch the clock-level hook fire three times with the expected millisecond values while the counter-level onAdvance hook fires exactly twice, one per advance call.

Loading example…

Source on GitHub

Entities ​

@studnicky/clock/entities exports every schema namespace in src/entities.

typescript
import { RealTimeClockProviderOptionsEntity } from "@studnicky/clock/entities";

Interfaces ​

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

typescript
import type { ClockProviderInterface } from "@studnicky/clock/interfaces";

Public entrypoints ​

Import pathUse it when
@studnicky/clock/nodeA Northstar Books server or worker needs runtime clocks, providers, counters, and clock errors.
@studnicky/clock/browserA browser bundle needs the same time primitive; it is a runtime alternative to /node, not another clock product.
@studnicky/clock/entitiesA consumer needs clock provider and counter schemas as boundary contracts.
@studnicky/clock/interfacesA consumer needs the typed clock-provider contract while supplying its own source.
@studnicky/clock/monotonic-nowA consumer needs validated nondecreasing millisecond values for ordered bookstore events.
@studnicky/clock/monotonic-now/interfacesA consumer needs the monotonic-time port as a contract.
@studnicky/clock/timingA book-search or checkout operation needs a bounded timing-event primitive.
@studnicky/clock/timing/entitiesA consumer needs timing schemas as contracts at its boundary.
@studnicky/clock/timing/interfacesA consumer needs the timing tracker contract while composing its own observability.

Exports ​

SymbolPurposeImport path
ClockProvides clock functionality.@studnicky/clock/node
ClockConversionErrorRepresents host timer conversion failures.@studnicky/clock/node
ClockErrorRepresents clock failures.@studnicky/clock/node
MonotonicNowValidates a finite, nondecreasing millisecond source.@studnicky/clock/monotonic-now
MonotonicNowInterfaceDefines the monotonic number-time port.@studnicky/clock/monotonic-now/interfaces
ClockProviderInterfaceDefines the clock provider contract.@studnicky/clock/interfaces
RealTimeClockProviderProvides real time clock provider functionality.@studnicky/clock/node
VirtualClockProviderProvides virtual clock provider functionality.@studnicky/clock/node
VirtualTimeCounterProvides virtual time counter functionality.@studnicky/clock/node
TIMING_STATUSDefines supported timing event statuses.@studnicky/clock/timing
NoOpTimingDiscards timing events.@studnicky/clock/timing
TimingRecords a bounded elapsed-time event timeline.@studnicky/clock/timing
TimingBuildErrorRepresents timing event build failures.@studnicky/clock/timing
TimingEventBuilds immutable timing event data.@studnicky/clock/timing
TimingInterfaceDefines the timing tracker contract.@studnicky/clock/timing/interfaces