first commit
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
# vector-clock
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## 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 clock
|
||||
- `merge(left, right)` — component-wise maximum, missing keys treated as zero
|
||||
- `compare(left, right)` — returns `"<" | ">" | "==" | "concurrent"`
|
||||
- `happensBefore(left, right)` — Lamport happens-before predicate
|
||||
- `lwwTotalOrder(left, right)` — total ordering for the `"lww"` strategy with
|
||||
`(clock, updatedAt, nodeId)` tie-breaking
|
||||
- `empty()` — 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
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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 with `nodeId`'s counter incremented by `1`. Throws
|
||||
`VaultSyncError(VECTOR_CLOCK.REGRESSION)` if the counter is already at
|
||||
`Number.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` — `true` iff `compare === "<"`.
|
||||
- `lwwTotalOrder(left, right): -1 | 0 | 1` — total order for last-write-wins
|
||||
resolution: vector-clock dominance, then `updatedAt`, then `nodeId`.
|
||||
- `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.
|
||||
Reference in New Issue
Block a user