Initial Commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
ReconciliationProtocol,
|
||||
type ReconciliationProtocolConfig,
|
||||
} from "./reconciliation-protocol.js";
|
||||
export type {
|
||||
ConflictReport,
|
||||
MeshSequence,
|
||||
ReconciliationOutcome,
|
||||
ServerWatermark,
|
||||
SessionAttributor,
|
||||
} from "./reconciliation-protocol.types.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
# reconciliation-protocol
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`ReconciliationProtocol` runs on the Tier-0 host the moment the server reconnects. It honours the server-supplied watermark (`meshEpoch` + per-session `sessionSequenceNumbers`), computes the differential set of buffered entries the server has not yet seen, attaches a `ConflictReport` whenever the host holds the same seq the server already records, and produces an ordered list of `ReconciliationOutcome`s the host can ship upstream. After the server acknowledges the diff, `commit(acceptedSeqs)` drops the reconciled entries from the local buffer.
|
||||
|
||||
The protocol is deliberately a pure-data engine: it does not own the gateway, the bus, or the network round-trip. Wiring those concerns is the plugin façade's job — this layer is unit-testable end-to-end without a server in the loop.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict epoch validation: rejects watermarks more than one epoch behind, and any watermark ahead of the host. `RECONCILIATION_EPOCH_INVALID` is thrown synchronously so a misconfigured server can never silently corrupt local state.
|
||||
- Configurable conflict resolver: defaults to last-writer-wins keyed by `bufferedAt` vs `issuedAt`, with ties resolving in favour of the host. Callers can swap in any `(report) => "host" | "server"` strategy without subclassing.
|
||||
- Session-keyed differential: the `sessionAttributor` callback maps an entry to its session id; entries that have no session attribution flow through as host-priority (the server never had a chance to record them).
|
||||
- Strict mode via `requireNoConflicts(outcomes)` — throws `RECONCILIATION_CONFLICT` if any conflict report is present. Used inside integration tests where silent LWW would mask a defect.
|
||||
- `commit(acceptedSeqs)` is the only mutating method; `diff` is pure so it can be re-run safely (e.g. after a server-side rejection).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
LogBuffer,
|
||||
ReconciliationProtocol,
|
||||
} from "@suaveplan/network-plugin-server-bridge";
|
||||
|
||||
const buffer = new LogBuffer({ capacity: 10_000 });
|
||||
const proto = new ReconciliationProtocol({
|
||||
buffer,
|
||||
sessionAttributor: (entry) => extractSession(entry),
|
||||
currentMeshEpoch: () => meshEpochManager.current(),
|
||||
});
|
||||
|
||||
const outcomes = proto.diff(serverWatermark);
|
||||
await gateway.send(serializeOutcomes(outcomes));
|
||||
proto.commit(new Set(outcomes.map((o) => o.entry.seq)));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ReconciliationProtocol } from "@suaveplan/network-plugin-server-bridge";
|
||||
|
||||
const proto = new ReconciliationProtocol({
|
||||
buffer,
|
||||
sessionAttributor: (entry) => routeToSession(entry),
|
||||
currentMeshEpoch: () => meshEpoch,
|
||||
resolveConflict: (report) => {
|
||||
// Custom strategy: server always wins for token-issuance entries,
|
||||
// host wins for everything else.
|
||||
if (report.sessionId.startsWith("token:")) return "server";
|
||||
return report.hostBufferedAt >= report.serverIssuedAt
|
||||
? "host"
|
||||
: "server";
|
||||
},
|
||||
});
|
||||
|
||||
const outcomes = proto.diff(watermark);
|
||||
proto.requireNoConflicts(outcomes); // fail-loud test mode
|
||||
const acked = await server.replay(outcomes);
|
||||
proto.commit(acked);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ReconciliationProtocol(config)` — `buffer`, `sessionAttributor`, `currentMeshEpoch` are required.
|
||||
- `acceptWatermark(watermark): void` — validates the epoch range. Throws `RECONCILIATION_EPOCH_INVALID` outside `[hostEpoch − 1, hostEpoch]`.
|
||||
- `diff(watermark): ReconciliationOutcome[]` — computes the differential. Pure; safe to call repeatedly.
|
||||
- `commit(acceptedSeqs): number` — drops acknowledged entries from the buffer. Returns the count dropped.
|
||||
- `requireNoConflicts(outcomes): void` — strict mode; throws `RECONCILIATION_CONFLICT` if any conflict was applied.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default LWW tiebreak favours the host because the host owns local activity by definition; the server only overrides when its own clock is strictly later. This matches the proposal's "server is authority but host is the source of activity" framing.
|
||||
- `commit` calls `LogBuffer.ackThrough(seq)` per entry rather than scanning the whole set, so the cost is `O(accepted × bufferTail)`. For typical reconnect sizes (≤ a few thousand entries) this is well below the wall-clock cost of the network round-trip.
|
||||
- The protocol is deliberately session-keyed rather than entry-keyed — per-session sequence numbers are what the server tracks at rest, and aligning the diff with that key avoids whole-buffer hash maps on every reconnect.
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LogBuffer } from "../buffer/log-buffer.js";
|
||||
import {
|
||||
NETWORK_SB_CODES,
|
||||
NetworkSbError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { ReconciliationProtocol } from "./reconciliation-protocol.js";
|
||||
import type { ServerWatermark } from "./reconciliation-protocol.types.js";
|
||||
|
||||
const wm = (overrides: Partial<ServerWatermark> = {}): ServerWatermark => ({
|
||||
meshEpoch: 1,
|
||||
sessionSequenceNumbers: {},
|
||||
issuedAt: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupBuffer = (clock = () => 100) => {
|
||||
const buf = new LogBuffer({ capacity: 16, clock });
|
||||
buf.append({ seq: 1, entryHash: "h1", bytes: new Uint8Array([1]) });
|
||||
buf.append({ seq: 2, entryHash: "h2", bytes: new Uint8Array([2]) });
|
||||
buf.append({ seq: 3, entryHash: "h3", bytes: new Uint8Array([3]) });
|
||||
return buf;
|
||||
};
|
||||
|
||||
describe("ReconciliationProtocol", () => {
|
||||
it("acceptWatermark accepts current epoch", () => {
|
||||
const buf = setupBuffer();
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => null,
|
||||
currentMeshEpoch: () => 5,
|
||||
});
|
||||
expect(() => r.acceptWatermark(wm({ meshEpoch: 5 }))).not.toThrow();
|
||||
});
|
||||
|
||||
it("acceptWatermark accepts epoch one behind", () => {
|
||||
const buf = setupBuffer();
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => null,
|
||||
currentMeshEpoch: () => 5,
|
||||
});
|
||||
expect(() => r.acceptWatermark(wm({ meshEpoch: 4 }))).not.toThrow();
|
||||
});
|
||||
|
||||
it("acceptWatermark rejects epoch ahead of host", () => {
|
||||
const buf = setupBuffer();
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => null,
|
||||
currentMeshEpoch: () => 5,
|
||||
});
|
||||
expect(() => r.acceptWatermark(wm({ meshEpoch: 6 }))).toThrow(
|
||||
NetworkSbError
|
||||
);
|
||||
});
|
||||
|
||||
it("acceptWatermark rejects epoch too far behind", () => {
|
||||
const buf = setupBuffer();
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => null,
|
||||
currentMeshEpoch: () => 5,
|
||||
});
|
||||
expect(() => r.acceptWatermark(wm({ meshEpoch: 3 }))).toThrow(
|
||||
NetworkSbError
|
||||
);
|
||||
});
|
||||
|
||||
it("ships every entry as host-priority when attributor returns null", () => {
|
||||
const buf = setupBuffer();
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => null,
|
||||
currentMeshEpoch: () => 1,
|
||||
});
|
||||
const outcomes = r.diff(wm());
|
||||
expect(outcomes).toHaveLength(3);
|
||||
for (const o of outcomes) expect(o.winner).toBe("host");
|
||||
});
|
||||
|
||||
it("treats missing session watermarks as -1 (every entry ships)", () => {
|
||||
const buf = setupBuffer();
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => "unknown-session",
|
||||
currentMeshEpoch: () => 1,
|
||||
});
|
||||
const outcomes = r.diff(wm({ sessionSequenceNumbers: {} }));
|
||||
expect(outcomes).toHaveLength(3);
|
||||
for (const o of outcomes) expect(o.winner).toBe("host");
|
||||
});
|
||||
|
||||
it("ships only entries past the per-session watermark", () => {
|
||||
const buf = setupBuffer();
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => "S1",
|
||||
currentMeshEpoch: () => 1,
|
||||
});
|
||||
const outcomes = r.diff(wm({ sessionSequenceNumbers: { S1: 1 } }));
|
||||
// Entry seq=1 collides with watermark; entries 2,3 are clean
|
||||
expect(outcomes.length).toBeGreaterThanOrEqual(2);
|
||||
expect(outcomes.map((o) => o.entry.seq)).toContain(2);
|
||||
expect(outcomes.map((o) => o.entry.seq)).toContain(3);
|
||||
});
|
||||
|
||||
it("LWW: server wins when watermark is newer", () => {
|
||||
const buf = setupBuffer(() => 50); // host bufferedAt = 50
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => "S",
|
||||
currentMeshEpoch: () => 1,
|
||||
});
|
||||
const outcomes = r.diff(
|
||||
wm({
|
||||
issuedAt: 200,
|
||||
sessionSequenceNumbers: { S: 1 },
|
||||
})
|
||||
);
|
||||
// Entry 1 conflicts; server wins (200 > 50) — entry 1 is dropped
|
||||
expect(outcomes.find((o) => o.entry.seq === 1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("LWW: host wins when host buffered later", () => {
|
||||
const buf = setupBuffer(() => 500); // host newer
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => "S",
|
||||
currentMeshEpoch: () => 1,
|
||||
});
|
||||
const outcomes = r.diff(
|
||||
wm({
|
||||
issuedAt: 200,
|
||||
sessionSequenceNumbers: { S: 1 },
|
||||
})
|
||||
);
|
||||
const seq1 = outcomes.find((o) => o.entry.seq === 1);
|
||||
expect(seq1?.winner).toBe("host");
|
||||
expect(seq1?.conflict?.resolution).toBe("lww-host");
|
||||
});
|
||||
|
||||
it("LWW tie defaults to host", () => {
|
||||
const buf = setupBuffer(() => 100); // exactly equal to issuedAt
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => "S",
|
||||
currentMeshEpoch: () => 1,
|
||||
});
|
||||
const outcomes = r.diff(
|
||||
wm({ issuedAt: 100, sessionSequenceNumbers: { S: 1 } })
|
||||
);
|
||||
const seq1 = outcomes.find((o) => o.entry.seq === 1);
|
||||
expect(seq1?.conflict?.resolution).toBe("tie-host");
|
||||
});
|
||||
|
||||
it("custom resolver overrides default", () => {
|
||||
const buf = setupBuffer(() => 500);
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => "S",
|
||||
currentMeshEpoch: () => 1,
|
||||
resolveConflict: () => "server",
|
||||
});
|
||||
const outcomes = r.diff(
|
||||
wm({ issuedAt: 200, sessionSequenceNumbers: { S: 1 } })
|
||||
);
|
||||
// entry 1 was server-wins → dropped
|
||||
expect(outcomes.find((o) => o.entry.seq === 1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("commit drops acknowledged entries from the buffer", () => {
|
||||
const buf = setupBuffer();
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => null,
|
||||
currentMeshEpoch: () => 1,
|
||||
});
|
||||
const dropped = r.commit(new Set([1, 2]));
|
||||
expect(dropped).toBe(2);
|
||||
expect(buf.peekAll().map((e) => e.seq)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("requireNoConflicts throws when conflicts present", () => {
|
||||
const buf = setupBuffer(() => 500);
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => "S",
|
||||
currentMeshEpoch: () => 1,
|
||||
});
|
||||
const outcomes = r.diff(
|
||||
wm({ issuedAt: 200, sessionSequenceNumbers: { S: 1 } })
|
||||
);
|
||||
expect(() => r.requireNoConflicts(outcomes)).toThrow(NetworkSbError);
|
||||
});
|
||||
|
||||
it("requireNoConflicts is a no-op when clean", () => {
|
||||
const buf = setupBuffer();
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => null,
|
||||
currentMeshEpoch: () => 1,
|
||||
});
|
||||
const outcomes = r.diff(wm());
|
||||
expect(() => r.requireNoConflicts(outcomes)).not.toThrow();
|
||||
});
|
||||
|
||||
it("requireNoConflicts surfaces NETWORK_SB_CODES.RECONCILIATION_CONFLICT", () => {
|
||||
const buf = setupBuffer(() => 500);
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => "S",
|
||||
currentMeshEpoch: () => 1,
|
||||
});
|
||||
const outcomes = r.diff(
|
||||
wm({ issuedAt: 200, sessionSequenceNumbers: { S: 1 } })
|
||||
);
|
||||
try {
|
||||
r.requireNoConflicts(outcomes);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(NetworkSbError);
|
||||
expect((err as NetworkSbError).code).toBe(
|
||||
NETWORK_SB_CODES.RECONCILIATION_CONFLICT
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { LogBuffer } from "../buffer/log-buffer.js";
|
||||
import {
|
||||
NETWORK_SB_CODES,
|
||||
NetworkSbError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { BufferedEntry } from "../types/buffer.types.js";
|
||||
import type {
|
||||
ConflictReport,
|
||||
ReconciliationOutcome,
|
||||
ServerWatermark,
|
||||
SessionAttributor,
|
||||
} from "./reconciliation-protocol.types.js";
|
||||
|
||||
export interface ReconciliationProtocolConfig {
|
||||
readonly buffer: LogBuffer;
|
||||
/**
|
||||
* Maps a buffered entry to a session id. Returning `null` ships the
|
||||
* entry as a host-priority diff (no per-session sequence comparison).
|
||||
*/
|
||||
readonly sessionAttributor: SessionAttributor;
|
||||
readonly clock?: () => number;
|
||||
readonly currentMeshEpoch: () => number;
|
||||
/**
|
||||
* Conflict resolver. Default is last-writer-wins keyed by the larger
|
||||
* of `hostBufferedAt` and `serverIssuedAt`; ties resolve in favour of
|
||||
* the host (the proposal explicitly states the host owns local
|
||||
* activity until the server overrides it).
|
||||
*/
|
||||
readonly resolveConflict?: (report: ConflictReport) => "host" | "server";
|
||||
}
|
||||
|
||||
const defaultResolve = (report: ConflictReport): "host" | "server" => {
|
||||
if (report.hostBufferedAt > report.serverIssuedAt) return "host";
|
||||
if (report.serverIssuedAt > report.hostBufferedAt) return "server";
|
||||
return "host";
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconciliation protocol — runs on the Tier-0 host when the server
|
||||
* reconnects. Handshake:
|
||||
*
|
||||
* 1. Server sends its `ServerWatermark` (`meshEpoch`,
|
||||
* `sessionSequenceNumbers`).
|
||||
* 2. Host validates the epoch (must equal the host's current epoch
|
||||
* OR be exactly one behind — anything further apart is a fatal
|
||||
* `RECONCILIATION_EPOCH_INVALID`).
|
||||
* 3. Host computes the differential — every buffered entry whose seq
|
||||
* is strictly greater than the watermark for its session.
|
||||
* 4. Each differential entry is run through the resolver to attach a
|
||||
* conflict report when the server has a competing record.
|
||||
* 5. The output array is the ordered list the host sends upstream.
|
||||
*/
|
||||
export class ReconciliationProtocol {
|
||||
private readonly buffer: LogBuffer;
|
||||
private readonly attributor: SessionAttributor;
|
||||
private readonly currentMeshEpoch: () => number;
|
||||
private readonly resolve: (report: ConflictReport) => "host" | "server";
|
||||
|
||||
public constructor(config: ReconciliationProtocolConfig) {
|
||||
this.buffer = config.buffer;
|
||||
this.attributor = config.sessionAttributor;
|
||||
this.currentMeshEpoch = config.currentMeshEpoch;
|
||||
this.resolve = config.resolveConflict ?? defaultResolve;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the server-supplied watermark. Throws
|
||||
* `NetworkSbError(RECONCILIATION_EPOCH_INVALID)` if the epoch is
|
||||
* unreasonable.
|
||||
*/
|
||||
public acceptWatermark(watermark: ServerWatermark): void {
|
||||
const ours = this.currentMeshEpoch();
|
||||
const drift = ours - watermark.meshEpoch;
|
||||
if (drift < 0 || drift > 1) {
|
||||
throw new NetworkSbError(
|
||||
NETWORK_SB_CODES.RECONCILIATION_EPOCH_INVALID,
|
||||
"sb.reconciliation.epoch_invalid",
|
||||
{
|
||||
context: {
|
||||
hostEpoch: ours,
|
||||
serverEpoch: watermark.meshEpoch,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the ordered diff to ship upstream. Does NOT mutate the
|
||||
* buffer — call `commit` after the server acknowledges the diff.
|
||||
*/
|
||||
public diff(watermark: ServerWatermark): ReconciliationOutcome[] {
|
||||
this.acceptWatermark(watermark);
|
||||
const out: ReconciliationOutcome[] = [];
|
||||
for (const entry of this.buffer.peekAll()) {
|
||||
const sessionId = this.attributor(entry);
|
||||
if (sessionId === null) {
|
||||
out.push({ entry, winner: "host" });
|
||||
continue;
|
||||
}
|
||||
const watermarkSeq =
|
||||
watermark.sessionSequenceNumbers[sessionId] ?? -1;
|
||||
if (entry.seq <= watermarkSeq) {
|
||||
// Server already has this seq — that's a conflict only when
|
||||
// we genuinely ran ahead; if the server's seq dominates we
|
||||
// simply skip our copy.
|
||||
const report = this.buildConflict(entry, sessionId, watermark);
|
||||
const winner = this.resolve(report);
|
||||
if (winner === "host") {
|
||||
out.push({ entry, winner: "host", conflict: report });
|
||||
} else {
|
||||
}
|
||||
} else {
|
||||
out.push({ entry, winner: "host" });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Once the server acknowledges the diff, drop reconciled entries
|
||||
* from the local buffer. `acceptedSeqs` is the set of seqs the
|
||||
* server confirmed.
|
||||
*/
|
||||
public commit(acceptedSeqs: ReadonlySet<number>): number {
|
||||
let dropped = 0;
|
||||
for (const entry of this.buffer.peekAll()) {
|
||||
if (acceptedSeqs.has(entry.seq)) {
|
||||
this.buffer.ackThrough(entry.seq);
|
||||
dropped++;
|
||||
}
|
||||
}
|
||||
return dropped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict resolver — throws `NetworkSbError(RECONCILIATION_CONFLICT)`
|
||||
* when any entry produces a conflict report. Use during integration
|
||||
* tests to fail loud rather than silently picking a winner.
|
||||
*/
|
||||
public requireNoConflicts(
|
||||
outcomes: readonly ReconciliationOutcome[]
|
||||
): void {
|
||||
for (const o of outcomes) {
|
||||
if (o.conflict !== undefined) {
|
||||
throw new NetworkSbError(
|
||||
NETWORK_SB_CODES.RECONCILIATION_CONFLICT,
|
||||
"sb.reconciliation.conflict",
|
||||
{
|
||||
context: {
|
||||
sessionId: o.conflict.sessionId,
|
||||
seq: o.conflict.seq,
|
||||
resolution: o.conflict.resolution,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildConflict(
|
||||
entry: BufferedEntry,
|
||||
sessionId: string,
|
||||
watermark: ServerWatermark
|
||||
): ConflictReport {
|
||||
const winner =
|
||||
entry.bufferedAt > watermark.issuedAt
|
||||
? "lww-host"
|
||||
: entry.bufferedAt < watermark.issuedAt
|
||||
? "lww-server"
|
||||
: "tie-host";
|
||||
return {
|
||||
sessionId,
|
||||
seq: entry.seq,
|
||||
hostBufferedAt: entry.bufferedAt,
|
||||
serverIssuedAt: watermark.issuedAt,
|
||||
resolution: winner,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# reconciliation-protocol.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Companion type contracts for the reconciliation protocol. Defines `MeshSequence` (the abstract `(meshEpoch, sessionSequenceNumbers)` pair), `ServerWatermark` (the `MeshSequence` plus an `issuedAt` instant), `ReconciliationOutcome` (an entry-plus-winner record), `ConflictReport` (the per-conflict diagnostic the resolver receives), and `SessionAttributor` (the `(entry) => sessionId | null` callback the protocol uses to route entries through the per-session watermark check).
|
||||
|
||||
These types form the on-the-wire contract for the reconnect handshake. Both server and host must agree on their shape; the conformance test pins this so a server implementation can validate against this package's types via shared schema generation.
|
||||
|
||||
## Features
|
||||
|
||||
- `ServerWatermark.sessionSequenceNumbers` is `Readonly<Record<string, number>>` — the host treats every key as opaque; the server is the only side that mints session ids.
|
||||
- `ReconciliationOutcome.winner` is a 2-value union (`"host" | "server"`); future tiebreaks like `"merged"` can extend the union without breaking existing consumers.
|
||||
- `ConflictReport.resolution` is a 3-value union (`"lww-server" | "lww-host" | "tie-host"`) so dashboards can break down resolution distribution at a glance.
|
||||
- `SessionAttributor` returning `null` is the explicit opt-out from per-session reconciliation; the entry still ships, but always as host-priority.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ServerWatermark,
|
||||
SessionAttributor,
|
||||
} from "@suaveplan/network-plugin-server-bridge";
|
||||
|
||||
const watermark: ServerWatermark = {
|
||||
meshEpoch: 1,
|
||||
sessionSequenceNumbers: { "session-A": 42, "session-B": 17 },
|
||||
issuedAt: Date.now(),
|
||||
};
|
||||
|
||||
const attributor: SessionAttributor = (entry) => {
|
||||
const view = new DataView(entry.bytes.buffer);
|
||||
return view.getUint32(0).toString(16);
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ConflictReport,
|
||||
ReconciliationOutcome,
|
||||
} from "@suaveplan/network-plugin-server-bridge";
|
||||
|
||||
function summarize(outcomes: readonly ReconciliationOutcome[]): {
|
||||
hostWins: number;
|
||||
serverWins: number;
|
||||
conflicts: ConflictReport[];
|
||||
} {
|
||||
let hostWins = 0;
|
||||
let serverWins = 0;
|
||||
const conflicts: ConflictReport[] = [];
|
||||
for (const o of outcomes) {
|
||||
if (o.winner === "host") hostWins++;
|
||||
else serverWins++;
|
||||
if (o.conflict !== undefined) conflicts.push(o.conflict);
|
||||
}
|
||||
return { hostWins, serverWins, conflicts };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MeshSequence` — `{ meshEpoch: number; sessionSequenceNumbers: Readonly<Record<string, number>> }`.
|
||||
- `ServerWatermark` — `MeshSequence & { issuedAt: number }`.
|
||||
- `ReconciliationOutcome` — `{ entry: BufferedEntry; winner: "host" | "server"; conflict?: ConflictReport }`.
|
||||
- `ConflictReport` — `{ sessionId; seq; hostBufferedAt; serverIssuedAt; resolution }`.
|
||||
- `SessionAttributor` — `(entry: BufferedEntry) => string | null`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Every member is `readonly` to discourage accidental mutation by handlers downstream of `diff()`. The protocol itself emits fresh objects per call so handlers are free to share references.
|
||||
- The 64-bit `meshEpoch` is intentionally `number` (not `bigint`) — JavaScript's `Number.MAX_SAFE_INTEGER` is `2^53 − 1`, far above the realistic upper bound for mesh-epoch counters (one increment per mesh restart, ~9 quadrillion increments before precision loss).
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { BufferedEntry } from "../types/buffer.types.js";
|
||||
|
||||
export interface MeshSequence {
|
||||
readonly meshEpoch: number;
|
||||
readonly sessionSequenceNumbers: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watermark the server reports during the reconnect handshake. The host
|
||||
* replays every buffered entry whose `seq` is strictly greater than the
|
||||
* matching `sessionSequenceNumbers[sessionId]` (or every entry if the
|
||||
* server has no record for that session).
|
||||
*/
|
||||
export interface ServerWatermark extends MeshSequence {
|
||||
/** Wall-clock instant the watermark was issued (ms since epoch). */
|
||||
readonly issuedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single reconciled entry. Carries the original `BufferedEntry`,
|
||||
* the resolved `winner` (in case of conflict), and a `conflict?: ConflictReport`
|
||||
* when the protocol had to apply a tiebreak rule.
|
||||
*/
|
||||
export interface ReconciliationOutcome {
|
||||
readonly entry: BufferedEntry;
|
||||
readonly winner: "host" | "server";
|
||||
readonly conflict?: ConflictReport;
|
||||
}
|
||||
|
||||
export interface ConflictReport {
|
||||
readonly sessionId: string;
|
||||
readonly seq: number;
|
||||
readonly hostBufferedAt: number;
|
||||
readonly serverIssuedAt: number;
|
||||
readonly resolution: "lww-server" | "lww-host" | "tie-host";
|
||||
}
|
||||
|
||||
/**
|
||||
* Function the host calls to attribute a buffered entry to a session.
|
||||
* Returning `null` excludes the entry from session-keyed reconciliation
|
||||
* (it will still ship as a host-priority entry).
|
||||
*/
|
||||
export type SessionAttributor = (entry: BufferedEntry) => string | null;
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* NET-33 — log replay and reconciliation on reconnection.
|
||||
*
|
||||
* Pins: epoch sync, watermark validation, conflict detection.
|
||||
*/
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LogBuffer } from "../buffer/log-buffer.js";
|
||||
import { NETWORK_SB_CODES } from "../error-codes/error-codes.js";
|
||||
import { ReconciliationProtocol } from "./reconciliation-protocol.js";
|
||||
|
||||
describe("NET-33 — reconciliation", () => {
|
||||
it("validates server epoch", () => {
|
||||
const buf = new LogBuffer({ capacity: 4 });
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => null,
|
||||
currentMeshEpoch: () => 5,
|
||||
});
|
||||
try {
|
||||
r.acceptWatermark({
|
||||
meshEpoch: 99,
|
||||
sessionSequenceNumbers: {},
|
||||
issuedAt: 1,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as { code: string }).code).toBe(
|
||||
NETWORK_SB_CODES.RECONCILIATION_EPOCH_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("ships only entries past the per-session watermark", () => {
|
||||
const buf = new LogBuffer({ capacity: 8 });
|
||||
for (const seq of [1, 2, 3, 4]) {
|
||||
buf.append({
|
||||
seq,
|
||||
entryHash: `h${seq}`,
|
||||
bytes: new Uint8Array([seq]),
|
||||
});
|
||||
}
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => "S",
|
||||
currentMeshEpoch: () => 1,
|
||||
});
|
||||
const out = r.diff({
|
||||
meshEpoch: 1,
|
||||
sessionSequenceNumbers: { S: 2 },
|
||||
issuedAt: 0,
|
||||
});
|
||||
const seqs = out.map((o) => o.entry.seq);
|
||||
expect(seqs).toContain(3);
|
||||
expect(seqs).toContain(4);
|
||||
});
|
||||
|
||||
it("flags conflicts when the server holds the same seq", () => {
|
||||
const buf = new LogBuffer({ capacity: 8, clock: () => 50 });
|
||||
buf.append({ seq: 1, entryHash: "h1", bytes: new Uint8Array([1]) });
|
||||
const r = new ReconciliationProtocol({
|
||||
buffer: buf,
|
||||
sessionAttributor: () => "S",
|
||||
currentMeshEpoch: () => 1,
|
||||
});
|
||||
const out = r.diff({
|
||||
meshEpoch: 1,
|
||||
sessionSequenceNumbers: { S: 1 },
|
||||
issuedAt: 100,
|
||||
});
|
||||
// server-wins (issuedAt > bufferedAt) so the entry drops
|
||||
expect(out).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user