@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}`);Subpath exports
| Subpath | Contents |
|---|---|
@studnicky/clock | Clock, RealTimeClockProvider, VirtualClockProvider, VirtualTimeCounter |
@studnicky/clock/interfaces | ClockProviderType |
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 deltasLen = VirtualTimeFixture.Deltas.length;
for (let i = 0; i < deltasLen; 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 ClockProviderType (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 { ClockProviderType } from '../src/index.js';
import { Clock } from '../src/index.js';
// --- Custom provider with fixed values ---
class FixedClockProvider implements ClockProviderType {
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 ClockProviderType {
#callCount: number;
public constructor() {
this.#callCount = 0;
}
public hrtime(): bigint {
this.#callCount += 1;
return BigInt(this.#callCount) * 1_000_000n;
}
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 {
Clock,
VirtualClockProvider,
VirtualTimeCounter
} from '../src/index.js';
class TracedCounter extends VirtualTimeCounter {
public constructor(options: Parameters<typeof VirtualTimeCounter.create>[0] = {}) {
super(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 } from '@studnicky/clock';
import type { ClockProviderType } from '@studnicky/clock/interfaces';
class TrackedClock extends Clock {
constructor(provider: ClockProviderType) {
super(provider);
}
protected override readHrtime(): bigint {
const t = super.readHrtime();
metrics.gauge('clock.hrtime', Number(t));
return t;
}
}Try it
The examples below run directly in the browser against the published package.
Builder
Watch the builder chain assemble a virtual clock — each step is logged.
/** builder-clock — construct Clock and VirtualClockProvider via the fluent builder API. Run: npx tsx packages/clock/examples/builder-clock.ts */
import assert from 'node:assert/strict';
import { Clock, VirtualClockProvider, VirtualTimeCounter } from '../src/index.js';
// #region usage
// Build a VirtualTimeCounter starting at epoch 1_000 ms.
const counter = VirtualTimeCounter.builder()
.withStartMs(1_000)
.build();
console.log('counter initial nowMs:', counter.nowMs());
// Build a VirtualClockProvider backed by that counter.
const provider = VirtualClockProvider.builder()
.withCounter(counter)
.build();
console.log('provider initial now():', provider.now());
// Build a Clock that delegates to the provider.
const clock = Clock.builder()
.withProvider(provider)
.build();
console.log('clock initial now():', clock.now());
// Advance the counter by 500 ms — Clock and provider see the change immediately.
counter.advance(500);
console.log('after advance(500) — counter.nowMs():', counter.nowMs());
console.log('after advance(500) — clock.now():', clock.now());
// #endregion usage
assert.equal(counter.nowMs(), 1_500);
assert.equal(provider.now(), 1_500);
assert.equal(clock.now(), 1_500);
console.log('builder-clock: all assertions passed');
Lifecycle hooks
Each now() and hrtime() read fires the corresponding hook on the counter, provider, and clock layers.
/** observedClock — trace every now/hrtime/advance read via protected hook overrides. Run: npx tsx examples/observedClock.ts */
import assert from 'node:assert/strict';
// #region usage
import {
Clock,
VirtualClockProvider,
VirtualTimeCounter
} from '../src/index.js';
class TracedCounter extends VirtualTimeCounter {
public constructor(options: Parameters<typeof VirtualTimeCounter.create>[0] = {}) {
super(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();
// #endregion usage