Skip to content

@studnicky/clock

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

Install

bash
pnpm add @studnicky/clock

Usage

Build a Clock instance with a provider, then call now() for epoch-ms and hrtime() for nanosecond bigint. Both reads are monotonically clamped per instance:

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, ClockProviderInterface, and ClockError from @studnicky/clock. Provider and counter option entities use @studnicky/clock/entities. Construct each stateful primitive through its root-exported create(...) method.

Virtual time control

VirtualTimeCounter and VirtualClockProvider give deterministic time control with no sleeping and no wall-clock dependency. Multiple independent or shared counters can drive separate clocks:

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

Implement ClockProviderInterface (two methods: now(): number and hrtime(): bigint) to inject any time source into Clock. Swapping the provider changes what Clock returns without touching consumers:

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 stateful operation across Clock, RealTimeClockProvider, VirtualClockProvider, and VirtualTimeCounter exposes a protected lifecycle hook. Subclass any of these classes and override the relevant hook to add logging, metrics, or tracing without touching public API behavior.

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

Clock exposes two protected read hooks that subclasses may override to intercept or replace values before monotonicity clamping:

typescript
import { Clock, type ClockProviderInterface } from '@studnicky/clock';

class TrackedClock extends Clock {
  protected override readHrtime(): bigint {
    const t = super.readHrtime();
    metrics.gauge('clock.hrtime', Number(t));
    return t;
  }
}

declare const provider: ClockProviderInterface;
const clock = TrackedClock.create(provider);

Try it

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

Lifecycle hooks

Each now() and hrtime() read fires the corresponding hook on the counter, provider, and clock layers.

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';

Exports

SymbolPurposeImport path
ClockProvides clock functionality.@studnicky/clock
ClockErrorRepresents clock failures.@studnicky/clock
ClockProviderInterfaceDefines the clock provider contract.@studnicky/clock
RealTimeClockProviderProvides real time clock provider functionality.@studnicky/clock
VirtualClockProviderProvides virtual clock provider functionality.@studnicky/clock
VirtualTimeCounterProvides virtual time counter functionality.@studnicky/clock