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
226 lines
6.6 KiB
TypeScript
226 lines
6.6 KiB
TypeScript
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;
|
|
}
|