[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
import type {
Dot,
HybridLogicalClock,
VersionVector,
} from './types';
import { ConvergentSyncInvariantError } from './types';
import { getOwnRecordValue, setOwnRecordValue } from './record';
export function compareStrings(left: string, right: string): number {
if (left < right) return -1;
if (left > right) return 1;
return 0;
}
export function compareDots(left: Dot, right: Dot): number {
const deviceOrder = compareStrings(left.deviceId, right.deviceId);
return deviceOrder !== 0 ? deviceOrder : left.counter - right.counter;
}
export function dotKey(dot: Dot): string {
return `${dot.deviceId}:${dot.counter}`;
}
export function observesDot(vector: VersionVector, dot: Dot): boolean {
return (getOwnRecordValue(vector, dot.deviceId) ?? 0) >= dot.counter;
}
export function mergeVersionVectors(
left: VersionVector,
right: VersionVector,
): VersionVector {
const merged: VersionVector = {};
const deviceIds = new Set([...Object.keys(left), ...Object.keys(right)]);
for (const deviceId of [...deviceIds].sort()) {
const counter = Math.max(
getOwnRecordValue(left, deviceId) ?? 0,
getOwnRecordValue(right, deviceId) ?? 0,
);
if (counter > 0) setOwnRecordValue(merged, deviceId, counter);
}
return merged;
}
/**
* Returns true when `candidate` has observed every write represented by
* `expected`. Extra counters in `candidate` are allowed: they represent a
* remote superset that must be joined and propagated, not a failed write.
*/
export function versionVectorDominates(
candidate: VersionVector,
expected: VersionVector,
): boolean {
return Object.keys(expected).every(
(deviceId) => (getOwnRecordValue(candidate, deviceId) ?? 0)
>= (getOwnRecordValue(expected, deviceId) ?? 0),
);
}
export function versionVectorsEqual(
left: VersionVector,
right: VersionVector,
): boolean {
return versionVectorDominates(left, right) && versionVectorDominates(right, left);
}
export function compareHybridLogicalClocks(
left: HybridLogicalClock,
right: HybridLogicalClock,
): number {
if (left.wallTime !== right.wallTime) return left.wallTime - right.wallTime;
return left.logical - right.logical;
}
export function maxHybridLogicalClock(
left: HybridLogicalClock,
right: HybridLogicalClock,
): HybridLogicalClock {
return compareHybridLogicalClocks(left, right) >= 0
? { ...left }
: { ...right };
}
export function tickHybridLogicalClock(
current: HybridLogicalClock,
now: number,
): HybridLogicalClock {
const safeNow = Number.isFinite(now) ? Math.max(0, Math.floor(now)) : 0;
if (!Number.isSafeInteger(safeNow)) {
throw new ConvergentSyncInvariantError('Hybrid logical clock wall time is out of range');
}
if (safeNow > current.wallTime) {
return { wallTime: safeNow, logical: 0 };
}
if (current.logical >= Number.MAX_SAFE_INTEGER) {
throw new ConvergentSyncInvariantError('Hybrid logical clock counter exhausted');
}
return { wallTime: current.wallTime, logical: current.logical + 1 };
}

View File

@@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { dotKey } from './clock.ts';
import {
isConvergentConflictSecret,
resolveConvergentFieldConflict,
} from './conflicts.ts';
import { createConvergentSyncStateFromPayload } from './payload.ts';
import { applyLegacySyncPayload } from './legacy.ts';
import { materializeConvergentSyncState, mergeConvergentSyncStates } from './state.ts';
import type { SyncPayload } from '../sync.ts';
function payload(label: string): SyncPayload {
return {
hosts: [{ id: 'h', label, hostname: 'example.com', port: 22, username: 'root', tags: [], os: 'linux' }],
keys: [], snippets: [], customGroups: [], syncedAt: 0,
};
}
test('field conflict resolution writes a causal value over every candidate', () => {
const basePayload = payload('base');
const base = createConvergentSyncStateFromPayload(basePayload, 'seed', 1);
const left = applyLegacySyncPayload(base, basePayload, payload('left'), 'left', 2);
const right = applyLegacySyncPayload(base, basePayload, payload('right'), 'right', 3);
const merged = mergeConvergentSyncStates(left, right);
const conflict = materializeConvergentSyncState(merged).conflicts.find(
(entry) => entry.address.kind === 'entity-field' && entry.address.field === 'label',
)!;
const selected = conflict.candidates.find((candidate) => candidate.value === 'left')!;
const resolved = resolveConvergentFieldConflict(
merged,
conflict,
dotKey(selected.dot),
'resolver',
4,
);
assert.equal(materializeConvergentSyncState(resolved).conflicts.length, 0);
assert.equal(materializeConvergentSyncState(resolved).collections.hosts[0]?.label, 'left');
});
test('secret conflicts are identified from paths and nested candidate keys', () => {
assert.equal(isConvergentConflictSecret({
address: { kind: 'entity-field', collection: 'keys', entityId: 'k', field: 'privateKey' },
candidates: [],
}), true);
assert.equal(isConvergentConflictSecret({
address: { kind: 'entity-field', collection: 'hosts', entityId: 'h', field: 'proxyConfig' },
candidates: [{
dot: { deviceId: 'a', counter: 1 },
hlc: { wallTime: 1, logical: 0 },
tombstone: false,
value: { password: 'do-not-render' },
selected: true,
}],
}), true);
assert.equal(isConvergentConflictSecret({
address: { kind: 'setting', path: ['ai', 'providers'] },
candidates: [{
dot: { deviceId: 'a', counter: 2 },
hlc: { wallTime: 2, logical: 0 },
tombstone: false,
value: [{ id: 'provider-1', credentials: { apiKey: 'nested-do-not-render' } }],
selected: true,
}],
}), true);
});

View File

@@ -0,0 +1,80 @@
import { dotKey } from './clock';
import { applyConvergentMutations } from './state';
import type {
ConvergentConflictAddress,
ConvergentConflictCandidate,
ConvergentFieldConflict,
ConvergentSyncStateV2,
JsonValue,
RegisterAddress,
} from './types';
export function convergentConflictAddressKey(address: ConvergentConflictAddress): string {
switch (address.kind) {
case 'entity-presence':
case 'entity-position':
return JSON.stringify([address.kind, address.collection, address.entityId]);
case 'entity-field':
return JSON.stringify([address.kind, address.collection, address.entityId, address.field]);
case 'setting':
return JSON.stringify([address.kind, ...address.path]);
case 'setting-structure':
return JSON.stringify([address.kind, ...address.paths]);
case 'string-entry-presence':
case 'string-entry-position':
return JSON.stringify([address.kind, address.collection, address.value]);
}
}
function selectedAddress(
conflict: ConvergentFieldConflict,
candidate: ConvergentConflictCandidate,
): RegisterAddress {
if (conflict.address.kind !== 'setting-structure') return conflict.address;
if (!candidate.settingPath?.length) {
throw new Error('A setting-structure candidate must identify its setting path');
}
return { kind: 'setting', path: candidate.settingPath };
}
export function resolveConvergentFieldConflict(
state: ConvergentSyncStateV2,
conflict: ConvergentFieldConflict,
candidateDot: string,
deviceId: string,
now: number,
): ConvergentSyncStateV2 {
const candidate = conflict.candidates.find((entry) => dotKey(entry.dot) === candidateDot);
if (!candidate) throw new Error('The selected convergent conflict candidate no longer exists');
if (!candidate.tombstone && candidate.value === undefined) {
throw new Error('The selected convergent conflict candidate has no value');
}
return applyConvergentMutations(state, deviceId, [{
kind: 'resolve-register',
address: selectedAddress(conflict, candidate),
...(candidate.tombstone ? { tombstone: true } : { value: candidate.value }),
}], now);
}
const SECRET_SEGMENT = /(?:password|passphrase|privatekey|secret|token|api[_-]?key|access[_-]?key)/i;
function valueContainsSecretField(value: JsonValue | undefined): boolean {
if (!value || typeof value !== 'object') return false;
if (Array.isArray(value)) return value.some((nested) => valueContainsSecretField(nested));
return Object.entries(value).some(([key, nested]) =>
SECRET_SEGMENT.test(key) || valueContainsSecretField(nested));
}
/** Values from secret-bearing registers must never be rendered or logged. */
export function isConvergentConflictSecret(conflict: ConvergentFieldConflict): boolean {
const { address } = conflict;
if (address.kind === 'entity-field' && SECRET_SEGMENT.test(address.field)) return true;
if (address.kind === 'setting' && address.path.some((segment) => SECRET_SEGMENT.test(segment))) {
return true;
}
if (
address.kind === 'setting-structure'
&& address.paths.some((path) => path.some((segment) => SECRET_SEGMENT.test(segment)))
) return true;
return conflict.candidates.some((candidate) => valueContainsSecretField(candidate.value));
}

View File

@@ -0,0 +1,211 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import fc from 'fast-check';
import {
applyConvergentMutations,
createConvergentSyncState,
materializeConvergentSyncState,
mergeConvergentSyncStates,
serializeConvergentSyncState,
type ConvergentMutation,
type JsonValue,
} from './index.ts';
const jsonValueArbitrary: fc.Arbitrary<JsonValue> = fc.oneof(
fc.string({ maxLength: 20 }),
fc.integer({ min: -1_000, max: 1_000 }),
fc.boolean(),
fc.array(fc.integer({ min: 0, max: 20 }), { maxLength: 5 }),
fc.record({ enabled: fc.boolean(), label: fc.string({ maxLength: 10 }) }),
);
const settingPathArbitrary: fc.Arbitrary<string[]> = fc.constantFrom(
'theme',
'terminalRoot',
'fontSize',
'palette',
).map((value) => {
if (value === 'theme') return ['theme'];
if (value === 'terminalRoot') return ['terminal'];
return ['terminal', value];
});
const mutationArbitrary: fc.Arbitrary<ConvergentMutation> = fc.oneof(
fc.record({
kind: fc.constant<'setting-set'>('setting-set'),
path: settingPathArbitrary,
value: jsonValueArbitrary,
}),
fc.record({
kind: fc.constant<'setting-delete'>('setting-delete'),
path: settingPathArbitrary,
}),
fc.record({
kind: fc.constant<'entity-field-set'>('entity-field-set'),
collection: fc.constant('hosts'),
entityId: fc.constantFrom('host-0', 'host-1', 'host-2'),
field: fc.constantFrom('label', 'hostname', 'tags'),
value: jsonValueArbitrary,
}),
fc.record({
kind: fc.constant<'entity-delete'>('entity-delete'),
collection: fc.constant('hosts'),
entityId: fc.constantFrom('host-0', 'host-1', 'host-2'),
}),
fc.record({
kind: fc.constant<'string-entry-add'>('string-entry-add'),
collection: fc.constant('customGroups'),
value: fc.constantFrom('alpha', 'beta', 'gamma'),
position: fc.integer({ min: 0, max: 10 }),
}),
fc.record({
kind: fc.constant<'string-entry-delete'>('string-entry-delete'),
collection: fc.constant('customGroups'),
value: fc.constantFrom('alpha', 'beta', 'gamma'),
}),
);
const mutationListArbitrary = fc.array(mutationArbitrary, { maxLength: 16 });
function replica(deviceId: string, mutations: ConvergentMutation[], time: number) {
return applyConvergentMutations(
createConvergentSyncState(),
deviceId,
mutations,
time,
);
}
test('merge is commutative', () => {
fc.assert(fc.property(
mutationListArbitrary,
mutationListArbitrary,
(leftMutations, rightMutations) => {
const left = replica('device-a', leftMutations, 100);
const right = replica('device-b', rightMutations, 100);
const leftRight = mergeConvergentSyncStates(left, right);
const rightLeft = mergeConvergentSyncStates(right, left);
assert.equal(
serializeConvergentSyncState(leftRight),
serializeConvergentSyncState(rightLeft),
);
assert.deepEqual(
materializeConvergentSyncState(leftRight),
materializeConvergentSyncState(rightLeft),
);
},
), { numRuns: 150 });
});
test('merge is associative', () => {
fc.assert(fc.property(
mutationListArbitrary,
mutationListArbitrary,
mutationListArbitrary,
(aMutations, bMutations, cMutations) => {
const a = replica('device-a', aMutations, 100);
const b = replica('device-b', bMutations, 100);
const c = replica('device-c', cMutations, 100);
const leftGrouped = mergeConvergentSyncStates(
mergeConvergentSyncStates(a, b),
c,
);
const rightGrouped = mergeConvergentSyncStates(
a,
mergeConvergentSyncStates(b, c),
);
assert.equal(
serializeConvergentSyncState(leftGrouped),
serializeConvergentSyncState(rightGrouped),
);
},
), { numRuns: 120 });
});
test('merge is idempotent', () => {
fc.assert(fc.property(mutationListArbitrary, (mutations) => {
const state = replica('device-a', mutations, 100);
assert.equal(
serializeConvergentSyncState(mergeConvergentSyncStates(state, state)),
serializeConvergentSyncState(state),
);
}), { numRuns: 150 });
});
test('causal parent deletion removes every generated descendant', () => {
fc.assert(fc.property(
fc.array(
fc.tuple(
fc.constantFrom('fontSize', 'fontFamily', 'palette', 'cursor'),
jsonValueArbitrary,
),
{ minLength: 1, maxLength: 12 },
),
(leaves) => {
const populated = applyConvergentMutations(
createConvergentSyncState(),
'device-a',
leaves.map(([leaf, value]) => ({
kind: 'setting-set' as const,
path: ['terminal', leaf],
value,
})),
100,
);
const deleted = applyConvergentMutations(populated, 'device-a', [{
kind: 'setting-delete',
path: ['terminal'],
}], 101);
assert.equal(
Object.hasOwn(materializeConvergentSyncState(deleted).settings, 'terminal'),
false,
);
assert.equal(
Object.hasOwn(
materializeConvergentSyncState(
mergeConvergentSyncStates(populated, deleted),
).settings,
'terminal',
),
false,
);
},
), { numRuns: 100 });
});
test('2-20 offline replicas converge across reordering, partitions, and duplicates', () => {
fc.assert(fc.property(
fc.array(mutationListArbitrary, { minLength: 2, maxLength: 20 }),
fc.array(fc.integer(), { minLength: 20, maxLength: 20 }),
(replicaMutations, orderKeys) => {
const replicas = replicaMutations.map((mutations, index) =>
replica(`device-${index.toString().padStart(2, '0')}`, mutations, 100 + index),
);
const baseline = replicas.reduce(mergeConvergentSyncStates);
const order = replicas
.map((state, index) => ({ state, key: orderKeys[index] ?? index }))
.sort((left, right) => left.key - right.key)
.map((item) => item.state);
const reordered = order.reduce(mergeConvergentSyncStates);
const split = Math.max(1, Math.floor(order.length / 2));
const leftPartition = order.slice(0, split).reduce(mergeConvergentSyncStates);
const rightPartition = order.slice(split).reduce(mergeConvergentSyncStates);
const partitioned = mergeConvergentSyncStates(
mergeConvergentSyncStates(leftPartition, leftPartition),
mergeConvergentSyncStates(rightPartition, rightPartition),
);
assert.equal(
serializeConvergentSyncState(reordered),
serializeConvergentSyncState(baseline),
);
assert.equal(
serializeConvergentSyncState(partitioned),
serializeConvergentSyncState(baseline),
);
},
), { numRuns: 60 });
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
export * from './clock';
export * from './conflicts';
export * from './json';
export * from './legacy';
export * from './migration';
export * from './payload';
export * from './record';
export * from './register';
export * from './registerId';
export * from './serialization';
export * from './state';
export * from './types';

View File

@@ -0,0 +1,62 @@
import type { JsonValue } from './types';
export function isJsonValue(value: unknown): value is JsonValue {
if (
value === null
|| typeof value === 'string'
|| typeof value === 'boolean'
) {
return true;
}
if (typeof value === 'number') return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJsonValue);
if (!value || typeof value !== 'object') return false;
return Object.values(value).every(isJsonValue);
}
/** Normalize in-memory model values exactly as the encrypted JSON payload does. */
export function normalizeJsonValue(value: unknown): JsonValue {
const serialized = JSON.stringify(value);
if (serialized === undefined) {
throw new TypeError('Value cannot be represented as JSON');
}
const normalized: unknown = JSON.parse(serialized);
if (!isJsonValue(normalized)) {
throw new TypeError('Value cannot be represented as JSON');
}
return normalized;
}
export function cloneJson<T extends JsonValue>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => cloneJson(item)) as T;
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, nested]) => [key, cloneJson(nested)]),
) as T;
}
return value;
}
export function canonicalizeJson<T extends JsonValue>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => canonicalizeJson(item)) as T;
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, canonicalizeJson(value[key])]),
) as T;
}
return value;
}
export function canonicalJsonString(value: JsonValue): string {
return JSON.stringify(canonicalizeJson(value));
}
export function jsonValuesEqual(left: JsonValue, right: JsonValue): boolean {
return canonicalJsonString(left) === canonicalJsonString(right);
}

View File

@@ -0,0 +1,231 @@
import { withHostsSanitizedForSync, type SyncPayload } from '../sync';
import { normalizeJsonValue } from './json';
import { encodeSettingPath } from './serialization';
import { applyConvergentMutations } from './state';
import type {
ConvergentMutation,
ConvergentSyncStateV2,
JsonValue,
} from './types';
import {
CONVERGENT_ENTITY_COLLECTIONS,
CONVERGENT_STRING_COLLECTIONS,
} from './payload';
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function stableValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableValue);
if (isRecord(value)) {
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, stableValue(value[key])]),
);
}
return value;
}
function fingerprint(value: unknown): string {
return JSON.stringify(stableValue(value));
}
function hasDefinedOwnProperty(payload: SyncPayload, property: string): boolean {
const record = payload as unknown as Record<string, unknown>;
return Object.prototype.hasOwnProperty.call(record, property)
&& record[property] !== undefined;
}
/** Preserve fields that an older client did not provide for safety checks. */
export function inheritOmittedLegacySyncFields(
baseline: SyncPayload,
legacy: SyncPayload,
): SyncPayload {
const result = { ...legacy } as SyncPayload;
const resultRecord = result as unknown as Record<string, unknown>;
const baselineRecord = baseline as unknown as Record<string, unknown>;
for (const property of [
...CONVERGENT_ENTITY_COLLECTIONS,
...CONVERGENT_STRING_COLLECTIONS,
'settings',
]) {
if (!hasDefinedOwnProperty(legacy, property)) {
resultRecord[property] = baselineRecord[property];
}
}
return result;
}
function normalizedEntityValue(
collection: string,
id: string,
value: Record<string, unknown>,
): Extract<ConvergentMutation, { kind: 'entity-upsert' }>['value'] {
try {
const normalized = normalizeJsonValue({ ...value, id });
if (!isRecord(normalized)) throw new TypeError('Entity is not an object');
return normalized as Extract<ConvergentMutation, { kind: 'entity-upsert' }>['value'];
} catch {
throw new Error(`${collection}/${id} contains a value that cannot be represented as JSON`);
}
}
function entityId(collection: string, value: Record<string, unknown>): string | undefined {
const id = collection === 'groupConfigs' ? value.path : value.id;
return typeof id === 'string' && id.length > 0 ? id : undefined;
}
function entityMap(payload: SyncPayload, collection: string): Map<string, Record<string, unknown>> {
const values = (payload as unknown as Record<string, unknown>)[collection];
const result = new Map<string, Record<string, unknown>>();
if (!Array.isArray(values)) return result;
for (const value of values) {
if (!isRecord(value)) continue;
const id = entityId(collection, value);
if (id) result.set(id, value);
}
return result;
}
function stringSet(payload: SyncPayload, collection: string): Set<string> {
const values = (payload as unknown as Record<string, unknown>)[collection];
return new Set(Array.isArray(values) ? values.filter((value): value is string => typeof value === 'string') : []);
}
function positionMap(values: Iterable<string>): Map<string, number> {
const positions = new Map<string, number>();
let position = 0;
for (const value of values) {
positions.set(value, position);
position += 1;
}
return positions;
}
function flattenSettings(
value: unknown,
path: string[] = [],
output: Map<string, { path: string[]; value: JsonValue }> = new Map(),
): Map<string, { path: string[]; value: JsonValue }> {
if (isRecord(value) && Object.keys(value).length > 0) {
for (const key of Object.keys(value).sort()) {
flattenSettings(value[key], [...path, key], output);
}
} else if (path.length > 0 && value !== undefined) {
output.set(encodeSettingPath(path), { path, value: value as JsonValue });
}
return output;
}
/** Compare only cloud materialized data; timestamps and reliability metadata are transport details. */
export function cloudSyncPayloadsEqual(left: SyncPayload, right: SyncPayload): boolean {
const project = (payload: SyncPayload) => {
const sanitized = withHostsSanitizedForSync(payload);
return {
...Object.fromEntries(
[...CONVERGENT_ENTITY_COLLECTIONS, ...CONVERGENT_STRING_COLLECTIONS]
.map((key) => [key, (sanitized as unknown as Record<string, unknown>)[key] ?? []]),
),
settings: sanitized.settings ?? {},
// Plugin sidecars are host-owned opaque data on the encrypted blob and
// must participate in migration freshness / equality checks.
pluginSidecars: sanitized.pluginSidecars ?? { version: 1, entries: [] },
};
};
return fingerprint(project(left)) === fingerprint(project(right));
}
/**
* Convert a trusted v1 baseline diff into deterministic CRDT writes. A missing
* or undefined optional top-level collection is treated as "unsupported by
* that client", while an explicitly present empty collection is a real
* deletion.
*/
export function diffLegacySyncPayload(
baseline: SyncPayload,
legacy: SyncPayload,
): ConvergentMutation[] {
const mutations: ConvergentMutation[] = [];
const sanitizedBaseline = withHostsSanitizedForSync(baseline);
const sanitizedLegacy = withHostsSanitizedForSync(legacy);
for (const collection of CONVERGENT_ENTITY_COLLECTIONS) {
if (!hasDefinedOwnProperty(legacy, collection)) continue;
const before = entityMap(sanitizedBaseline, collection);
const after = entityMap(sanitizedLegacy, collection);
const beforePositions = positionMap(before.keys());
const afterPositions = positionMap(after.keys());
const ids = new Set([...before.keys(), ...after.keys()]);
for (const id of [...ids].sort()) {
const previous = before.get(id);
const next = after.get(id);
if (previous && !next) {
mutations.push({ kind: 'entity-delete', collection, entityId: id });
} else if (
next
&& (
!previous
|| fingerprint(previous) !== fingerprint(next)
|| beforePositions.get(id) !== afterPositions.get(id)
)
) {
mutations.push({
kind: 'entity-upsert',
collection,
entityId: id,
value: normalizedEntityValue(collection, id, next),
position: afterPositions.get(id),
});
}
}
}
for (const collection of CONVERGENT_STRING_COLLECTIONS) {
if (!hasDefinedOwnProperty(legacy, collection)) continue;
const before = stringSet(baseline, collection);
const after = stringSet(legacy, collection);
const beforePositions = positionMap(before);
const afterPositions = positionMap(after);
for (const value of [...before].sort()) {
if (!after.has(value)) mutations.push({ kind: 'string-entry-delete', collection, value });
}
for (const value of [...after].sort()) {
if (!before.has(value) || beforePositions.get(value) !== afterPositions.get(value)) {
mutations.push({
kind: 'string-entry-add',
collection,
value,
position: afterPositions.get(value),
});
}
}
}
if (hasDefinedOwnProperty(legacy, 'settings')) {
const before = flattenSettings(baseline.settings);
const after = flattenSettings(legacy.settings);
const paths = new Set([...before.keys(), ...after.keys()]);
for (const encodedPath of [...paths].sort()) {
const previous = before.get(encodedPath);
const next = after.get(encodedPath);
if (previous && !next) {
mutations.push({ kind: 'setting-delete', path: previous.path });
} else if (next && (!previous || fingerprint(previous.value) !== fingerprint(next.value))) {
mutations.push({ kind: 'setting-set', path: next.path, value: next.value });
}
}
}
return mutations;
}
export function applyLegacySyncPayload(
state: ConvergentSyncStateV2,
baseline: SyncPayload,
legacy: SyncPayload,
syntheticDeviceId: string,
now: number,
): ConvergentSyncStateV2 {
return applyConvergentMutations(
state,
syntheticDeviceId,
diffLegacySyncPayload(baseline, legacy),
now,
);
}

View File

@@ -0,0 +1,384 @@
import {
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
hasSyncPayloadEntityData,
type CloudProvider,
type ConvergentMigrationPreview,
type ConvergentProviderMigrationStatus,
type SyncFileMeta,
type SyncPayload,
} from '../sync';
import { detectSuspiciousShrink } from '../syncGuards';
import { mergeSyncPayloads } from '../syncMerge';
import { summarizeSyncChanges } from '../syncReliability';
import { mergeConvergentSyncStates, materializeConvergentSyncState } from './state';
import type { ConvergentSyncStateV2 } from './types';
import {
cloudSyncPayloadsEqual,
applyLegacySyncPayload,
inheritOmittedLegacySyncFields,
} from './legacy';
import {
CONVERGENT_ENTITY_COLLECTIONS,
CONVERGENT_STRING_COLLECTIONS,
createConvergentSyncStateFromPayload,
hydrateConvergentSyncEnvelope,
materializeSyncPayloadFromConvergentState,
withConvergentSyncEnvelope,
} from './payload';
import {
mergePluginSyncSidecars,
mergePluginSyncSidecarsThreeWay,
} from '../pluginSyncSidecar';
/** LWW-union plugin sidecars from every migration input that carries them. */
function mergeMigrationSidecars(
...bundles: Array<SyncPayload['pluginSidecars'] | null | undefined>
): SyncPayload['pluginSidecars'] | undefined {
let entries: NonNullable<SyncPayload['pluginSidecars']>['entries'] = [];
let sawAny = false;
for (const bundle of bundles) {
if (!bundle || !Array.isArray(bundle.entries)) continue;
sawAny = true;
entries = mergePluginSyncSidecars({ local: entries, remote: bundle });
}
return sawAny ? { version: 1, entries } : undefined;
}
/**
* Three-way merge local sidecars against each remote source using that
* source's trusted baseline (falls back to local baseline). Preserves
* explicit local deletions instead of resurrecting them via LWW union.
*/
function mergeMigrationSidecarsWithBaselines(options: {
local: SyncPayload['pluginSidecars'] | null | undefined;
localBaseline: SyncPayload['pluginSidecars'] | null | undefined;
sources: Array<{
remote: SyncPayload['pluginSidecars'] | null | undefined;
baseline: SyncPayload['pluginSidecars'] | null | undefined;
}>;
}): SyncPayload['pluginSidecars'] | undefined {
let entries = Array.isArray(options.local?.entries) ? [...options.local.entries] : [];
const localBase = Array.isArray(options.localBaseline?.entries)
? options.localBaseline.entries
: [];
let sawAny = Array.isArray(options.local?.entries);
for (const source of options.sources) {
if (!source.remote || !Array.isArray(source.remote.entries)) continue;
sawAny = true;
const base = Array.isArray(source.baseline?.entries)
? source.baseline.entries
: localBase;
entries = mergePluginSyncSidecarsThreeWay({
base,
local: entries,
remote: source.remote.entries,
});
}
if (!sawAny) return undefined;
return { version: 1, entries };
}
/*
* A local snapshot with no cloud entities and no trusted base is a fresh
* device, not an untrusted deletion. Settings are intentionally ignored here
* because first-launch defaults must not prevent adoption of an existing v2
* vault. Once a trusted base exists, an empty snapshot remains a real deletion.
*/
function shouldIncludeLegacyLocalSource(
payload: SyncPayload,
trustedBaseline: SyncPayload | null,
): boolean {
return trustedBaseline !== null
|| hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS);
}
export type ConvergentMigrationProviderInput =
| { provider: CloudProvider; status: 'empty' }
| { provider: CloudProvider; status: 'unavailable'; message: string }
| {
provider: CloudProvider;
status: 'ready';
meta: SyncFileMeta;
payload: SyncPayload;
trustedBaseline: SyncPayload | null;
};
export interface ConvergentMigrationPlan {
preview: ConvergentMigrationPreview;
state: ConvergentSyncStateV2 | null;
payload: SyncPayload | null;
}
function runtimeSchema(meta: SyncFileMeta): 1 | 2 | 'future' | 'invalid' {
const value = (meta as { syncSchemaVersion?: unknown }).syncSchemaVersion;
if (value === undefined) return 1;
if (value === 2) return 2;
if (typeof value === 'number' && Number.isInteger(value) && value > 2) return 'future';
return 'invalid';
}
function countSettingsLeaves(value: unknown, root = true): number {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value === undefined ? 0 : 1;
const entries = Object.values(value as Record<string, unknown>);
if (entries.length === 0) return root ? 0 : 1;
return entries.reduce<number>(
(total, child) => total + countSettingsLeaves(child, false),
0,
);
}
function entityCount(payload: SyncPayload, key: string): number {
const value = (payload as unknown as Record<string, unknown>)[key];
return Array.isArray(value) ? value.length : 0;
}
function statusFor(
input: ConvergentMigrationProviderInput,
schemaVersion: ConvergentProviderMigrationStatus['schemaVersion'],
status: ConvergentProviderMigrationStatus['status'],
message?: string,
): ConvergentProviderMigrationStatus {
return {
provider: input.provider,
status,
schemaVersion,
entityCount: input.status === 'ready'
? [...CONVERGENT_ENTITY_COLLECTIONS, ...CONVERGENT_STRING_COLLECTIONS]
.reduce((total, key) => total + entityCount(input.payload, key), 0)
: 0,
hasTrustedBaseline: input.status === 'ready' && input.trustedBaseline !== null,
...(message ? { message } : {}),
};
}
export function planConvergentSyncMigration(options: {
localPayload: SyncPayload;
localTrustedBaseline: SyncPayload | null;
providers: ConvergentMigrationProviderInput[];
deviceId: string;
now: number;
}): ConvergentMigrationPlan {
const providers = [...options.providers].sort((left, right) => left.provider.localeCompare(right.provider));
const blockedReasons: string[] = [];
const providerStatuses: ConvergentProviderMigrationStatus[] = [];
const shrinkFindings: ConvergentMigrationPreview['shrinkFindings'] = [];
const v1Inputs: Extract<ConvergentMigrationProviderInput, { status: 'ready' }>[] = [];
const v2Inputs: Array<Extract<ConvergentMigrationProviderInput, { status: 'ready' }> & { state: ConvergentSyncStateV2 }> = [];
for (const input of providers) {
if (input.status === 'empty') {
providerStatuses.push(statusFor(input, 1, 'empty'));
continue;
}
if (input.status === 'unavailable') {
blockedReasons.push(`${input.provider}: ${input.message}`);
providerStatuses.push(statusFor(input, 'invalid', 'unavailable', input.message));
continue;
}
const schema = runtimeSchema(input.meta);
if (schema === 'future' || schema === 'invalid') {
const message = schema === 'future'
? 'Provider contains a newer sync schema'
: 'Provider contains invalid sync schema metadata';
blockedReasons.push(`${input.provider}: ${message}`);
providerStatuses.push(statusFor(input, schema, 'blocked', message));
continue;
}
if (schema === 1) {
if (input.payload.convergentSync) {
const message = 'Provider envelope does not match its plaintext schema metadata';
blockedReasons.push(`${input.provider}: ${message}`);
providerStatuses.push(statusFor(input, 'invalid', 'blocked', message));
} else {
v1Inputs.push(input);
providerStatuses.push(statusFor(input, 1, 'ready'));
}
continue;
}
try {
if (!input.payload.convergentSync) throw new Error('missing convergent envelope');
const state = hydrateConvergentSyncEnvelope(input.payload.convergentSync, input.payload);
v2Inputs.push({ ...input, state });
providerStatuses.push(statusFor(input, 2, 'ready'));
} catch (error) {
const message = `Damaged convergent envelope: ${error instanceof Error ? error.message : String(error)}`;
blockedReasons.push(`${input.provider}: ${message}`);
providerStatuses.push(statusFor(input, 'invalid', 'blocked', message));
}
}
let state: ConvergentSyncStateV2 | null = null;
let materialized: SyncPayload | null = null;
if (blockedReasons.length === 0 && v2Inputs.length === 0) {
const includeLocalSource = shouldIncludeLegacyLocalSource(
options.localPayload,
options.localTrustedBaseline,
);
const seedFromProvider = !includeLocalSource && v1Inputs.length > 0;
let merged = seedFromProvider ? v1Inputs[0].payload : options.localPayload;
if (seedFromProvider) {
const seed = v1Inputs[0];
const shrink = detectSuspiciousShrink(
seed.payload,
seed.trustedBaseline,
seed.payload,
);
if (shrink.suspicious) {
shrinkFindings.push({ provider: seed.provider, finding: shrink });
blockedReasons.push(`${seed.provider}: legacy migration would remove too many entities`);
}
}
const remainingInputs = seedFromProvider ? v1Inputs.slice(1) : v1Inputs;
for (const input of remainingInputs) {
if (!input.trustedBaseline) {
if (!cloudSyncPayloadsEqual(merged, input.payload)) {
blockedReasons.push(`${input.provider}: no trusted legacy baseline is available`);
}
continue;
}
const result = mergeSyncPayloads(input.trustedBaseline, merged, input.payload);
const changeSummary = summarizeSyncChanges(
input.trustedBaseline,
merged,
input.payload,
);
if (result.hadConflicts || changeSummary.hasConflicts) {
blockedReasons.push(`${input.provider}: legacy smart merge has unresolved conflicts`);
}
const shrink = detectSuspiciousShrink(result.payload, input.trustedBaseline, input.payload);
if (shrink.suspicious) {
shrinkFindings.push({ provider: input.provider, finding: shrink });
blockedReasons.push(`${input.provider}: legacy migration would remove too many entities`);
}
merged = result.payload;
}
if (blockedReasons.length === 0) {
state = createConvergentSyncStateFromPayload(merged, options.deviceId, options.now);
// Prefer the three-way merge result already on `merged` (preserves
// explicit sidecar deletions). Do not re-LWW raw provider bundles —
// that would resurrect entries three-way merge correctly removed.
const migrationSidecars = Object.prototype.hasOwnProperty.call(merged, 'pluginSidecars')
? merged.pluginSidecars
: mergeMigrationSidecars(
options.localPayload.pluginSidecars,
...v1Inputs.map((input) => input.payload.pluginSidecars),
);
materialized = materializeSyncPayloadFromConvergentState(state, {
syncedAt: options.now,
syncMeta: merged.syncMeta,
...(migrationSidecars ? { pluginSidecars: migrationSidecars } : {}),
});
}
} else if (blockedReasons.length === 0) {
state = v2Inputs.map((input) => input.state).reduce(mergeConvergentSyncStates);
// Three-way per provider so local resets are not resurrected from a
// still-stale remote entry during convergent enablement.
const joinedSidecars = mergeMigrationSidecarsWithBaselines({
local: options.localPayload.pluginSidecars,
localBaseline: options.localTrustedBaseline?.pluginSidecars,
sources: [
...v2Inputs.map((input) => ({
remote: input.payload.pluginSidecars,
baseline: input.trustedBaseline?.pluginSidecars,
})),
...v1Inputs.map((input) => ({
remote: input.payload.pluginSidecars,
baseline: input.trustedBaseline?.pluginSidecars,
})),
],
});
const joinedPayload = materializeSyncPayloadFromConvergentState(state, {
syncedAt: options.now,
...(joinedSidecars ? { pluginSidecars: joinedSidecars } : {}),
});
const legacySources: Array<{
id: string;
payload: SyncPayload;
baseline: SyncPayload | null;
now: number;
provider?: CloudProvider;
}> = [
...(shouldIncludeLegacyLocalSource(
options.localPayload,
options.localTrustedBaseline,
) ? [{
id: `legacy-local:${options.deviceId}`,
payload: options.localPayload,
baseline: options.localTrustedBaseline,
now: options.now,
}] : []),
...v1Inputs.map((input) => ({
id: `legacy-provider:${input.provider}:${input.meta.deviceId}`,
payload: input.payload,
baseline: input.trustedBaseline,
now: input.meta.updatedAt,
provider: input.provider,
})),
];
const branches: ConvergentSyncStateV2[] = [];
for (const source of legacySources) {
if (cloudSyncPayloadsEqual(source.payload, joinedPayload)) continue;
if (!source.baseline) {
blockedReasons.push(`${source.id}: no trusted legacy baseline is available`);
continue;
}
const shrink = detectSuspiciousShrink(
inheritOmittedLegacySyncFields(source.baseline, source.payload),
source.baseline,
);
if (shrink.suspicious) {
if (source.provider) {
shrinkFindings.push({ provider: source.provider, finding: shrink });
}
blockedReasons.push(`${source.id}: legacy migration would remove too many entities`);
continue;
}
branches.push(applyLegacySyncPayload(state, source.baseline, source.payload, source.id, source.now));
}
if (blockedReasons.length === 0) {
state = branches.reduce(mergeConvergentSyncStates, state);
// joinedSidecars already unions local + all provider inputs. Re-LWW-ing
// raw sources again cannot add unique entries and can confuse future
// three-way paths that expect the joined set to be final.
materialized = materializeSyncPayloadFromConvergentState(state, {
syncedAt: options.now,
...(joinedSidecars ? { pluginSidecars: joinedSidecars } : {}),
});
}
}
const conflicts = state ? materializeConvergentSyncState(state).conflicts : [];
if (conflicts.length > 0) blockedReasons.push('The convergent state contains unresolved field conflicts');
const canInitialize = blockedReasons.length === 0 && state !== null && materialized !== null;
const payload = canInitialize && state
? withConvergentSyncEnvelope(state, {
syncedAt: options.now,
syncMeta: materialized?.syncMeta,
...(materialized?.pluginSidecars
? { pluginSidecars: materialized.pluginSidecars }
: {}),
})
: null;
const previewPayload = materialized ?? options.localPayload;
const entityCounts = Object.fromEntries(
[...CONVERGENT_ENTITY_COLLECTIONS, ...CONVERGENT_STRING_COLLECTIONS]
.map((key) => [key, entityCount(previewPayload, key)]),
) as ConvergentMigrationPreview['entityCounts'];
return {
preview: {
schemaVersion: 2,
canInitialize,
entityCounts,
settingsLeafCount: countSettingsLeaves(previewPayload.settings),
conflictCount: conflicts.length,
conflicts,
shrinkFindings,
providers: providerStatuses,
oldClientCompatibility: 'materialized-v1-snapshot',
blockedReasons,
},
state: canInitialize ? state : null,
payload,
};
}

View File

@@ -0,0 +1,585 @@
import {
withHostsSanitizedForSync,
type CloudSyncPayloadEntityKey,
type SyncFileMeta,
type SyncPayload,
type SyncReliabilityMeta,
} from '../sync';
import { dotKey } from './clock';
import {
cloneJson,
isJsonValue,
jsonValuesEqual,
normalizeJsonValue,
} from './json';
import { selectRegisterWinner, isTombstoneCandidate } from './register';
import { createEmptyRecord, setOwnRecordValue } from './record';
import {
assertValidConvergentSyncState,
canonicalizeConvergentSyncState,
decodeSettingPath,
} from './serialization';
import {
applyConvergentMutations,
createConvergentSyncState,
materializeConvergentSyncState,
} from './state';
import type {
CollectionPosition,
ConvergentEnvelopeCandidate,
ConvergentEnvelopeCollectionState,
ConvergentEnvelopeEntityState,
ConvergentEnvelopeRegister,
ConvergentEnvelopeStateV2,
ConvergentEnvelopeStringCollectionState,
ConvergentEnvelopeStringEntryState,
ConvergentMutation,
ConvergentSyncEnvelopeV2,
ConvergentSyncStateV2,
JsonObject,
JsonValue,
MultiValueRegister,
RegisterCandidate,
} from './types';
export const CONVERGENT_ENTITY_COLLECTIONS = [
'hosts',
'keys',
'identities',
'proxyProfiles',
'snippets',
'notes',
'portForwardingRules',
'groupConfigs',
] as const satisfies readonly CloudSyncPayloadEntityKey[];
export const CONVERGENT_STRING_COLLECTIONS = [
'customGroups',
'snippetPackages',
'noteGroups',
] as const satisfies readonly CloudSyncPayloadEntityKey[];
type ConvergentEntityCollection = typeof CONVERGENT_ENTITY_COLLECTIONS[number];
type ConvergentStringCollection = typeof CONVERGENT_STRING_COLLECTIONS[number];
const ENTITY_COLLECTION_SET = new Set<string>(CONVERGENT_ENTITY_COLLECTIONS);
const STRING_COLLECTION_SET = new Set<string>(CONVERGENT_STRING_COLLECTIONS);
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function toJsonValue(value: unknown, label: string): JsonValue {
try {
return normalizeJsonValue(value);
} catch {
throw new Error(`${label} contains a value that cannot be represented as JSON`);
}
}
function entityId(collection: ConvergentEntityCollection, value: Record<string, unknown>): string {
const raw = collection === 'groupConfigs' ? value.path : value.id;
if (typeof raw !== 'string' || raw.length === 0) {
throw new Error(`${collection} contains an entity without a stable identifier`);
}
return raw;
}
function entityJson(
collection: ConvergentEntityCollection,
value: Record<string, unknown>,
): JsonObject {
const id = entityId(collection, value);
const json = toJsonValue(value, `${collection}/${id}`);
if (!isRecord(json)) throw new Error(`${collection}/${id} must be a JSON object`);
return {
...json,
id,
} as JsonObject;
}
function payloadEntityValues(
payload: SyncPayload,
collection: ConvergentEntityCollection,
): Record<string, unknown>[] {
const values = payload[collection];
return Array.isArray(values) ? values as unknown as Record<string, unknown>[] : [];
}
function payloadStringValues(
payload: SyncPayload,
collection: ConvergentStringCollection,
): string[] {
const values = payload[collection];
return Array.isArray(values)
? values.filter((value): value is string => typeof value === 'string')
: [];
}
function appendSettingMutations(
value: unknown,
path: string[],
mutations: ConvergentMutation[],
): void {
if (isRecord(value) && Object.keys(value).length > 0) {
for (const key of Object.keys(value).sort()) {
appendSettingMutations(value[key], [...path, key], mutations);
}
return;
}
if (path.length === 0 || value === undefined) return;
mutations.push({
kind: 'setting-set',
path,
value: toJsonValue(value, `settings.${path.join('.')}`),
});
}
export function syncPayloadToConvergentMutations(payload: SyncPayload): ConvergentMutation[] {
const sanitized = withHostsSanitizedForSync(payload);
const mutations: ConvergentMutation[] = [];
for (const collection of CONVERGENT_ENTITY_COLLECTIONS) {
payloadEntityValues(sanitized, collection).forEach((value, position) => {
const id = entityId(collection, value);
mutations.push({
kind: 'entity-upsert',
collection,
entityId: id,
value: entityJson(collection, value),
position,
});
});
}
for (const collection of CONVERGENT_STRING_COLLECTIONS) {
payloadStringValues(sanitized, collection).forEach((value, position) => {
mutations.push({ kind: 'string-entry-add', collection, value, position });
});
}
appendSettingMutations(sanitized.settings, [], mutations);
return mutations;
}
export function createConvergentSyncStateFromPayload(
payload: SyncPayload,
deviceId: string,
now: number,
): ConvergentSyncStateV2 {
return applyConvergentMutations(
createConvergentSyncState(),
deviceId,
syncPayloadToConvergentMutations(payload),
now,
);
}
function requireKnownCollections(state: ConvergentSyncStateV2): void {
for (const collection of Object.keys(state.collections)) {
if (!ENTITY_COLLECTION_SET.has(collection)) {
throw new Error(`Unsupported convergent entity collection: ${collection}`);
}
}
for (const collection of Object.keys(state.stringCollections)) {
if (!STRING_COLLECTION_SET.has(collection)) {
throw new Error(`Unsupported convergent string collection: ${collection}`);
}
}
}
function collectionValues(
collections: Record<string, JsonObject[]>,
collection: ConvergentEntityCollection,
): JsonObject[] {
return collections[collection] ?? [];
}
function typedCollection<T>(
collections: Record<string, JsonObject[]>,
collection: Exclude<ConvergentEntityCollection, 'groupConfigs'>,
): T[] {
return collectionValues(collections, collection) as unknown as T[];
}
export function materializeSyncPayloadFromConvergentState(
state: ConvergentSyncStateV2,
options: {
syncedAt: number;
syncMeta?: SyncReliabilityMeta;
/** Opaque plugin sidecars travel with the encrypted blob outside CRDT fields. */
pluginSidecars?: SyncPayload['pluginSidecars'];
},
): SyncPayload {
requireKnownCollections(state);
const materialized = materializeConvergentSyncState(state);
const groupConfigs = collectionValues(materialized.collections, 'groupConfigs').map((value) => {
const { id: _id, ...groupConfig } = value;
return groupConfig as unknown as import('../models').GroupConfig;
});
const settings = Object.keys(materialized.settings).length > 0
? materialized.settings as unknown as NonNullable<SyncPayload['settings']>
: undefined;
return {
hosts: typedCollection<import('../models').Host>(materialized.collections, 'hosts'),
keys: typedCollection<import('../models').SSHKey>(materialized.collections, 'keys'),
identities: typedCollection<import('../models').Identity>(materialized.collections, 'identities'),
proxyProfiles: typedCollection<import('../models').ProxyProfile>(materialized.collections, 'proxyProfiles'),
snippets: typedCollection<import('../models').Snippet>(materialized.collections, 'snippets'),
customGroups: materialized.stringCollections.customGroups ?? [],
snippetPackages: materialized.stringCollections.snippetPackages ?? [],
notes: typedCollection<import('../models').VaultNote>(materialized.collections, 'notes'),
noteGroups: materialized.stringCollections.noteGroups ?? [],
portForwardingRules: typedCollection<import('../models').PortForwardingRule>(materialized.collections, 'portForwardingRules'),
groupConfigs,
settings,
syncedAt: options.syncedAt,
...(options.syncMeta ? { syncMeta: options.syncMeta } : {}),
// Preserve explicit empty bundles so lifecycle materializations (conflict
// resolve / downgrade) can clear or re-upload sidecars rather than omit
// the field and look like a legacy payload.
...(options.pluginSidecars && Array.isArray(options.pluginSidecars.entries)
? {
pluginSidecars: {
version: 1 as const,
entries: options.pluginSidecars.entries,
},
}
: {}),
};
}
function materializedEntity(
payload: SyncPayload,
collection: string,
id: string,
): Record<string, unknown> | undefined {
if (!ENTITY_COLLECTION_SET.has(collection)) return undefined;
const values = payloadEntityValues(payload, collection as ConvergentEntityCollection);
return values.find((value) => entityId(collection as ConvergentEntityCollection, value) === id);
}
function nestedSetting(payload: SyncPayload, path: string[]): unknown {
let value: unknown = payload.settings;
for (const segment of path) {
if (!isRecord(value) || !Object.prototype.hasOwnProperty.call(value, segment)) return undefined;
value = value[segment];
}
return value;
}
function stableUnknown(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableUnknown);
if (isRecord(value)) {
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, stableUnknown(value[key])]),
);
}
return value;
}
function materializedCloudFingerprint(payload: SyncPayload): string {
return JSON.stringify(stableUnknown({
...Object.fromEntries(
CONVERGENT_ENTITY_COLLECTIONS.map((collection) => [
collection,
payloadEntityValues(payload, collection),
]),
),
...Object.fromEntries(
CONVERGENT_STRING_COLLECTIONS.map((collection) => [
collection,
payloadStringValues(payload, collection),
]),
),
settings: payload.settings ?? {},
}));
}
function assertMaterializedPayloadMatchesState(
state: ConvergentSyncStateV2,
payload: SyncPayload,
): void {
const expected = materializeSyncPayloadFromConvergentState(state, { syncedAt: 0 });
if (materializedCloudFingerprint(expected) !== materializedCloudFingerprint(payload)) {
throw new Error('Convergent envelope does not match its materialized v1 snapshot');
}
}
function compactRegister<T extends JsonValue>(
register: MultiValueRegister<T>,
materializedValue?: unknown,
allowMaterializedValue = false,
): ConvergentEnvelopeRegister<T> {
const winner = selectRegisterWinner(register);
return {
candidates: register.candidates.map((candidate): ConvergentEnvelopeCandidate<T> => {
const base = {
dot: { ...candidate.dot },
context: candidate.context.map((dot) => ({ ...dot })),
hlc: { ...candidate.hlc },
};
if (isTombstoneCandidate(candidate)) return { ...base, tombstone: true };
if (
allowMaterializedValue
&& winner
&& dotKey(candidate.dot) === dotKey(winner.dot)
&& isJsonValue(materializedValue)
&& jsonValuesEqual(candidate.value, materializedValue)
) {
return { ...base, materialized: true };
}
return { ...base, value: cloneJson(candidate.value) };
}),
};
}
export function createConvergentSyncEnvelope(
state: ConvergentSyncStateV2,
materializedPayload: SyncPayload,
): ConvergentSyncEnvelopeV2 {
const canonical = canonicalizeConvergentSyncState(state);
requireKnownCollections(canonical);
assertMaterializedPayloadMatchesState(canonical, materializedPayload);
const collections = createEmptyRecord<ConvergentEnvelopeCollectionState>();
for (const [collectionName, collection] of Object.entries(canonical.collections)) {
const entities = createEmptyRecord<ConvergentEnvelopeEntityState>();
for (const [id, entity] of Object.entries(collection.entities)) {
const materialized = materializedEntity(materializedPayload, collectionName, id);
const fields = createEmptyRecord<ConvergentEnvelopeRegister>();
for (const [field, register] of Object.entries(entity.fields)) {
setOwnRecordValue(fields, field, compactRegister(register, materialized?.[field], true));
}
setOwnRecordValue(entities, id, {
presence: compactRegister(entity.presence),
...(entity.position ? { position: compactRegister(entity.position) } : {}),
fields,
});
}
setOwnRecordValue(collections, collectionName, { entities });
}
const settings = createEmptyRecord<ConvergentEnvelopeRegister>();
for (const [encodedPath, register] of Object.entries(canonical.settings)) {
setOwnRecordValue(settings, encodedPath, compactRegister(
register,
nestedSetting(materializedPayload, decodeSettingPath(encodedPath)),
true,
));
}
const stringCollections = createEmptyRecord<ConvergentEnvelopeStringCollectionState>();
for (const [collectionName, collection] of Object.entries(canonical.stringCollections)) {
const entries = createEmptyRecord<ConvergentEnvelopeStringEntryState>();
for (const [value, entry] of Object.entries(collection.entries)) {
setOwnRecordValue(entries, value, {
presence: compactRegister(entry.presence),
...(entry.position ? { position: compactRegister(entry.position) } : {}),
});
}
setOwnRecordValue(stringCollections, collectionName, { entries });
}
return {
schemaVersion: 2,
encoding: 'materialized-winner-v1',
state: {
vector: Object.fromEntries(Object.entries(canonical.vector)),
dotOrigins: Object.fromEntries(
Object.entries(canonical.dotOrigins).map(([deviceId, origins]) => [deviceId, { ...origins }]),
),
hlc: { ...canonical.hlc },
collections,
settings,
stringCollections,
},
};
}
function hydrateRegister<T extends JsonValue>(
register: ConvergentEnvelopeRegister<T>,
materializedValue: unknown,
label: string,
): MultiValueRegister<T> {
if (!register || !Array.isArray(register.candidates) || register.candidates.length === 0) {
throw new Error(`${label} has no candidates`);
}
return {
candidates: register.candidates.map((candidate, index): RegisterCandidate<T> => {
const candidateLabel = `${label}.candidates[${index}]`;
const base = {
dot: { ...candidate.dot },
context: candidate.context.map((dot) => ({ ...dot })),
hlc: { ...candidate.hlc },
};
if (
candidate.tombstone !== undefined
&& candidate.tombstone !== true
&& candidate.tombstone !== false
) {
throw new Error(`${candidateLabel} has an invalid tombstone marker`);
}
if (
'materialized' in candidate
&& candidate.materialized !== undefined
&& candidate.materialized !== true
) {
throw new Error(`${candidateLabel} has an invalid materialized marker`);
}
if (candidate.tombstone === true) {
if ('materialized' in candidate || 'value' in candidate) {
throw new Error(`${candidateLabel} tombstone contains a value marker`);
}
return { ...base, tombstone: true };
}
if ('materialized' in candidate && candidate.materialized === true) {
if ('value' in candidate || !isJsonValue(materializedValue)) {
throw new Error(`${candidateLabel} cannot reconstruct its materialized value`);
}
return { ...base, value: cloneJson(materializedValue) as T };
}
if (!('value' in candidate) || !isJsonValue(candidate.value)) {
throw new Error(`${candidateLabel} is missing a JSON value`);
}
return { ...base, value: cloneJson(candidate.value) as T };
}),
};
}
function envelopeState(value: unknown): ConvergentEnvelopeStateV2 {
if (!isRecord(value)) throw new Error('Convergent sync envelope state is invalid');
return value as unknown as ConvergentEnvelopeStateV2;
}
export function hydrateConvergentSyncEnvelope(
envelope: ConvergentSyncEnvelopeV2,
materializedPayload: SyncPayload,
): ConvergentSyncStateV2 {
if (
!envelope
|| envelope.schemaVersion !== 2
|| envelope.encoding !== 'materialized-winner-v1'
) {
throw new Error('Unsupported convergent sync envelope');
}
const encoded = envelopeState(envelope.state);
const collections = createEmptyRecord<ConvergentSyncStateV2['collections'][string]>();
for (const [collectionName, collection] of Object.entries(encoded.collections ?? {})) {
if (!ENTITY_COLLECTION_SET.has(collectionName) || !isRecord(collection?.entities)) {
throw new Error(`Unsupported or invalid convergent collection: ${collectionName}`);
}
const entities = createEmptyRecord<ConvergentSyncStateV2['collections'][string]['entities'][string]>();
for (const [id, entity] of Object.entries(collection.entities)) {
if (!isRecord(entity) || !isRecord(entity.fields)) {
throw new Error(`Invalid convergent entity: ${collectionName}/${id}`);
}
const materialized = materializedEntity(materializedPayload, collectionName, id);
const fields = createEmptyRecord<MultiValueRegister>();
for (const [field, register] of Object.entries(entity.fields)) {
setOwnRecordValue(fields, field, hydrateRegister(
register,
materialized?.[field],
`${collectionName}/${id}/${field}`,
));
}
setOwnRecordValue(entities, id, {
presence: hydrateRegister(entity.presence, true, `${collectionName}/${id}/presence`),
...(entity.position
? { position: hydrateRegister<CollectionPosition>(entity.position, undefined, `${collectionName}/${id}/position`) }
: {}),
fields,
});
}
setOwnRecordValue(collections, collectionName, { entities });
}
const settings = createEmptyRecord<MultiValueRegister>();
for (const [path, register] of Object.entries(encoded.settings ?? {})) {
setOwnRecordValue(settings, path, hydrateRegister(
register,
nestedSetting(materializedPayload, decodeSettingPath(path)),
`settings/${path}`,
));
}
const stringCollections = createEmptyRecord<ConvergentSyncStateV2['stringCollections'][string]>();
for (const [collectionName, collection] of Object.entries(encoded.stringCollections ?? {})) {
if (!STRING_COLLECTION_SET.has(collectionName) || !isRecord(collection?.entries)) {
throw new Error(`Unsupported or invalid convergent string collection: ${collectionName}`);
}
const entries = createEmptyRecord<ConvergentSyncStateV2['stringCollections'][string]['entries'][string]>();
for (const [value, entry] of Object.entries(collection.entries)) {
if (!isRecord(entry)) throw new Error(`Invalid convergent string entry: ${collectionName}/${value}`);
setOwnRecordValue(entries, value, {
presence: hydrateRegister(entry.presence, true, `${collectionName}/${value}/presence`),
...(entry.position
? { position: hydrateRegister<CollectionPosition>(entry.position, undefined, `${collectionName}/${value}/position`) }
: {}),
});
}
setOwnRecordValue(stringCollections, collectionName, { entries });
}
const state: ConvergentSyncStateV2 = {
schemaVersion: 2,
vector: encoded.vector,
dotOrigins: encoded.dotOrigins,
hlc: encoded.hlc,
collections,
settings,
stringCollections,
};
assertValidConvergentSyncState(state);
const canonical = canonicalizeConvergentSyncState(state);
assertMaterializedPayloadMatchesState(canonical, materializedPayload);
return canonical;
}
export function withConvergentSyncEnvelope(
state: ConvergentSyncStateV2,
options: {
syncedAt: number;
syncMeta?: SyncReliabilityMeta;
pluginSidecars?: SyncPayload['pluginSidecars'];
},
): SyncPayload {
const payload = materializeSyncPayloadFromConvergentState(state, options);
return {
...payload,
convergentSync: createConvergentSyncEnvelope(state, payload),
};
}
export function validateConvergentSyncPayload(
meta: Pick<SyncFileMeta, 'syncSchemaVersion'>,
payload: SyncPayload,
): ConvergentSyncStateV2 | null {
const schemaVersion = (meta as { syncSchemaVersion?: unknown }).syncSchemaVersion;
if (schemaVersion === undefined) {
if (payload.convergentSync !== undefined) {
throw new Error('Convergent sync envelope is present without schema metadata');
}
return null;
}
if (schemaVersion !== 2) {
throw new Error(`Unsupported sync schema version: ${String(schemaVersion)}`);
}
if (!payload.convergentSync) {
throw new Error('Sync schema v2 payload is missing its convergent envelope');
}
return hydrateConvergentSyncEnvelope(payload.convergentSync, payload);
}
/** Prevent the legacy snapshot writer from silently erasing v2/future metadata. */
export function assertConvergentSyncWriteCompatible(
remoteMeta: Pick<SyncFileMeta, 'syncSchemaVersion'> | null | undefined,
outgoingPayload: SyncPayload,
): void {
if (!remoteMeta) return;
const remoteSchema = (remoteMeta as { syncSchemaVersion?: unknown }).syncSchemaVersion;
if (remoteSchema === undefined) return;
if (remoteSchema !== 2) {
throw new Error(`Cannot overwrite unsupported sync schema version: ${String(remoteSchema)}`);
}
if (!outgoingPayload.convergentSync) {
throw new Error(
'Cloud data uses convergent sync v2. Enable or migrate convergent sync before uploading.',
);
}
}
export function stripConvergentSyncEnvelope(payload: SyncPayload): SyncPayload {
const { convergentSync: _convergentSync, ...legacyPayload } = payload;
return legacyPayload;
}

View File

@@ -0,0 +1,756 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { sanitizeHost } from '../host.ts';
import type { SyncFileMeta, SyncPayload } from '../sync.ts';
import {
applyConvergentMutations,
assertConvergentSyncWriteCompatible,
applyLegacySyncPayload,
cloudSyncPayloadsEqual,
createConvergentSyncEnvelope,
createConvergentSyncStateFromPayload,
diffLegacySyncPayload,
hydrateConvergentSyncEnvelope,
materializeSyncPayloadFromConvergentState,
mergeConvergentSyncStates,
planConvergentSyncMigration,
serializeConvergentSyncState,
validateConvergentSyncPayload,
withConvergentSyncEnvelope,
createConvergentSyncState,
} from './index.ts';
const NOW = 1_700_000_000_000;
function payload(label = 'Production'): SyncPayload {
return {
hosts: [{
id: 'host-1',
label,
hostname: 'example.com',
username: 'root',
tags: ['prod'],
os: 'linux',
password: 'host-secret',
}],
keys: [{
id: 'key-1',
label: 'Deploy key',
type: 'ED25519',
privateKey: 'private-secret',
source: 'imported',
category: 'key',
created: NOW,
}],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: ['prod'],
snippetPackages: [],
notes: [],
noteGroups: [],
portForwardingRules: [],
groupConfigs: [],
settings: {
theme: 'dark',
ai: { providers: [{ id: 'provider-1', apiKey: 'api-secret' }] },
},
syncedAt: NOW,
};
}
function emptyPayload(settings?: SyncPayload['settings']): SyncPayload {
return {
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
snippetPackages: [],
notes: [],
noteGroups: [],
portForwardingRules: [],
groupConfigs: [],
settings,
syncedAt: NOW,
};
}
function meta(overrides: Partial<SyncFileMeta> = {}): SyncFileMeta {
return {
version: 1,
updatedAt: NOW,
deviceId: 'remote-device',
appVersion: '1.0.0',
iv: 'iv',
salt: 'salt',
algorithm: 'AES-256-GCM',
kdf: 'PBKDF2',
...overrides,
};
}
test('encrypted envelope omits materialized winner values and hydrates exactly', () => {
const state = createConvergentSyncStateFromPayload(payload(), 'device-a', NOW);
const materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: NOW });
const envelope = createConvergentSyncEnvelope(state, materialized);
const envelopeJson = JSON.stringify(envelope);
assert.equal(envelopeJson.includes('host-secret'), false);
assert.equal(envelopeJson.includes('private-secret'), false);
assert.equal(envelopeJson.includes('api-secret'), false);
assert.match(JSON.stringify(materialized), /private-secret/);
assert.equal(
serializeConvergentSyncState(hydrateConvergentSyncEnvelope(envelope, materialized)),
serializeConvergentSyncState(state),
);
});
test('envelope creation and hydration reject a materialized snapshot that disagrees with state', () => {
const state = createConvergentSyncStateFromPayload(payload('State value'), 'device-a', NOW);
const mismatched = payload('Different snapshot value');
assert.throws(
() => createConvergentSyncEnvelope(state, mismatched),
/does not match its materialized v1 snapshot/,
);
const materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: NOW });
const envelope = createConvergentSyncEnvelope(state, materialized);
const damaged = structuredClone(envelope);
const labelRegister = damaged.state.collections.hosts.entities['host-1'].fields.label;
const selected = labelRegister.candidates.find(
(candidate) => 'materialized' in candidate && candidate.materialized === true,
);
assert.ok(selected);
const damagedCandidate = selected as unknown as { materialized?: true; value?: string };
delete damagedCandidate.materialized;
damagedCandidate.value = 'Envelope-only value';
assert.throws(
() => hydrateConvergentSyncEnvelope(damaged, materialized),
/does not match its materialized v1 snapshot/,
);
});
test('poisoned enc:v1 secrets still round-trip through convergent envelope validation', () => {
// Materialize must preserve device-bound ciphertext that already lives in the
// CRDT + v1 snapshot pair. Stripping here would make decrypt/hydrate reject
// the exact poisoned v2 clouds #2702 needs to recover from.
const completeBlob = Buffer.alloc(19, 0);
Buffer.from('v10', 'utf8').copy(completeBlob, 0);
const ENC = `enc:v1:${completeBlob.toString('base64')}`;
const poisoned = payload();
poisoned.hosts = [{ ...poisoned.hosts[0]!, password: ENC }];
poisoned.keys = [{ ...poisoned.keys[0]!, privateKey: ENC }];
const state = createConvergentSyncStateFromPayload(poisoned, 'device-a', NOW);
const materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: NOW });
assert.equal(materialized.hosts[0]?.password, ENC);
assert.equal(materialized.keys[0]?.privateKey, ENC);
const envelope = createConvergentSyncEnvelope(state, materialized);
assert.equal(
serializeConvergentSyncState(hydrateConvergentSyncEnvelope(envelope, materialized)),
serializeConvergentSyncState(state),
);
});
test('envelope maps preserve prototype-like entity, field, setting, and string identifiers', () => {
const specialObject = JSON.parse('{"id":"__proto__","constructor":"safe"}') as {
id: string;
constructor: string;
};
const state = applyConvergentMutations(createConvergentSyncState(), 'device-a', [
{
kind: 'entity-upsert',
collection: 'hosts',
entityId: '__proto__',
value: specialObject,
position: 0,
},
{ kind: 'setting-set', path: ['__proto__'], value: 'safe-setting' },
{ kind: 'string-entry-add', collection: 'customGroups', value: '__proto__', position: 0 },
], NOW);
const materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: NOW });
const envelope = createConvergentSyncEnvelope(state, materialized);
const hydrated = hydrateConvergentSyncEnvelope(
JSON.parse(JSON.stringify(envelope)),
JSON.parse(JSON.stringify(materialized)),
);
assert.equal(serializeConvergentSyncState(hydrated), serializeConvergentSyncState(state));
});
test('envelope retains concurrent alternatives while the selected winner remains materialized', () => {
const base = createConvergentSyncStateFromPayload(payload('Base'), 'seed', NOW);
const left = applyConvergentMutations(base, 'device-a', [{
kind: 'entity-field-set',
collection: 'hosts',
entityId: 'host-1',
field: 'label',
value: 'Left alternative',
}], NOW + 1);
const right = applyConvergentMutations(base, 'device-z', [{
kind: 'entity-field-set',
collection: 'hosts',
entityId: 'host-1',
field: 'label',
value: 'Right winner',
}], NOW + 1);
const state = mergeConvergentSyncStates(left, right);
const materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: NOW + 1 });
const envelopeJson = JSON.stringify(createConvergentSyncEnvelope(state, materialized));
assert.match(envelopeJson, /Left alternative/);
assert.equal(envelopeJson.includes('Right winner'), false);
});
test('schema validation fails closed for missing, mismatched, future, and damaged envelopes', () => {
const state = createConvergentSyncStateFromPayload(payload(), 'device-a', NOW);
const v2 = withConvergentSyncEnvelope(state, { syncedAt: NOW });
assert.equal(
serializeConvergentSyncState(validateConvergentSyncPayload(meta({ syncSchemaVersion: 2 }), v2)!),
serializeConvergentSyncState(state),
);
assert.throws(
() => validateConvergentSyncPayload(meta(), v2),
/without schema metadata/,
);
assert.throws(
() => validateConvergentSyncPayload(meta({ syncSchemaVersion: 2 }), payload()),
/missing its convergent envelope/,
);
assert.throws(
() => validateConvergentSyncPayload(
{ ...meta(), syncSchemaVersion: 3 } as unknown as SyncFileMeta,
payload(),
),
/Unsupported sync schema version/,
);
const damaged = structuredClone(v2);
damaged.convergentSync!.state.vector['device-a'] = 999;
assert.throws(
() => validateConvergentSyncPayload(meta({ syncSchemaVersion: 2 }), damaged),
/not witnessed|cover every counter/,
);
});
test('legacy writers cannot silently overwrite convergent or future cloud schemas', () => {
const state = createConvergentSyncStateFromPayload(payload(), 'device-a', NOW);
const v2 = withConvergentSyncEnvelope(state, { syncedAt: NOW });
assert.doesNotThrow(() => assertConvergentSyncWriteCompatible(meta(), payload()));
assert.doesNotThrow(() => assertConvergentSyncWriteCompatible(
meta({ syncSchemaVersion: 2 }),
v2,
));
assert.throws(
() => assertConvergentSyncWriteCompatible(meta({ syncSchemaVersion: 2 }), payload()),
/Enable or migrate convergent sync/,
);
assert.throws(
() => assertConvergentSyncWriteCompatible(
{ syncSchemaVersion: 3 } as unknown as SyncFileMeta,
v2,
),
/unsupported sync schema/,
);
});
test('cloudSyncPayloadsEqual ignores lastConnectedAt telemetry', () => {
const left = payload();
const right = {
...payload(),
hosts: [{ ...payload().hosts[0], lastConnectedAt: NOW }],
};
assert.equal(cloudSyncPayloadsEqual(left, right), true);
});
test('createConvergentSyncStateFromPayload does not persist lastConnectedAt', () => {
const withTelemetry = {
...payload(),
hosts: [{ ...payload().hosts[0], lastConnectedAt: NOW }],
};
const state = createConvergentSyncStateFromPayload(withTelemetry, 'device-a', NOW);
const materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: NOW });
assert.equal(materialized.hosts[0].lastConnectedAt, undefined);
assert.equal('lastConnectedAt' in materialized.hosts[0], false);
});
test('diffLegacySyncPayload ignores lastConnectedAt-only host changes', () => {
const baseline = payload();
const legacy = {
...payload(),
hosts: [{ ...payload().hosts[0], lastConnectedAt: NOW + 1 }],
};
assert.deepEqual(diffLegacySyncPayload(baseline, legacy), []);
});
test('trusted legacy diff becomes causal CRDT writes without carrying transport metadata', () => {
const baseline = payload('Before');
const state = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
const legacy = {
...payload('After'),
keys: [],
syncedAt: NOW + 100,
};
const next = applyLegacySyncPayload(
state,
baseline,
legacy,
'legacy:github:remote-device',
NOW + 100,
);
const materialized = materializeSyncPayloadFromConvergentState(next, { syncedAt: NOW + 100 });
assert.equal(materialized.hosts[0].label, 'After');
assert.deepEqual(materialized.keys, []);
assert.equal(cloudSyncPayloadsEqual(materialized, legacy), true);
});
test('payload and legacy conversion normalize undefined fields with JSON semantics', () => {
const baseline = payload('Before');
baseline.hosts = [sanitizeHost({
...baseline.hosts[0],
proxyConfig: {
type: 'http',
host: 'proxy.example.com',
port: 8080,
username: undefined,
},
})];
assert.equal(Object.hasOwn(baseline.hosts[0], 'iconMode'), true);
assert.equal(Object.hasOwn(baseline.hosts[0].proxyConfig!, 'username'), true);
const state = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
const initial = materializeSyncPayloadFromConvergentState(state, { syncedAt: NOW });
assert.equal(Object.hasOwn(initial.hosts[0], 'iconMode'), false);
assert.equal(Object.hasOwn(initial.hosts[0].proxyConfig!, 'username'), false);
const legacy: SyncPayload = {
...baseline,
hosts: [{ ...baseline.hosts[0], label: 'After' }],
syncedAt: NOW + 1,
};
const next = applyLegacySyncPayload(
state,
baseline,
legacy,
'legacy:github:remote-device',
NOW + 1,
);
const materialized = materializeSyncPayloadFromConvergentState(next, { syncedAt: NOW + 1 });
assert.equal(materialized.hosts[0].label, 'After');
assert.equal(Object.hasOwn(materialized.hosts[0], 'iconMode'), false);
assert.equal(Object.hasOwn(materialized.hosts[0].proxyConfig!, 'username'), false);
});
test('trusted legacy diff treats own undefined optional fields as omitted', () => {
const baseline = payload();
baseline.identities = [{
id: 'identity-1',
label: 'Production identity',
username: 'root',
authMethod: 'password',
password: 'identity-secret',
created: NOW,
}];
baseline.noteGroups = ['operations'];
const legacy = {
...baseline,
identities: undefined,
noteGroups: undefined,
settings: undefined,
syncedAt: NOW + 1,
} as unknown as SyncPayload;
assert.deepEqual(diffLegacySyncPayload(baseline, legacy), []);
const state = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
const next = applyLegacySyncPayload(
state,
baseline,
legacy,
'legacy:github:remote-device',
NOW + 1,
);
const materialized = materializeSyncPayloadFromConvergentState(next, { syncedAt: NOW + 1 });
assert.equal(materialized.identities?.[0]?.id, 'identity-1');
assert.deepEqual(materialized.noteGroups, ['operations']);
assert.equal(materialized.settings?.theme, 'dark');
});
test('payload conversion still rejects entities that JSON cannot serialize', () => {
const invalid = payload();
const circular: Record<string, unknown> = {};
circular.self = circular;
(invalid.hosts[0] as unknown as Record<string, unknown>).invalid = circular;
assert.throws(
() => createConvergentSyncStateFromPayload(invalid, 'seed', NOW),
/cannot be represented as JSON/,
);
});
test('trusted legacy diff preserves reorder-only entity and string collection edits', () => {
const baseline = payload();
baseline.hosts.push({
...baseline.hosts[0],
id: 'host-2',
label: 'Staging',
hostname: 'staging.example.com',
});
baseline.customGroups = ['prod', 'staging'];
const state = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
const legacy: SyncPayload = {
...baseline,
hosts: [baseline.hosts[1], baseline.hosts[0]],
customGroups: ['staging', 'prod'],
syncedAt: NOW + 1,
};
const next = applyLegacySyncPayload(
state,
baseline,
legacy,
'legacy:github:remote-device',
NOW + 1,
);
const materialized = materializeSyncPayloadFromConvergentState(next, { syncedAt: NOW + 1 });
assert.deepEqual(materialized.hosts.map((host) => host.id), ['host-2', 'host-1']);
assert.deepEqual(materialized.customGroups, ['staging', 'prod']);
assert.equal(cloudSyncPayloadsEqual(materialized, legacy), true);
});
test('v1-only migration previews and creates a backward-compatible v2 payload', () => {
const local = payload();
const remote = {
...payload(),
snippets: [{ id: 'snippet-1', label: 'List', command: 'ls' }],
};
const plan = planConvergentSyncMigration({
localPayload: local,
localTrustedBaseline: null,
providers: [{
provider: 'github',
status: 'ready',
meta: meta(),
payload: remote,
trustedBaseline: local,
}],
deviceId: 'local-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, true);
assert.equal(plan.preview.oldClientCompatibility, 'materialized-v1-snapshot');
assert.equal(plan.payload?.snippets.length, 1);
assert.equal(plan.payload?.convergentSync?.schemaVersion, 2);
});
test('a fresh entity-empty device adopts v1 cloud settings instead of merging local defaults', () => {
const remote = payload('Remote');
remote.settings = { theme: 'dark' };
const plan = planConvergentSyncMigration({
localPayload: emptyPayload({ theme: 'light' }),
localTrustedBaseline: null,
providers: [{
provider: 'github',
status: 'ready',
meta: meta(),
payload: remote,
trustedBaseline: null,
}],
deviceId: 'fresh-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, true);
assert.equal(plan.payload?.hosts[0].label, 'Remote');
assert.equal(plan.payload?.settings?.theme, 'dark');
});
test('v1-only migration blocks divergent provider data without a trusted baseline', () => {
const local = payload('Stale local host');
const remote = emptyPayload();
const plan = planConvergentSyncMigration({
localPayload: local,
localTrustedBaseline: null,
providers: [{
provider: 'github',
status: 'ready',
meta: meta(),
payload: remote,
trustedBaseline: null,
}],
deviceId: 'local-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, false);
assert.match(plan.preview.blockedReasons.join(' '), /github: no trusted legacy baseline/);
assert.equal(plan.payload, null);
});
test('v1-only migration accepts matching provider data without a trusted baseline', () => {
const local = payload('Matching host');
const plan = planConvergentSyncMigration({
localPayload: local,
localTrustedBaseline: null,
providers: [{
provider: 'github',
status: 'ready',
meta: meta(),
payload: structuredClone(local),
trustedBaseline: null,
}],
deviceId: 'local-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, true);
assert.equal(plan.payload?.hosts[0]?.label, 'Matching host');
});
test('a fresh device still blocks a shrunk v1 provider used as the migration seed', () => {
const baseline = payload('Base');
baseline.hosts = Array.from({ length: 4 }, (_, index) => ({
...baseline.hosts[0],
id: `host-${index + 1}`,
label: `Host ${index + 1}`,
}));
const remote: SyncPayload = {
...baseline,
hosts: baseline.hosts.slice(0, 1),
syncedAt: NOW + 1,
};
const plan = planConvergentSyncMigration({
localPayload: emptyPayload(),
localTrustedBaseline: null,
providers: [{
provider: 'github',
status: 'ready',
meta: meta(),
payload: remote,
trustedBaseline: baseline,
}],
deviceId: 'fresh-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, false);
assert.equal(plan.preview.shrinkFindings[0]?.provider, 'github');
assert.equal(plan.preview.shrinkFindings[0]?.finding.lost, 3);
assert.match(plan.preview.blockedReasons.join(' '), /remove too many entities/);
});
test('migration blocks unresolved v1 conflicts and future provider schemas', () => {
const baseline = payload('Base');
const conflict = planConvergentSyncMigration({
localPayload: payload('Local'),
localTrustedBaseline: baseline,
providers: [{
provider: 'github',
status: 'ready',
meta: meta(),
payload: payload('Remote'),
trustedBaseline: baseline,
}],
deviceId: 'local-device',
now: NOW + 1,
});
assert.equal(conflict.preview.canInitialize, false);
assert.match(conflict.preview.blockedReasons.join(' '), /unresolved conflicts/);
const future = planConvergentSyncMigration({
localPayload: baseline,
localTrustedBaseline: null,
providers: [{
provider: 'github',
status: 'ready',
meta: { ...meta(), syncSchemaVersion: 3 } as unknown as SyncFileMeta,
payload: baseline,
trustedBaseline: null,
}],
deviceId: 'local-device',
now: NOW + 1,
});
assert.equal(future.preview.canInitialize, false);
assert.equal(future.preview.providers[0].schemaVersion, 'future');
});
test('joining existing v2 data blocks changed legacy writers without a trusted baseline', () => {
const remoteState = createConvergentSyncStateFromPayload(payload('Remote'), 'remote', NOW);
const remotePayload = withConvergentSyncEnvelope(remoteState, { syncedAt: NOW });
const plan = planConvergentSyncMigration({
localPayload: payload('Unbased local edit'),
localTrustedBaseline: null,
providers: [{
provider: 'github',
status: 'ready',
meta: meta({ syncSchemaVersion: 2 }),
payload: remotePayload,
trustedBaseline: null,
}],
deviceId: 'local-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, false);
assert.match(plan.preview.blockedReasons.join(' '), /no trusted legacy baseline/);
});
test('joining existing v2 data blocks a shrunk legacy local source', () => {
const baseline = payload('Remote');
baseline.hosts = Array.from({ length: 4 }, (_, index) => ({
...baseline.hosts[0],
id: `host-${index + 1}`,
label: `Host ${index + 1}`,
}));
const local: SyncPayload = {
...baseline,
hosts: baseline.hosts.slice(0, 1),
syncedAt: NOW + 1,
};
const remoteState = createConvergentSyncStateFromPayload(baseline, 'remote', NOW);
const plan = planConvergentSyncMigration({
localPayload: local,
localTrustedBaseline: baseline,
providers: [{
provider: 'github',
status: 'ready',
meta: meta({ syncSchemaVersion: 2 }),
payload: withConvergentSyncEnvelope(remoteState, { syncedAt: NOW }),
trustedBaseline: null,
}],
deviceId: 'local-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, false);
assert.match(plan.preview.blockedReasons.join(' '), /legacy-local:local-device.*remove too many entities/);
});
test('joining existing v2 data blocks a shrunk legacy provider source', () => {
const baseline = payload('Remote');
baseline.hosts = Array.from({ length: 4 }, (_, index) => ({
...baseline.hosts[0],
id: `host-${index + 1}`,
label: `Host ${index + 1}`,
}));
const legacy: SyncPayload = {
...baseline,
hosts: baseline.hosts.slice(0, 1),
syncedAt: NOW + 1,
};
const remoteState = createConvergentSyncStateFromPayload(baseline, 'remote', NOW);
const plan = planConvergentSyncMigration({
localPayload: emptyPayload(),
localTrustedBaseline: null,
providers: [
{
provider: 'github',
status: 'ready',
meta: meta({ syncSchemaVersion: 2 }),
payload: withConvergentSyncEnvelope(remoteState, { syncedAt: NOW }),
trustedBaseline: null,
},
{
provider: 'webdav',
status: 'ready',
meta: meta({ deviceId: 'legacy-device' }),
payload: legacy,
trustedBaseline: baseline,
},
],
deviceId: 'fresh-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, false);
assert.equal(plan.preview.shrinkFindings[0]?.provider, 'webdav');
assert.equal(plan.preview.shrinkFindings[0]?.finding.lost, 3);
});
test('v2 migration shrink checks preserve optional collections omitted by legacy clients', () => {
const baseline = payload('Remote');
baseline.identities = Array.from({ length: 4 }, (_, index) => ({
id: `identity-${index + 1}`,
label: `Identity ${index + 1}`,
username: `user-${index + 1}`,
authMethod: 'password' as const,
created: NOW,
}));
const local = {
...baseline,
identities: undefined,
syncedAt: NOW + 1,
} as unknown as SyncPayload;
const remoteState = createConvergentSyncStateFromPayload(baseline, 'remote', NOW);
const plan = planConvergentSyncMigration({
localPayload: local,
localTrustedBaseline: baseline,
providers: [{
provider: 'github',
status: 'ready',
meta: meta({ syncSchemaVersion: 2 }),
payload: withConvergentSyncEnvelope(remoteState, { syncedAt: NOW }),
trustedBaseline: null,
}],
deviceId: 'local-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, true);
assert.equal(plan.preview.shrinkFindings.length, 0);
assert.equal(plan.payload?.identities?.length, 4);
});
test('a fresh entity-empty device adopts existing v2 data without a trusted baseline', () => {
const remoteState = createConvergentSyncStateFromPayload(payload('Remote'), 'remote', NOW);
const remotePayload = withConvergentSyncEnvelope(remoteState, { syncedAt: NOW });
const plan = planConvergentSyncMigration({
localPayload: emptyPayload({ theme: 'light' }),
localTrustedBaseline: null,
providers: [{
provider: 'github',
status: 'ready',
meta: meta({ syncSchemaVersion: 2 }),
payload: remotePayload,
trustedBaseline: null,
}],
deviceId: 'fresh-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, true);
assert.equal(plan.payload?.hosts[0].label, 'Remote');
assert.equal(plan.payload?.settings?.theme, 'dark');
});
test('an empty local snapshot with a trusted baseline remains a real deletion', () => {
const baseline = payload('Remote');
const remoteState = createConvergentSyncStateFromPayload(baseline, 'remote', NOW);
const remotePayload = withConvergentSyncEnvelope(remoteState, { syncedAt: NOW });
const plan = planConvergentSyncMigration({
localPayload: emptyPayload(),
localTrustedBaseline: baseline,
providers: [{
provider: 'github',
status: 'ready',
meta: meta({ syncSchemaVersion: 2 }),
payload: remotePayload,
trustedBaseline: null,
}],
deviceId: 'legacy-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, true);
assert.deepEqual(plan.payload?.hosts, []);
});

View File

@@ -0,0 +1,25 @@
export function createEmptyRecord<T>(): Record<string, T> {
return {};
}
export function getOwnRecordValue<T>(
record: Record<string, T>,
key: string,
): T | undefined {
return Object.prototype.hasOwnProperty.call(record, key)
? record[key]
: undefined;
}
export function setOwnRecordValue<T>(
record: Record<string, T>,
key: string,
value: T,
): void {
Object.defineProperty(record, key, {
configurable: true,
enumerable: true,
value,
writable: true,
});
}

View 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;
}

View File

@@ -0,0 +1,24 @@
import type { RegisterAddress } from './types';
/** Collision-free identity persisted for causal-origin validation. */
export function registerId(address: RegisterAddress): string {
switch (address.kind) {
case 'entity-presence':
return JSON.stringify([address.kind, address.collection, address.entityId]);
case 'entity-position':
return JSON.stringify([address.kind, address.collection, address.entityId]);
case 'entity-field':
return JSON.stringify([
address.kind,
address.collection,
address.entityId,
address.field,
]);
case 'setting':
return JSON.stringify([address.kind, ...address.path]);
case 'string-entry-presence':
return JSON.stringify([address.kind, address.collection, address.value]);
case 'string-entry-position':
return JSON.stringify([address.kind, address.collection, address.value]);
}
}

View 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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,261 @@
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue =
| JsonPrimitive
| JsonValue[]
| { [key: string]: JsonValue };
export type JsonObject = { [key: string]: JsonValue };
export interface VersionVector {
[deviceId: string]: number;
}
export interface DotOriginIndex {
[deviceId: string]: Record<string, string>;
}
export interface Dot {
deviceId: string;
counter: number;
}
export interface HybridLogicalClock {
wallTime: number;
logical: number;
}
interface RegisterCandidateBase {
dot: Dot;
/** Dots observed in this register before this candidate was written. */
context: Dot[];
hlc: HybridLogicalClock;
}
export interface RegisterValueCandidate<T extends JsonValue = JsonValue>
extends RegisterCandidateBase {
value: T;
tombstone?: false;
}
export interface RegisterTombstoneCandidate extends RegisterCandidateBase {
tombstone: true;
}
export type RegisterCandidate<T extends JsonValue = JsonValue> =
| RegisterValueCandidate<T>
| RegisterTombstoneCandidate;
export interface MultiValueRegister<T extends JsonValue = JsonValue> {
candidates: RegisterCandidate<T>[];
}
export type CollectionPosition = string | number;
export interface ConvergentEntityState {
presence: MultiValueRegister<boolean>;
position?: MultiValueRegister<CollectionPosition>;
fields: Record<string, MultiValueRegister>;
}
export interface ConvergentCollectionState {
entities: Record<string, ConvergentEntityState>;
}
export interface ConvergentStringEntryState {
presence: MultiValueRegister<boolean>;
position?: MultiValueRegister<CollectionPosition>;
}
export interface ConvergentStringCollectionState {
entries: Record<string, ConvergentStringEntryState>;
}
/**
* Pure CRDT state. The encrypted protocol envelope is introduced separately;
* this type deliberately contains no provider, persistence, or UI concerns.
*/
export interface ConvergentSyncStateV2 {
schemaVersion: 2;
vector: VersionVector;
/** Register identity for every allocated device counter. */
dotOrigins: DotOriginIndex;
hlc: HybridLogicalClock;
collections: Record<string, ConvergentCollectionState>;
settings: Record<string, MultiValueRegister>;
stringCollections: Record<string, ConvergentStringCollectionState>;
}
/**
* A register candidate stored in the encrypted cloud envelope. Winner values
* that already exist in the adjacent materialized v1 snapshot may be omitted
* and reconstructed during hydration. Structural values (presence and
* position) remain inline so the envelope is self-describing.
*/
export type ConvergentEnvelopeCandidate<T extends JsonValue = JsonValue> =
| RegisterTombstoneCandidate
| (Omit<RegisterValueCandidate<T>, 'value'> & {
value?: T;
materialized?: true;
});
export interface ConvergentEnvelopeRegister<T extends JsonValue = JsonValue> {
candidates: ConvergentEnvelopeCandidate<T>[];
}
export interface ConvergentEnvelopeEntityState {
presence: ConvergentEnvelopeRegister<boolean>;
position?: ConvergentEnvelopeRegister<CollectionPosition>;
fields: Record<string, ConvergentEnvelopeRegister>;
}
export interface ConvergentEnvelopeCollectionState {
entities: Record<string, ConvergentEnvelopeEntityState>;
}
export interface ConvergentEnvelopeStringEntryState {
presence: ConvergentEnvelopeRegister<boolean>;
position?: ConvergentEnvelopeRegister<CollectionPosition>;
}
export interface ConvergentEnvelopeStringCollectionState {
entries: Record<string, ConvergentEnvelopeStringEntryState>;
}
export interface ConvergentEnvelopeStateV2 {
vector: VersionVector;
dotOrigins: DotOriginIndex;
hlc: HybridLogicalClock;
collections: Record<string, ConvergentEnvelopeCollectionState>;
settings: Record<string, ConvergentEnvelopeRegister>;
stringCollections: Record<string, ConvergentEnvelopeStringCollectionState>;
}
/**
* Stored inside the AES-256-GCM encrypted SyncPayload. Plaintext metadata only
* advertises `syncSchemaVersion: 2`; candidate values never leave ciphertext.
*/
export interface ConvergentSyncEnvelopeV2 {
schemaVersion: 2;
encoding: 'materialized-winner-v1';
state: ConvergentEnvelopeStateV2;
}
export type RegisterAddress =
| {
kind: 'entity-presence';
collection: string;
entityId: string;
}
| {
kind: 'entity-position';
collection: string;
entityId: string;
}
| {
kind: 'entity-field';
collection: string;
entityId: string;
field: string;
}
| {
kind: 'setting';
path: string[];
}
| {
kind: 'string-entry-presence';
collection: string;
value: string;
}
| {
kind: 'string-entry-position';
collection: string;
value: string;
};
export type ConvergentConflictAddress = RegisterAddress | {
kind: 'setting-structure';
paths: string[][];
};
export type ConvergentMutation =
| {
kind: 'entity-upsert';
collection: string;
entityId: string;
value: JsonObject;
position?: CollectionPosition;
}
| {
kind: 'entity-field-set';
collection: string;
entityId: string;
field: string;
value: JsonValue;
}
| {
kind: 'entity-field-delete';
collection: string;
entityId: string;
field: string;
}
| {
kind: 'entity-delete';
collection: string;
entityId: string;
}
| {
kind: 'setting-set';
path: string[];
value: JsonValue;
}
| {
kind: 'setting-delete';
path: string[];
}
| {
kind: 'string-entry-add';
collection: string;
value: string;
position?: CollectionPosition;
}
| {
kind: 'string-entry-delete';
collection: string;
value: string;
}
| {
kind: 'resolve-register';
address: RegisterAddress;
value?: JsonValue;
tombstone?: boolean;
};
export interface ConvergentConflictCandidate {
dot: Dot;
hlc: HybridLogicalClock;
tombstone: boolean;
value?: JsonValue;
/** Present when candidates from multiple setting leaf paths conflict. */
settingPath?: string[];
selected: boolean;
}
export interface ConvergentFieldConflict {
address: ConvergentConflictAddress;
candidates: ConvergentConflictCandidate[];
}
export interface MaterializedConvergentSyncState {
collections: Record<string, JsonObject[]>;
settings: JsonObject;
stringCollections: Record<string, string[]>;
conflicts: ConvergentFieldConflict[];
}
export class ConvergentSyncInvariantError extends Error {
constructor(message: string) {
super(message);
this.name = 'ConvergentSyncInvariantError';
}
}