3.9 KiB
vector-clock
(no submodules)
Purpose
vector-clock.ts implements the causal-ordering primitives used by every
other module in @suaveplan/vault-sync. Vector clocks let two divergent
peers detect whether one branch is the causal ancestor of the other or
whether the edits were concurrent — a prerequisite for safe CRDT merges,
durable queue ordering, and explicit conflict reporting in SyncEngine.
The implementation deliberately avoids secret-dependent branches: every
comparison uses integer arithmetic against nodeId-keyed counters with no
short-circuit on values that could leak side-channel information.
Features
increment(clock, nodeId)— pure, allocates a fresh clockmerge(left, right)— component-wise maximum, missing keys treated as zerocompare(left, right)— returns"<" | ">" | "==" | "concurrent"happensBefore(left, right)— Lamport happens-before predicatelwwTotalOrder(left, right)— total ordering for the"lww"strategy with(clock, updatedAt, nodeId)tie-breakingempty()— canonical empty vector clock factory- All operations are pure and side-effect free; inputs are never mutated
- Saturation guard: counter never exceeds
Number.MAX_SAFE_INTEGER
Usage: Basic Example
import {
compare,
increment,
merge,
} from "@suaveplan/vault-sync/vector-clock/vector-clock";
const a = increment({}, "node-a");
const b = increment({}, "node-b");
console.log(compare(a, b)); // "concurrent"
console.log(merge(a, b)); // { "node-a": 1, "node-b": 1 }
Usage: Advanced Example
import {
increment,
lwwTotalOrder,
merge,
} from "@suaveplan/vault-sync/vector-clock/vector-clock";
import type { VectorClockState } from "@suaveplan/vault-sync/types";
interface Snapshot {
clock: VectorClockState;
updatedAt: number;
nodeId: string;
}
function applyMutation(local: Snapshot, nodeId: string, now: number): Snapshot {
return {
clock: increment(local.clock, nodeId),
updatedAt: now,
nodeId,
};
}
function pickWinner(left: Snapshot, right: Snapshot): Snapshot {
return lwwTotalOrder(left, right) === -1 ? left : right;
}
// Reconcile a remote snapshot into local state.
function reconcile(local: Snapshot, remote: Snapshot): Snapshot {
const winner = pickWinner(local, remote);
return {
clock: merge(local.clock, remote.clock),
updatedAt: winner.updatedAt,
nodeId: winner.nodeId,
};
}
API Reference
increment(clock: VectorClockState, nodeId: string): VectorClockState— returns a new clock withnodeId's counter incremented by1. ThrowsVaultSyncError(VECTOR_CLOCK.REGRESSION)if the counter is already atNumber.MAX_SAFE_INTEGER.merge(left, right): VectorClockState— element-wise max of two clocks.compare(left, right): "<" | ">" | "==" | "concurrent"— total relation classifying causal order.happensBefore(left, right): boolean—trueiffcompare === "<".lwwTotalOrder(left, right): -1 | 0 | 1— total order for last-write-wins resolution: vector-clock dominance, thenupdatedAt, thennodeId.empty(): VectorClockState—{}literal helper.
Implementation Notes
The clock is modelled as Readonly<Record<string, number>> rather than a
class so the data is trivially serialisable and comparable by deep equality
without custom equals(). compare enumerates the union of node ids; both
absent counters default to 0, which preserves the identity that an empty
clock causally precedes any non-empty one. The lwwTotalOrder resolves
remaining ties deterministically so two peers with identical inputs always
elect the same winner — an essential property for offline-first
convergence. Saturation at Number.MAX_SAFE_INTEGER is treated as a hard
error rather than rolling over, since silent rollover would silently break
causal ordering.