[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:
555
domain/convergentSync/serialization.ts
Normal file
555
domain/convergentSync/serialization.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import {
|
||||
compareCandidatesByDot,
|
||||
isTombstoneCandidate,
|
||||
} from './register';
|
||||
import { compareDots, compareHybridLogicalClocks, dotKey } from './clock';
|
||||
import { canonicalizeJson, cloneJson, isJsonValue } from './json';
|
||||
import { getOwnRecordValue } from './record';
|
||||
import { registerId } from './registerId';
|
||||
import {
|
||||
ConvergentSyncInvariantError,
|
||||
type ConvergentCollectionState,
|
||||
type ConvergentEntityState,
|
||||
type ConvergentStringCollectionState,
|
||||
type ConvergentStringEntryState,
|
||||
type ConvergentSyncStateV2,
|
||||
type Dot,
|
||||
type DotOriginIndex,
|
||||
type JsonValue,
|
||||
type MultiValueRegister,
|
||||
type RegisterCandidate,
|
||||
type VersionVector,
|
||||
} from './types';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function assertNonNegativeInteger(value: unknown, label: string): asserts value is number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new ConvergentSyncInvariantError(`${label} must be a non-negative integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveInteger(value: unknown, label: string): asserts value is number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) <= 0) {
|
||||
throw new ConvergentSyncInvariantError(`${label} must be a positive integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonEmptyKey(value: string, label: string): void {
|
||||
if (value.length === 0) {
|
||||
throw new ConvergentSyncInvariantError(`${label} must not be empty`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertVersionVector(value: unknown, label: string): asserts value is VersionVector {
|
||||
if (!isRecord(value)) {
|
||||
throw new ConvergentSyncInvariantError(`${label} must be an object`);
|
||||
}
|
||||
for (const [deviceId, counter] of Object.entries(value)) {
|
||||
assertNonEmptyKey(deviceId, `${label} device ID`);
|
||||
assertPositiveInteger(counter, `${label}.${deviceId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertDotOrigins(
|
||||
value: unknown,
|
||||
vector: VersionVector,
|
||||
): asserts value is DotOriginIndex {
|
||||
if (!isRecord(value)) {
|
||||
throw new ConvergentSyncInvariantError('dotOrigins must be an object');
|
||||
}
|
||||
for (const [deviceId, origins] of Object.entries(value)) {
|
||||
assertNonEmptyKey(deviceId, 'dotOrigins device ID');
|
||||
if (!isRecord(origins)) {
|
||||
throw new ConvergentSyncInvariantError(`dotOrigins.${deviceId} must be an object`);
|
||||
}
|
||||
const vectorCounter = getOwnRecordValue(vector, deviceId);
|
||||
if (!vectorCounter || Object.keys(origins).length !== vectorCounter) {
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`dotOrigins.${deviceId} must cover every counter in the state vector`,
|
||||
);
|
||||
}
|
||||
for (let counter = 1; counter <= vectorCounter; counter += 1) {
|
||||
const origin = getOwnRecordValue(origins, String(counter));
|
||||
if (typeof origin !== 'string' || origin.length === 0) {
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`dotOrigins.${deviceId}.${counter} must contain a register identity`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const deviceId of Object.keys(vector)) {
|
||||
if (!Object.hasOwn(value, deviceId)) {
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`dotOrigins.${deviceId} must cover every counter in the state vector`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertClock(value: unknown, label: string): void {
|
||||
if (!isRecord(value)) {
|
||||
throw new ConvergentSyncInvariantError(`${label} must be an object`);
|
||||
}
|
||||
assertNonNegativeInteger(value.wallTime, `${label}.wallTime`);
|
||||
assertNonNegativeInteger(value.logical, `${label}.logical`);
|
||||
}
|
||||
|
||||
function assertCoveredDot(
|
||||
value: unknown,
|
||||
state: ConvergentSyncStateV2,
|
||||
label: string,
|
||||
): asserts value is Dot {
|
||||
if (!isRecord(value)) {
|
||||
throw new ConvergentSyncInvariantError(`${label} must be a dot`);
|
||||
}
|
||||
if (typeof value.deviceId !== 'string' || value.deviceId.length === 0) {
|
||||
throw new ConvergentSyncInvariantError(`${label}.deviceId must not be empty`);
|
||||
}
|
||||
assertPositiveInteger(value.counter, `${label}.counter`);
|
||||
if ((getOwnRecordValue(state.vector, value.deviceId) ?? 0) < value.counter) {
|
||||
throw new ConvergentSyncInvariantError(`${label} is not covered by the state vector`);
|
||||
}
|
||||
}
|
||||
|
||||
function recordVectorWitness(
|
||||
witnessedDots: Map<string, Set<number>>,
|
||||
dot: Dot,
|
||||
): void {
|
||||
const counters = witnessedDots.get(dot.deviceId) ?? new Set<number>();
|
||||
counters.add(dot.counter);
|
||||
witnessedDots.set(dot.deviceId, counters);
|
||||
}
|
||||
|
||||
interface DotLocation {
|
||||
candidateLabel: string;
|
||||
registerIdentity: string;
|
||||
}
|
||||
|
||||
interface ContextReference {
|
||||
key: string;
|
||||
label: string;
|
||||
registerIdentity: string;
|
||||
}
|
||||
|
||||
function assertDotOrigin(
|
||||
state: ConvergentSyncStateV2,
|
||||
dot: Dot,
|
||||
expectedRegisterId: string,
|
||||
label: string,
|
||||
): void {
|
||||
const deviceOrigins = getOwnRecordValue(state.dotOrigins, dot.deviceId);
|
||||
const origin = deviceOrigins
|
||||
? getOwnRecordValue(deviceOrigins, String(dot.counter))
|
||||
: undefined;
|
||||
if (origin !== expectedRegisterId) {
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`${label} is assigned to a different register origin`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertVectorIsExactlyWitnessed(
|
||||
vector: VersionVector,
|
||||
witnessedDots: Map<string, Set<number>>,
|
||||
): void {
|
||||
for (const [deviceId, counter] of Object.entries(vector)) {
|
||||
const counters = witnessedDots.get(deviceId);
|
||||
if (!counters || counters.size !== counter || !counters.has(counter)) {
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`vector.${deviceId} is not witnessed by retained candidate dots and contexts`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertCandidate(
|
||||
value: unknown,
|
||||
state: ConvergentSyncStateV2,
|
||||
label: string,
|
||||
registerIdentity: string,
|
||||
globalDots: Map<string, DotLocation>,
|
||||
witnessedDots: Map<string, Set<number>>,
|
||||
contextReferences: ContextReference[],
|
||||
): asserts value is RegisterCandidate {
|
||||
if (!isRecord(value)) {
|
||||
throw new ConvergentSyncInvariantError(`${label} must contain a dot`);
|
||||
}
|
||||
const candidateDot = value.dot;
|
||||
assertCoveredDot(candidateDot, state, `${label}.dot`);
|
||||
assertDotOrigin(state, candidateDot, registerIdentity, `${label}.dot`);
|
||||
if (!Array.isArray(value.context)) {
|
||||
throw new ConvergentSyncInvariantError(`${label}.context must be an array of dots`);
|
||||
}
|
||||
const contextKeys = new Set<string>();
|
||||
value.context.forEach((contextDot, index) => {
|
||||
const contextLabel = `${label}.context[${index}]`;
|
||||
assertCoveredDot(contextDot, state, contextLabel);
|
||||
assertDotOrigin(state, contextDot, registerIdentity, contextLabel);
|
||||
const contextKey = dotKey(contextDot);
|
||||
if (contextKeys.has(contextKey)) {
|
||||
throw new ConvergentSyncInvariantError(`${label}.context contains duplicate dot ${contextKey}`);
|
||||
}
|
||||
if (contextKey === dotKey(candidateDot)) {
|
||||
throw new ConvergentSyncInvariantError(`${label}.context must not contain its own dot`);
|
||||
}
|
||||
if (
|
||||
contextDot.deviceId === candidateDot.deviceId
|
||||
&& contextDot.counter >= candidateDot.counter
|
||||
) {
|
||||
throw new ConvergentSyncInvariantError(`${contextLabel} must precede its own device dot`);
|
||||
}
|
||||
contextKeys.add(contextKey);
|
||||
contextReferences.push({ key: contextKey, label: contextLabel, registerIdentity });
|
||||
recordVectorWitness(witnessedDots, contextDot);
|
||||
});
|
||||
|
||||
const deviceId = candidateDot.deviceId;
|
||||
|
||||
assertClock(value.hlc, `${label}.hlc`);
|
||||
const candidateClock = value.hlc as { wallTime: number; logical: number };
|
||||
if (compareHybridLogicalClocks(candidateClock, state.hlc) > 0) {
|
||||
throw new ConvergentSyncInvariantError(`${label}.hlc exceeds the state clock`);
|
||||
}
|
||||
|
||||
if (
|
||||
value.tombstone !== undefined
|
||||
&& value.tombstone !== false
|
||||
&& value.tombstone !== true
|
||||
) {
|
||||
throw new ConvergentSyncInvariantError(`${label}.tombstone must be a boolean`);
|
||||
}
|
||||
const tombstone = value.tombstone === true;
|
||||
if (!tombstone && !isJsonValue(value.value)) {
|
||||
throw new ConvergentSyncInvariantError(`${label}.value must be valid JSON`);
|
||||
}
|
||||
if (tombstone && Object.prototype.hasOwnProperty.call(value, 'value')) {
|
||||
throw new ConvergentSyncInvariantError(`${label} tombstones must not contain a value`);
|
||||
}
|
||||
|
||||
const key = dotKey({ deviceId, counter: candidateDot.counter });
|
||||
const previousLocation = globalDots.get(key);
|
||||
if (previousLocation) {
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`Dot ${key} is reused by ${previousLocation.candidateLabel} and ${label}`,
|
||||
);
|
||||
}
|
||||
globalDots.set(key, { candidateLabel: label, registerIdentity });
|
||||
recordVectorWitness(witnessedDots, candidateDot);
|
||||
}
|
||||
|
||||
function assertRegister(
|
||||
value: unknown,
|
||||
state: ConvergentSyncStateV2,
|
||||
label: string,
|
||||
registerIdentity: string,
|
||||
globalDots: Map<string, DotLocation>,
|
||||
witnessedDots: Map<string, Set<number>>,
|
||||
contextReferences: ContextReference[],
|
||||
valueValidator?: (candidate: RegisterCandidate, label: string) => void,
|
||||
): asserts value is MultiValueRegister {
|
||||
if (!isRecord(value) || !Array.isArray(value.candidates) || value.candidates.length === 0) {
|
||||
throw new ConvergentSyncInvariantError(`${label} must contain at least one candidate`);
|
||||
}
|
||||
value.candidates.forEach((candidate, index) => {
|
||||
const candidateLabel = `${label}.candidates[${index}]`;
|
||||
assertCandidate(
|
||||
candidate,
|
||||
state,
|
||||
candidateLabel,
|
||||
registerIdentity,
|
||||
globalDots,
|
||||
witnessedDots,
|
||||
contextReferences,
|
||||
);
|
||||
valueValidator?.(candidate, candidateLabel);
|
||||
});
|
||||
}
|
||||
|
||||
function assertPresenceCandidate(candidate: RegisterCandidate, label: string): void {
|
||||
if (!isTombstoneCandidate(candidate) && candidate.value !== true) {
|
||||
throw new ConvergentSyncInvariantError(`${label} presence values must be true`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositionCandidate(candidate: RegisterCandidate, label: string): void {
|
||||
if (
|
||||
!isTombstoneCandidate(candidate)
|
||||
&& typeof candidate.value !== 'string'
|
||||
&& typeof candidate.value !== 'number'
|
||||
) {
|
||||
throw new ConvergentSyncInvariantError(`${label} position must be a string or number`);
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeSettingPath(path: string[]): string {
|
||||
if (path.length === 0 || path.some((segment) => segment.length === 0)) {
|
||||
throw new ConvergentSyncInvariantError('Setting paths require non-empty segments');
|
||||
}
|
||||
return `/${path.map((segment) => segment.replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}`;
|
||||
}
|
||||
|
||||
export function decodeSettingPath(encoded: string): string[] {
|
||||
if (!encoded.startsWith('/') || encoded.length === 1) {
|
||||
throw new ConvergentSyncInvariantError(`Invalid encoded setting path: ${encoded}`);
|
||||
}
|
||||
const path = encoded.slice(1).split('/').map((segment) =>
|
||||
segment.replaceAll('~1', '/').replaceAll('~0', '~'),
|
||||
);
|
||||
if (encodeSettingPath(path) !== encoded) {
|
||||
throw new ConvergentSyncInvariantError(`Non-canonical setting path: ${encoded}`);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function assertValidConvergentSyncState(
|
||||
value: unknown,
|
||||
): asserts value is ConvergentSyncStateV2 {
|
||||
if (!isRecord(value) || value.schemaVersion !== 2) {
|
||||
throw new ConvergentSyncInvariantError('Expected convergent sync schema version 2');
|
||||
}
|
||||
assertVersionVector(value.vector, 'vector');
|
||||
assertDotOrigins(value.dotOrigins, value.vector);
|
||||
assertClock(value.hlc, 'hlc');
|
||||
if (!isRecord(value.collections) || !isRecord(value.settings) || !isRecord(value.stringCollections)) {
|
||||
throw new ConvergentSyncInvariantError('Collections, settings, and stringCollections must be objects');
|
||||
}
|
||||
|
||||
const state = value as unknown as ConvergentSyncStateV2;
|
||||
const globalDots = new Map<string, DotLocation>();
|
||||
const witnessedDots = new Map<string, Set<number>>();
|
||||
const contextReferences: ContextReference[] = [];
|
||||
for (const [collectionName, collection] of Object.entries(state.collections)) {
|
||||
assertNonEmptyKey(collectionName, 'Collection name');
|
||||
if (!isRecord(collection) || !isRecord(collection.entities)) {
|
||||
throw new ConvergentSyncInvariantError(`Collection ${collectionName} must contain entities`);
|
||||
}
|
||||
for (const [entityId, entity] of Object.entries(collection.entities)) {
|
||||
assertNonEmptyKey(entityId, `Entity ID in ${collectionName}`);
|
||||
if (!isRecord(entity) || !isRecord(entity.fields)) {
|
||||
throw new ConvergentSyncInvariantError(`Entity ${collectionName}/${entityId} is invalid`);
|
||||
}
|
||||
const entityLabel = `collections.${collectionName}.${entityId}`;
|
||||
assertRegister(
|
||||
entity.presence,
|
||||
state,
|
||||
`${entityLabel}.presence`,
|
||||
registerId({ kind: 'entity-presence', collection: collectionName, entityId }),
|
||||
globalDots,
|
||||
witnessedDots,
|
||||
contextReferences,
|
||||
assertPresenceCandidate,
|
||||
);
|
||||
if (entity.position !== undefined) {
|
||||
assertRegister(
|
||||
entity.position,
|
||||
state,
|
||||
`${entityLabel}.position`,
|
||||
registerId({ kind: 'entity-position', collection: collectionName, entityId }),
|
||||
globalDots,
|
||||
witnessedDots,
|
||||
contextReferences,
|
||||
assertPositionCandidate,
|
||||
);
|
||||
}
|
||||
for (const [field, register] of Object.entries(entity.fields)) {
|
||||
assertNonEmptyKey(field, `${entityLabel} field`);
|
||||
if (field === 'id') {
|
||||
throw new ConvergentSyncInvariantError(`${entityLabel} must not store structural ID as a field`);
|
||||
}
|
||||
assertRegister(
|
||||
register,
|
||||
state,
|
||||
`${entityLabel}.fields.${field}`,
|
||||
registerId({ kind: 'entity-field', collection: collectionName, entityId, field }),
|
||||
globalDots,
|
||||
witnessedDots,
|
||||
contextReferences,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [encodedPath, register] of Object.entries(state.settings)) {
|
||||
decodeSettingPath(encodedPath);
|
||||
assertRegister(
|
||||
register,
|
||||
state,
|
||||
`settings.${encodedPath}`,
|
||||
registerId({ kind: 'setting', path: decodeSettingPath(encodedPath) }),
|
||||
globalDots,
|
||||
witnessedDots,
|
||||
contextReferences,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [collectionName, collection] of Object.entries(state.stringCollections)) {
|
||||
assertNonEmptyKey(collectionName, 'String collection name');
|
||||
if (!isRecord(collection) || !isRecord(collection.entries)) {
|
||||
throw new ConvergentSyncInvariantError(`String collection ${collectionName} must contain entries`);
|
||||
}
|
||||
for (const [entryValue, entry] of Object.entries(collection.entries)) {
|
||||
assertNonEmptyKey(entryValue, `Entry value in ${collectionName}`);
|
||||
if (!isRecord(entry)) {
|
||||
throw new ConvergentSyncInvariantError(`String entry ${collectionName}/${entryValue} is invalid`);
|
||||
}
|
||||
const entryLabel = `stringCollections.${collectionName}.${entryValue}`;
|
||||
assertRegister(
|
||||
entry.presence,
|
||||
state,
|
||||
`${entryLabel}.presence`,
|
||||
registerId({
|
||||
kind: 'string-entry-presence',
|
||||
collection: collectionName,
|
||||
value: entryValue,
|
||||
}),
|
||||
globalDots,
|
||||
witnessedDots,
|
||||
contextReferences,
|
||||
assertPresenceCandidate,
|
||||
);
|
||||
if (entry.position !== undefined) {
|
||||
assertRegister(
|
||||
entry.position,
|
||||
state,
|
||||
`${entryLabel}.position`,
|
||||
registerId({
|
||||
kind: 'string-entry-position',
|
||||
collection: collectionName,
|
||||
value: entryValue,
|
||||
}),
|
||||
globalDots,
|
||||
witnessedDots,
|
||||
contextReferences,
|
||||
assertPositionCandidate,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const reference of contextReferences) {
|
||||
const retainedLocation = globalDots.get(reference.key);
|
||||
if (retainedLocation) {
|
||||
const location = retainedLocation.registerIdentity === reference.registerIdentity
|
||||
? 'the same register'
|
||||
: 'another register';
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`${reference.label} references candidate dot ${reference.key} retained in ${location}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assertVectorIsExactlyWitnessed(state.vector, witnessedDots);
|
||||
}
|
||||
|
||||
function sortRecord<T>(record: Record<string, T>, clone: (value: T) => T): Record<string, T> {
|
||||
return Object.fromEntries(
|
||||
Object.keys(record).sort().map((key) => [key, clone(record[key])]),
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalCandidate<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,
|
||||
}))
|
||||
.sort(compareDots),
|
||||
hlc: {
|
||||
wallTime: candidate.hlc.wallTime,
|
||||
logical: candidate.hlc.logical,
|
||||
},
|
||||
};
|
||||
if (isTombstoneCandidate(candidate)) return { ...base, tombstone: true };
|
||||
return { ...base, value: canonicalizeJson(cloneJson(candidate.value)) };
|
||||
}
|
||||
|
||||
function canonicalRegister<T extends JsonValue>(
|
||||
register: MultiValueRegister<T>,
|
||||
): MultiValueRegister<T> {
|
||||
return {
|
||||
candidates: register.candidates
|
||||
.map(canonicalCandidate)
|
||||
.sort(compareCandidatesByDot),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalEntity(entity: ConvergentEntityState): ConvergentEntityState {
|
||||
return {
|
||||
presence: canonicalRegister(entity.presence),
|
||||
...(entity.position ? { position: canonicalRegister(entity.position) } : {}),
|
||||
fields: sortRecord(entity.fields, canonicalRegister),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalCollection(collection: ConvergentCollectionState): ConvergentCollectionState {
|
||||
return { entities: sortRecord(collection.entities, canonicalEntity) };
|
||||
}
|
||||
|
||||
function canonicalStringEntry(entry: ConvergentStringEntryState): ConvergentStringEntryState {
|
||||
return {
|
||||
presence: canonicalRegister(entry.presence),
|
||||
...(entry.position ? { position: canonicalRegister(entry.position) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalStringCollection(
|
||||
collection: ConvergentStringCollectionState,
|
||||
): ConvergentStringCollectionState {
|
||||
return { entries: sortRecord(collection.entries, canonicalStringEntry) };
|
||||
}
|
||||
|
||||
function canonicalDotOrigins(origins: DotOriginIndex): DotOriginIndex {
|
||||
return Object.fromEntries(
|
||||
Object.keys(origins).sort().map((deviceId) => [
|
||||
deviceId,
|
||||
Object.fromEntries(
|
||||
Object.entries(origins[deviceId])
|
||||
.sort(([left], [right]) => Number(left) - Number(right)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function canonicalizeConvergentSyncState(
|
||||
state: ConvergentSyncStateV2,
|
||||
): ConvergentSyncStateV2 {
|
||||
assertValidConvergentSyncState(state);
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
vector: sortRecord(state.vector, (counter) => counter),
|
||||
dotOrigins: canonicalDotOrigins(state.dotOrigins),
|
||||
hlc: {
|
||||
wallTime: state.hlc.wallTime,
|
||||
logical: state.hlc.logical,
|
||||
},
|
||||
collections: sortRecord(state.collections, canonicalCollection),
|
||||
settings: sortRecord(state.settings, canonicalRegister),
|
||||
stringCollections: sortRecord(state.stringCollections, canonicalStringCollection),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeConvergentSyncState(state: ConvergentSyncStateV2): string {
|
||||
return JSON.stringify(canonicalizeConvergentSyncState(state));
|
||||
}
|
||||
|
||||
export function hydrateConvergentSyncState(serialized: string): ConvergentSyncStateV2 {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(serialized) as unknown;
|
||||
} catch (error) {
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`Invalid convergent sync JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
assertValidConvergentSyncState(parsed);
|
||||
return canonicalizeConvergentSyncState(parsed);
|
||||
}
|
||||
Reference in New Issue
Block a user