@arki/clock

next

for @arki/[email protected] · Tier 2: standalone libraries · MIT

A clock you pass in, so time-dependent code can be tested without waiting.

Version 0.1.1 next

Install

The latest version on npm is 0.0.2 archive, not supported

bun add @arki/clock
Other package managers
npm install @arki/clock
pnpm add @arki/clock
yarn add @arki/clock

Release health for @arki/[email protected]

Doc examples

5 compiled5 run0 skipped

Open gaps

6 release checks fail

  • checkfile-llms-txt
  • checkfile-agents-md
  • checkfile-agent-guide
  • checkreadme-h2-order
  • checkpkg-engines
  • checkfiles-ship-dist-docs

Used by demos

No demo uses this package yet.

Runtimes

No engines field, so no runtime requirement is declared.

Benchmarks

No benchmarks are published for this version.

README

A clock you pass in, so time-dependent code can be tested without waiting. SystemClock reads real time. MockClock moves only when a test says so.

Install

bun add @arki/clock
# or
npm install @arki/clock

Quick start

Limit a password-reset email to one per 15 minutes. The gate takes a Clock, so the test runs a 15-minute window in no time.

import type { Clock } from '@arki/clock';
import { MockClock } from '@arki/clock';
 
function createCooldownGate(clock: Clock, windowMs: number) {
  const lastAllowed = new Map<string, number>();
  return (key: string): boolean => {
    const now = clock.now().getTime();
    const last = lastAllowed.get(key);
    if (last !== undefined && now - last < windowMs) return false;
    lastAllowed.set(key, now);
    return true;
  };
}
 
const clock = new MockClock(new Date('2026-01-01T00:00:00.000Z'));
const tryAcquire = createCooldownGate(clock, 15 * 60 * 1000);
const first = tryAcquire('[email protected]'); // true
clock.advance(15 * 60 * 1000 - 1);
const early = tryAcquire('[email protected]'); // false: 1 ms left in the window
clock.advance(1);
const later = tryAcquire('[email protected]'); // true: the window is over
if (!first || early || !later) throw new Error('the window did not behave');

In production, pass new SystemClock() instead. The tested version of this gate is examples/cooldown-gate.ts, and it has a test.

/** Lets one action through per window, for example one password-reset email per 15 minutes. */
export function createCooldownGate(clock: Clock, windowMs: number) {
  const lastAllowed = new Map<string, number>();
  return {
    /** True when the key may act now. A true answer starts a new window for that key. */
    tryAcquire(key: string): boolean {
      const now = clock.now().getTime();
      const last = lastAllowed.get(key);
      if (last !== undefined && now - last < windowMs) return false;
      lastAllowed.set(key, now);
      return true;
    },
  };
}

When not to use this

  • You need fake timers for code you do not own. That code calls the global setTimeout, and @arki/clock cannot reach it. Use a library that patches the globals, such as @sinonjs/fake-timers.
  • You need a scheduler with cron rules or persistence. A clock only reads time and waits.
  • You need dates, zones or durations. Use @arki/date, which builds on this package.

Runtime support

Pure ESM with a portable @arki/failure dependency and no node:* imports. It uses only Date and setTimeout at call time, so it runs on Node, Bun, Deno, browsers and React Native (Hermes). bun run test:runtimes runs a smoke test of the built package on each runtime. The Hermes leg needs HERMES_BIN and prints INCOMPLETE when it is missing.

RuntimeResultVersion testedDate (UTC)
nodepassv26.11.12026-10-17
bunpass1.4.22026-10-17
denopass2.9.72026-10-17
browserpassChromium 156.0.8078.42026-10-17
hermesnot run-2026-10-17

Public imports

import { Clock, SystemClock, MockClock, MAX_TIMER_MS, ClockError, CLOCK_ERROR_CODES } from '@arki/clock'. The package has one entry.

Clock

The type every clock implements:

  • now(): Date — return the current time as a Date.
  • setTimer(callback, ms): () => void — call callback once, ms milliseconds from now. Returns a function that cancels the timer. Throws ARKI_CLOCK_E002 when ms is negative, not finite, or above MAX_TIMER_MS.
  • sleep(ms, signal?): Promise<void | SleepFailure> — resolve ms milliseconds from now. Aborting signal clears the timer and returns SleepCancelled (private cause is signal.reason).

MAX_TIMER_MS is 2^31 - 1 ms (about 24.8 days), the longest delay every runtime's setTimeout honors.

SystemClock

Production implementation backed by the runtime Date and setTimeout. now() returns new Date().

MockClock

Controllable clock for tests.

  • new MockClock(initialNow?: Date) — defaults to the Unix epoch.
  • now(): Date — returns a fresh Date on every call (callers cannot corrupt internal state by mutating it).
  • advance(ms: number): void — moves time forward and fires every timer that comes due, in time order, each at its own due time. A timer set by a firing callback fires too if it is due within the step. Throws ClockError (ARKI_CLOCK_E001) on negative or non-finite values.
  • advanceAsync(ms: number): Promise<void> — like advance, but lets pending promise callbacks run before the first timer and after each one. Use it when async code sets the next timer after an await, like a retry loop. It waits one real macrotask per timer, so do not mix it with fake timers that replace setTimeout.
  • setNow(date: Date): void — sets absolute time; allowed to go backward for arranging past scenarios. Fires no timers.
import { isFailure } from '@arki/failure';
import { MockClock } from '@arki/clock';
 
const clock = new MockClock();
async function callWithRetry(): Promise<number> {
  let tries = 0;
  for (let attempt = 0; attempt < 3; attempt += 1) {
    tries += 1;
    const outcome = await clock.sleep(1000 * 2 ** attempt); // 1 s, 2 s, 4 s between tries
    if (isFailure(outcome)) throw outcome;
  }
  return tries;
}
 
const result = callWithRetry();
await clock.advanceAsync(7000); // all the retry delay, in about a millisecond
if ((await result) !== 3) throw new Error('expected three tries');

Failure behaviour

Operational cancellation returns SleepCancelled, defined once in SLEEP_FAILURES; SleepFailure is inferred from that tuple. Static timer/advance misuse still throws ClockError under A33. Unknown callback/runtime defects remain exceptions. now() and setNow() add no failure outcome.

CodeRaised byMeaning
ARKI_CLOCK_E001MockClock.advance, advanceAsyncThe step is negative, NaN or not finite. Use setNow to go back.
ARKI_CLOCK_E002setTimer, sleepThe delay is negative, not finite, or above MAX_TIMER_MS.
clock.sleep_cancelledsleepThe signal aborted the sleep; private cause is signal.reason.

Handle an aborted sleep like this:

import { SleepCancelled, MockClock } from '@arki/clock';
 
const clock = new MockClock();
const controller = new AbortController();
const waiting = clock.sleep(1000, controller.signal);
controller.abort('user cancelled');
const outcome = await waiting;
if (!SleepCancelled.is(outcome)) throw new Error('sleep should have returned cancellation');
if (outcome.cause !== 'user cancelled') throw new Error('cause should be the abort reason');

Limits

  • The longest delay is MAX_TIMER_MS: 2^31 - 1 ms, about 24.8 days. setTimer(fn, MAX_TIMER_MS) works. setTimer(fn, MAX_TIMER_MS + 1) throws ARKI_CLOCK_E002. Split a longer wait into steps.
  • MockClock.advance is synchronous. A timer that async code sets after an await does not exist yet. Use advanceAsync, which waits one real macrotask per timer.
  • setNow moves time and fires no timers, even ones it jumps past.
  • A timer has no jitter and no drift. SystemClock timers are as exact as the platform setTimeout.
  • The clock does not patch the globals. Code that calls Date or setTimeout directly is not controlled.

Documentation

@arki/clock works on its own. With the @arki/dot application framework, see packages/dot/docs/.

Support

Report a bug or ask a question at github.com/arki-labs/arki/issues. Include the delay or step you passed and the error code you saw.

Benchmarks

Compares @arki/clock with the platform clocks (Date, performance) and with @sinonjs/fake-timers, the fake clock behind Jest, Vitest and many Sinon setups. These are the two things a clock abstraction does: read real time, and stand in for it in tests.

Node v22.13.1 on Apple M2 Max, 2026-10-13. Median time per operation, lower is better.

Read the real time

LibraryTime / opvs fastest
@arki/clock SystemClock.now()349.3 ns2.61×
native new Date()280.0 ns2.09×
native Date.now()137.2 ns1.03×
native performance.now()133.7 nsfastest

Read a fake time

A mock clock stopped at a fixed instant. @arki/clock and new clock.Date() return a Date; clock.now is a plain number.

LibraryTime / opvs fastest
@arki/clock MockClock.now()235.6 ns6.70×
fake-timers new clock.Date()902.8 ns25.67×
fake-timers clock.now35.2 nsfastest

Advance the fake time by 1 ms (no timers pending)

LibraryTime / opvs fastest
@arki/clock MockClock.advance(1)50.2 nsfastest
fake-timers clock.tick(1)549.0 ns10.94×

Schedule a callback, advance 10 ms, run it

fake-timers schedules with setTimeout and runs the callback inside tick. The @arki/clock row computes the deadline, advances, and runs the callback itself.

LibraryTime / opvs fastest
@arki/clock advance + manual deadline check720.6 nsfastest
fake-timers setTimeout + tick(10)3.23 µs4.49×

Create a fresh fake clock

The per-test setup cost. install also patches the global timers and Date, so the row includes uninstall to restore them. createClock makes a clock without touching globals.

LibraryTime / opvs fastest
@arki/clock new MockClock(date)33.6 nsfastest
fake-timers createClock(date)16.21 µs482.72×
fake-timers install(), then uninstall()247.19 µs7360.67×

Read the rows with the contracts in mind:

  • @arki/clock is a small injected object: your code takes a Clock and calls now(). @sinonjs/fake-timers instead replaces the global Date, setTimeout and friends, and then moves them with tick. The two solve the same testing problem with different contracts.
  • @arki/clock has no timers. In the "advance and fire" group the @arki/clock row advances time and checks a deadline by hand, which is how code written against an injected clock does the same job. fake-timers does the scheduling for you, so it does more work per call.
  • SystemClock.now() returns a fresh Date, exactly like new Date(). The gap between those two rows is the cost of the wrapper. Date.now() and performance.now() return numbers and skip the allocation.
  • MockClock.advance throws on negative or non-finite input, so time can never run backward by mistake. fake-timers accepts the value you give it.

Reproduce with bun run bench in the package directory; regenerate this section with bun run bench --write-readme.

License

MIT. See LICENSE.

Sleep waits for asynchronous cleanup returned by a provider before settling. Asynchronous listener registration rejection becomes a defect while sleep is pending; a rejection after completion is observed without changing the completed outcome. Owned sleep provider registration and cleanup defects return canonical UnexpectedDefect from @arki/failure. SLEEP_FAILURES contains exactly SleepCancelled and UnexpectedDefect; private causes are excluded from serialization.

Docs 2 pages in 0.1.1

Public imports 1 path · 10 exports

Import pathExportsFirst names
@arki/clock10 exportsClock, CLOCK_ERROR_CODES, ClockError, ClockErrorCode, …
All 10 names
  • Clock type
  • CLOCK_ERROR_CODES variable
  • ClockError class
  • ClockErrorCode type
  • MAX_TIMER_MS variable
  • MockClock class
  • SLEEP_FAILURES variable
  • SleepCancelled variable
  • SleepFailure type
  • SystemClock class

Every export with its signature, as JSON