[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
225
domain/convergentSync/register.ts
Normal file
225
domain/convergentSync/register.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
compareDots,
|
||||
compareHybridLogicalClocks,
|
||||
compareStrings,
|
||||
dotKey,
|
||||
} from './clock';
|
||||
import { canonicalJsonString, cloneJson } from './json';
|
||||
import {
|
||||
ConvergentSyncInvariantError,
|
||||
type Dot,
|
||||
type HybridLogicalClock,
|
||||
type JsonValue,
|
||||
type MultiValueRegister,
|
||||
type RegisterCandidate,
|
||||
} from './types';
|
||||
|
||||
export function isTombstoneCandidate(
|
||||
candidate: RegisterCandidate,
|
||||
): candidate is Extract<RegisterCandidate, { tombstone: true }> {
|
||||
return candidate.tombstone === true;
|
||||
}
|
||||
|
||||
export function cloneCandidate<T extends JsonValue>(
|
||||
candidate: RegisterCandidate<T>,
|
||||
): RegisterCandidate<T> {
|
||||
const base = {
|
||||
dot: {
|
||||
deviceId: candidate.dot.deviceId,
|
||||
counter: candidate.dot.counter,
|
||||
},
|
||||
context: candidate.context.map((dot) => ({
|
||||
deviceId: dot.deviceId,
|
||||
counter: dot.counter,
|
||||
})),
|
||||
hlc: {
|
||||
wallTime: candidate.hlc.wallTime,
|
||||
logical: candidate.hlc.logical,
|
||||
},
|
||||
};
|
||||
if (isTombstoneCandidate(candidate)) {
|
||||
return { ...base, tombstone: true };
|
||||
}
|
||||
return { ...base, value: cloneJson(candidate.value) };
|
||||
}
|
||||
|
||||
export function createRegisterCandidate<T extends JsonValue>(options: {
|
||||
dot: Dot;
|
||||
context: Dot[];
|
||||
hlc: HybridLogicalClock;
|
||||
value?: T;
|
||||
tombstone?: boolean;
|
||||
}): RegisterCandidate<T> {
|
||||
const base = {
|
||||
dot: {
|
||||
deviceId: options.dot.deviceId,
|
||||
counter: options.dot.counter,
|
||||
},
|
||||
context: options.context.map((dot) => ({
|
||||
deviceId: dot.deviceId,
|
||||
counter: dot.counter,
|
||||
})),
|
||||
hlc: {
|
||||
wallTime: options.hlc.wallTime,
|
||||
logical: options.hlc.logical,
|
||||
},
|
||||
};
|
||||
if (options.tombstone) {
|
||||
if (options.value !== undefined) {
|
||||
throw new ConvergentSyncInvariantError('A tombstone candidate cannot contain a value');
|
||||
}
|
||||
return { ...base, tombstone: true };
|
||||
}
|
||||
if (options.value === undefined) {
|
||||
throw new ConvergentSyncInvariantError('A non-tombstone candidate requires a value');
|
||||
}
|
||||
return { ...base, value: cloneJson(options.value) };
|
||||
}
|
||||
|
||||
function canonicalContext(context: Dot[]): string {
|
||||
return JSON.stringify(
|
||||
context
|
||||
.map((dot) => ({ deviceId: dot.deviceId, counter: dot.counter }))
|
||||
.sort(compareDots),
|
||||
);
|
||||
}
|
||||
|
||||
function candidateFingerprint(candidate: RegisterCandidate): string {
|
||||
return JSON.stringify({
|
||||
dot: {
|
||||
deviceId: candidate.dot.deviceId,
|
||||
counter: candidate.dot.counter,
|
||||
},
|
||||
context: canonicalContext(candidate.context),
|
||||
hlc: {
|
||||
wallTime: candidate.hlc.wallTime,
|
||||
logical: candidate.hlc.logical,
|
||||
},
|
||||
tombstone: isTombstoneCandidate(candidate),
|
||||
value: isTombstoneCandidate(candidate)
|
||||
? undefined
|
||||
: canonicalJsonString(candidate.value),
|
||||
});
|
||||
}
|
||||
|
||||
function assertEquivalentCandidates(
|
||||
left: RegisterCandidate,
|
||||
right: RegisterCandidate,
|
||||
): void {
|
||||
if (candidateFingerprint(left) !== candidateFingerprint(right)) {
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`Dot ${dotKey(left.dot)} has conflicting candidate payloads`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function candidateDominates(
|
||||
winner: RegisterCandidate,
|
||||
candidate: RegisterCandidate,
|
||||
): boolean {
|
||||
return winner.context.some((dot) => dotKey(dot) === dotKey(candidate.dot));
|
||||
}
|
||||
|
||||
export function registerCausalContext(
|
||||
register: MultiValueRegister | undefined,
|
||||
): Dot[] {
|
||||
const context = new Map<string, Dot>();
|
||||
const observe = (dot: Dot) => {
|
||||
context.set(dotKey(dot), {
|
||||
deviceId: dot.deviceId,
|
||||
counter: dot.counter,
|
||||
});
|
||||
};
|
||||
for (const candidate of register?.candidates ?? []) {
|
||||
candidate.context.forEach(observe);
|
||||
observe(candidate.dot);
|
||||
}
|
||||
return [...context.values()].sort(compareDots);
|
||||
}
|
||||
|
||||
export function compareRegisterCandidates(
|
||||
left: RegisterCandidate,
|
||||
right: RegisterCandidate,
|
||||
): number {
|
||||
const leftTombstone = isTombstoneCandidate(left);
|
||||
const rightTombstone = isTombstoneCandidate(right);
|
||||
if (leftTombstone !== rightTombstone) return leftTombstone ? -1 : 1;
|
||||
|
||||
const clockOrder = compareHybridLogicalClocks(left.hlc, right.hlc);
|
||||
if (clockOrder !== 0) return clockOrder;
|
||||
|
||||
const deviceOrder = compareStrings(left.dot.deviceId, right.dot.deviceId);
|
||||
if (deviceOrder !== 0) return deviceOrder;
|
||||
return left.dot.counter - right.dot.counter;
|
||||
}
|
||||
|
||||
export function compareCandidatesByDot(
|
||||
left: RegisterCandidate,
|
||||
right: RegisterCandidate,
|
||||
): number {
|
||||
return compareDots(left.dot, right.dot);
|
||||
}
|
||||
|
||||
export function selectRegisterWinner<T extends JsonValue>(
|
||||
register: MultiValueRegister<T> | undefined,
|
||||
): RegisterCandidate<T> | undefined {
|
||||
if (!register || register.candidates.length === 0) return undefined;
|
||||
return register.candidates.reduce((winner, candidate) =>
|
||||
compareRegisterCandidates(candidate, winner) > 0 ? candidate : winner,
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeMultiValueRegisters<T extends JsonValue>(
|
||||
left: MultiValueRegister<T> | undefined,
|
||||
right: MultiValueRegister<T> | undefined,
|
||||
): MultiValueRegister<T> | undefined {
|
||||
const leftCandidates = left?.candidates ?? [];
|
||||
const rightCandidates = right?.candidates ?? [];
|
||||
const leftContext = registerCausalContext(left);
|
||||
const rightContext = registerCausalContext(right);
|
||||
const leftByDot = new Map(leftCandidates.map((candidate) => [dotKey(candidate.dot), candidate]));
|
||||
const rightByDot = new Map(rightCandidates.map((candidate) => [dotKey(candidate.dot), candidate]));
|
||||
const candidates: RegisterCandidate<T>[] = [];
|
||||
|
||||
for (const key of new Set([...leftByDot.keys(), ...rightByDot.keys()])) {
|
||||
const leftCandidate = leftByDot.get(key);
|
||||
const rightCandidate = rightByDot.get(key);
|
||||
|
||||
if (leftCandidate && rightCandidate) {
|
||||
assertEquivalentCandidates(leftCandidate, rightCandidate);
|
||||
candidates.push(cloneCandidate(leftCandidate));
|
||||
} else if (
|
||||
leftCandidate
|
||||
&& !rightContext.some((dot) => dotKey(dot) === dotKey(leftCandidate.dot))
|
||||
) {
|
||||
candidates.push(cloneCandidate(leftCandidate));
|
||||
} else if (
|
||||
rightCandidate
|
||||
&& !leftContext.some((dot) => dotKey(dot) === dotKey(rightCandidate.dot))
|
||||
) {
|
||||
candidates.push(cloneCandidate(rightCandidate));
|
||||
}
|
||||
}
|
||||
|
||||
const maximal = candidates.filter((candidate, index) =>
|
||||
!candidates.some((other, otherIndex) =>
|
||||
index !== otherIndex && candidateDominates(other, candidate),
|
||||
),
|
||||
);
|
||||
|
||||
if (maximal.length === 0) return undefined;
|
||||
maximal.sort(compareCandidatesByDot);
|
||||
return { candidates: maximal };
|
||||
}
|
||||
|
||||
export function registerHasConflict(register: MultiValueRegister): boolean {
|
||||
if (register.candidates.length < 2) return false;
|
||||
const distinctValues = new Set(
|
||||
register.candidates.map((candidate) =>
|
||||
isTombstoneCandidate(candidate)
|
||||
? '<tombstone>'
|
||||
: canonicalJsonString(candidate.value),
|
||||
),
|
||||
);
|
||||
return distinctValues.size > 1;
|
||||
}
|
||||
Reference in New Issue
Block a user