@studnicky/clock
Wall-clock and monotonic time primitives with injectable providers for deterministic testing.
Install
pnpm add @studnicky/clockUsage
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:
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:
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:
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.
| Hook | Class | When it fires | Args |
|---|---|---|---|
onNow(timestamp) | Clock | After each now() call, with the monotonically-clamped epoch-ms returned to the caller | timestamp: number |
onHrtime(value) | Clock | After each hrtime() call, with the monotonically-clamped nanosecond bigint returned to the caller | value: bigint |
onNow(timestamp) | RealTimeClockProvider | After each now() call, with the final epoch-ms (raw + offset) returned to the caller | timestamp: number |
onHrtime(value) | RealTimeClockProvider | After each hrtime() call, with the final nanosecond bigint (performance.now() + offset) returned to the caller | value: bigint |
onNow(timestamp) | VirtualClockProvider | After each now() call, with the virtual epoch-ms (clamped to 0 if negative) returned to the caller | timestamp: number |
onHrtime(value) | VirtualClockProvider | After each hrtime() call, with the virtual nanosecond bigint returned to the caller | value: bigint |
onAdvance(deltaMs, nowMs) | VirtualTimeCounter | After each positive advance() call, with the applied delta and the resulting epoch-ms | deltaMs: number, nowMs: number |
onNowMs(value) | VirtualTimeCounter | After each nowMs() call, with the current virtual epoch-ms returned to the caller | value: number |
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:
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.
Entities
@studnicky/clock/entities exports every schema namespace in src/entities.
import { RealTimeClockProviderOptionsEntity } from '@studnicky/clock/entities';Interfaces
@studnicky/clock/interfaces exports every TypeScript interface in src/interfaces, including configuration and state contracts.
import type { ClockProviderInterface } from '@studnicky/clock/interfaces';Exports
| Symbol | Purpose | Import path |
|---|---|---|
Clock | Provides clock functionality. | @studnicky/clock |
ClockError | Represents clock failures. | @studnicky/clock |
ClockProviderInterface | Defines the clock provider contract. | @studnicky/clock |
RealTimeClockProvider | Provides real time clock provider functionality. | @studnicky/clock |
VirtualClockProvider | Provides virtual clock provider functionality. | @studnicky/clock |
VirtualTimeCounter | Provides virtual time counter functionality. | @studnicky/clock |