[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
98
domain/convergentSync/clock.ts
Normal file
98
domain/convergentSync/clock.ts
Normal 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 };
|
||||
}
|
||||
69
domain/convergentSync/conflicts.test.ts
Normal file
69
domain/convergentSync/conflicts.test.ts
Normal 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);
|
||||
});
|
||||
80
domain/convergentSync/conflicts.ts
Normal file
80
domain/convergentSync/conflicts.ts
Normal 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));
|
||||
}
|
||||
211
domain/convergentSync/convergentSync.property.test.ts
Normal file
211
domain/convergentSync/convergentSync.property.test.ts
Normal 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 });
|
||||
});
|
||||
1262
domain/convergentSync/convergentSync.test.ts
Normal file
1262
domain/convergentSync/convergentSync.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
12
domain/convergentSync/index.ts
Normal file
12
domain/convergentSync/index.ts
Normal 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';
|
||||
62
domain/convergentSync/json.ts
Normal file
62
domain/convergentSync/json.ts
Normal 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);
|
||||
}
|
||||
231
domain/convergentSync/legacy.ts
Normal file
231
domain/convergentSync/legacy.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
384
domain/convergentSync/migration.ts
Normal file
384
domain/convergentSync/migration.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
585
domain/convergentSync/payload.ts
Normal file
585
domain/convergentSync/payload.ts
Normal 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;
|
||||
}
|
||||
756
domain/convergentSync/payloadMigration.test.ts
Normal file
756
domain/convergentSync/payloadMigration.test.ts
Normal 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, []);
|
||||
});
|
||||
25
domain/convergentSync/record.ts
Normal file
25
domain/convergentSync/record.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
225
domain/convergentSync/register.ts
Normal file
225
domain/convergentSync/register.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
compareDots,
|
||||
compareHybridLogicalClocks,
|
||||
compareStrings,
|
||||
dotKey,
|
||||
} from './clock';
|
||||
import { canonicalJsonString, cloneJson } from './json';
|
||||
import {
|
||||
ConvergentSyncInvariantError,
|
||||
type Dot,
|
||||
type HybridLogicalClock,
|
||||
type JsonValue,
|
||||
type MultiValueRegister,
|
||||
type RegisterCandidate,
|
||||
} from './types';
|
||||
|
||||
export function isTombstoneCandidate(
|
||||
candidate: RegisterCandidate,
|
||||
): candidate is Extract<RegisterCandidate, { tombstone: true }> {
|
||||
return candidate.tombstone === true;
|
||||
}
|
||||
|
||||
export function cloneCandidate<T extends JsonValue>(
|
||||
candidate: RegisterCandidate<T>,
|
||||
): RegisterCandidate<T> {
|
||||
const base = {
|
||||
dot: {
|
||||
deviceId: candidate.dot.deviceId,
|
||||
counter: candidate.dot.counter,
|
||||
},
|
||||
context: candidate.context.map((dot) => ({
|
||||
deviceId: dot.deviceId,
|
||||
counter: dot.counter,
|
||||
})),
|
||||
hlc: {
|
||||
wallTime: candidate.hlc.wallTime,
|
||||
logical: candidate.hlc.logical,
|
||||
},
|
||||
};
|
||||
if (isTombstoneCandidate(candidate)) {
|
||||
return { ...base, tombstone: true };
|
||||
}
|
||||
return { ...base, value: cloneJson(candidate.value) };
|
||||
}
|
||||
|
||||
export function createRegisterCandidate<T extends JsonValue>(options: {
|
||||
dot: Dot;
|
||||
context: Dot[];
|
||||
hlc: HybridLogicalClock;
|
||||
value?: T;
|
||||
tombstone?: boolean;
|
||||
}): RegisterCandidate<T> {
|
||||
const base = {
|
||||
dot: {
|
||||
deviceId: options.dot.deviceId,
|
||||
counter: options.dot.counter,
|
||||
},
|
||||
context: options.context.map((dot) => ({
|
||||
deviceId: dot.deviceId,
|
||||
counter: dot.counter,
|
||||
})),
|
||||
hlc: {
|
||||
wallTime: options.hlc.wallTime,
|
||||
logical: options.hlc.logical,
|
||||
},
|
||||
};
|
||||
if (options.tombstone) {
|
||||
if (options.value !== undefined) {
|
||||
throw new ConvergentSyncInvariantError('A tombstone candidate cannot contain a value');
|
||||
}
|
||||
return { ...base, tombstone: true };
|
||||
}
|
||||
if (options.value === undefined) {
|
||||
throw new ConvergentSyncInvariantError('A non-tombstone candidate requires a value');
|
||||
}
|
||||
return { ...base, value: cloneJson(options.value) };
|
||||
}
|
||||
|
||||
function canonicalContext(context: Dot[]): string {
|
||||
return JSON.stringify(
|
||||
context
|
||||
.map((dot) => ({ deviceId: dot.deviceId, counter: dot.counter }))
|
||||
.sort(compareDots),
|
||||
);
|
||||
}
|
||||
|
||||
function candidateFingerprint(candidate: RegisterCandidate): string {
|
||||
return JSON.stringify({
|
||||
dot: {
|
||||
deviceId: candidate.dot.deviceId,
|
||||
counter: candidate.dot.counter,
|
||||
},
|
||||
context: canonicalContext(candidate.context),
|
||||
hlc: {
|
||||
wallTime: candidate.hlc.wallTime,
|
||||
logical: candidate.hlc.logical,
|
||||
},
|
||||
tombstone: isTombstoneCandidate(candidate),
|
||||
value: isTombstoneCandidate(candidate)
|
||||
? undefined
|
||||
: canonicalJsonString(candidate.value),
|
||||
});
|
||||
}
|
||||
|
||||
function assertEquivalentCandidates(
|
||||
left: RegisterCandidate,
|
||||
right: RegisterCandidate,
|
||||
): void {
|
||||
if (candidateFingerprint(left) !== candidateFingerprint(right)) {
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`Dot ${dotKey(left.dot)} has conflicting candidate payloads`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function candidateDominates(
|
||||
winner: RegisterCandidate,
|
||||
candidate: RegisterCandidate,
|
||||
): boolean {
|
||||
return winner.context.some((dot) => dotKey(dot) === dotKey(candidate.dot));
|
||||
}
|
||||
|
||||
export function registerCausalContext(
|
||||
register: MultiValueRegister | undefined,
|
||||
): Dot[] {
|
||||
const context = new Map<string, Dot>();
|
||||
const observe = (dot: Dot) => {
|
||||
context.set(dotKey(dot), {
|
||||
deviceId: dot.deviceId,
|
||||
counter: dot.counter,
|
||||
});
|
||||
};
|
||||
for (const candidate of register?.candidates ?? []) {
|
||||
candidate.context.forEach(observe);
|
||||
observe(candidate.dot);
|
||||
}
|
||||
return [...context.values()].sort(compareDots);
|
||||
}
|
||||
|
||||
export function compareRegisterCandidates(
|
||||
left: RegisterCandidate,
|
||||
right: RegisterCandidate,
|
||||
): number {
|
||||
const leftTombstone = isTombstoneCandidate(left);
|
||||
const rightTombstone = isTombstoneCandidate(right);
|
||||
if (leftTombstone !== rightTombstone) return leftTombstone ? -1 : 1;
|
||||
|
||||
const clockOrder = compareHybridLogicalClocks(left.hlc, right.hlc);
|
||||
if (clockOrder !== 0) return clockOrder;
|
||||
|
||||
const deviceOrder = compareStrings(left.dot.deviceId, right.dot.deviceId);
|
||||
if (deviceOrder !== 0) return deviceOrder;
|
||||
return left.dot.counter - right.dot.counter;
|
||||
}
|
||||
|
||||
export function compareCandidatesByDot(
|
||||
left: RegisterCandidate,
|
||||
right: RegisterCandidate,
|
||||
): number {
|
||||
return compareDots(left.dot, right.dot);
|
||||
}
|
||||
|
||||
export function selectRegisterWinner<T extends JsonValue>(
|
||||
register: MultiValueRegister<T> | undefined,
|
||||
): RegisterCandidate<T> | undefined {
|
||||
if (!register || register.candidates.length === 0) return undefined;
|
||||
return register.candidates.reduce((winner, candidate) =>
|
||||
compareRegisterCandidates(candidate, winner) > 0 ? candidate : winner,
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeMultiValueRegisters<T extends JsonValue>(
|
||||
left: MultiValueRegister<T> | undefined,
|
||||
right: MultiValueRegister<T> | undefined,
|
||||
): MultiValueRegister<T> | undefined {
|
||||
const leftCandidates = left?.candidates ?? [];
|
||||
const rightCandidates = right?.candidates ?? [];
|
||||
const leftContext = registerCausalContext(left);
|
||||
const rightContext = registerCausalContext(right);
|
||||
const leftByDot = new Map(leftCandidates.map((candidate) => [dotKey(candidate.dot), candidate]));
|
||||
const rightByDot = new Map(rightCandidates.map((candidate) => [dotKey(candidate.dot), candidate]));
|
||||
const candidates: RegisterCandidate<T>[] = [];
|
||||
|
||||
for (const key of new Set([...leftByDot.keys(), ...rightByDot.keys()])) {
|
||||
const leftCandidate = leftByDot.get(key);
|
||||
const rightCandidate = rightByDot.get(key);
|
||||
|
||||
if (leftCandidate && rightCandidate) {
|
||||
assertEquivalentCandidates(leftCandidate, rightCandidate);
|
||||
candidates.push(cloneCandidate(leftCandidate));
|
||||
} else if (
|
||||
leftCandidate
|
||||
&& !rightContext.some((dot) => dotKey(dot) === dotKey(leftCandidate.dot))
|
||||
) {
|
||||
candidates.push(cloneCandidate(leftCandidate));
|
||||
} else if (
|
||||
rightCandidate
|
||||
&& !leftContext.some((dot) => dotKey(dot) === dotKey(rightCandidate.dot))
|
||||
) {
|
||||
candidates.push(cloneCandidate(rightCandidate));
|
||||
}
|
||||
}
|
||||
|
||||
const maximal = candidates.filter((candidate, index) =>
|
||||
!candidates.some((other, otherIndex) =>
|
||||
index !== otherIndex && candidateDominates(other, candidate),
|
||||
),
|
||||
);
|
||||
|
||||
if (maximal.length === 0) return undefined;
|
||||
maximal.sort(compareCandidatesByDot);
|
||||
return { candidates: maximal };
|
||||
}
|
||||
|
||||
export function registerHasConflict(register: MultiValueRegister): boolean {
|
||||
if (register.candidates.length < 2) return false;
|
||||
const distinctValues = new Set(
|
||||
register.candidates.map((candidate) =>
|
||||
isTombstoneCandidate(candidate)
|
||||
? '<tombstone>'
|
||||
: canonicalJsonString(candidate.value),
|
||||
),
|
||||
);
|
||||
return distinctValues.size > 1;
|
||||
}
|
||||
24
domain/convergentSync/registerId.ts
Normal file
24
domain/convergentSync/registerId.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
555
domain/convergentSync/serialization.ts
Normal file
555
domain/convergentSync/serialization.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import {
|
||||
compareCandidatesByDot,
|
||||
isTombstoneCandidate,
|
||||
} from './register';
|
||||
import { compareDots, compareHybridLogicalClocks, dotKey } from './clock';
|
||||
import { canonicalizeJson, cloneJson, isJsonValue } from './json';
|
||||
import { getOwnRecordValue } from './record';
|
||||
import { registerId } from './registerId';
|
||||
import {
|
||||
ConvergentSyncInvariantError,
|
||||
type ConvergentCollectionState,
|
||||
type ConvergentEntityState,
|
||||
type ConvergentStringCollectionState,
|
||||
type ConvergentStringEntryState,
|
||||
type ConvergentSyncStateV2,
|
||||
type Dot,
|
||||
type DotOriginIndex,
|
||||
type JsonValue,
|
||||
type MultiValueRegister,
|
||||
type RegisterCandidate,
|
||||
type VersionVector,
|
||||
} from './types';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function assertNonNegativeInteger(value: unknown, label: string): asserts value is number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new ConvergentSyncInvariantError(`${label} must be a non-negative integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveInteger(value: unknown, label: string): asserts value is number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) <= 0) {
|
||||
throw new ConvergentSyncInvariantError(`${label} must be a positive integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonEmptyKey(value: string, label: string): void {
|
||||
if (value.length === 0) {
|
||||
throw new ConvergentSyncInvariantError(`${label} must not be empty`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertVersionVector(value: unknown, label: string): asserts value is VersionVector {
|
||||
if (!isRecord(value)) {
|
||||
throw new ConvergentSyncInvariantError(`${label} must be an object`);
|
||||
}
|
||||
for (const [deviceId, counter] of Object.entries(value)) {
|
||||
assertNonEmptyKey(deviceId, `${label} device ID`);
|
||||
assertPositiveInteger(counter, `${label}.${deviceId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertDotOrigins(
|
||||
value: unknown,
|
||||
vector: VersionVector,
|
||||
): asserts value is DotOriginIndex {
|
||||
if (!isRecord(value)) {
|
||||
throw new ConvergentSyncInvariantError('dotOrigins must be an object');
|
||||
}
|
||||
for (const [deviceId, origins] of Object.entries(value)) {
|
||||
assertNonEmptyKey(deviceId, 'dotOrigins device ID');
|
||||
if (!isRecord(origins)) {
|
||||
throw new ConvergentSyncInvariantError(`dotOrigins.${deviceId} must be an object`);
|
||||
}
|
||||
const vectorCounter = getOwnRecordValue(vector, deviceId);
|
||||
if (!vectorCounter || Object.keys(origins).length !== vectorCounter) {
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`dotOrigins.${deviceId} must cover every counter in the state vector`,
|
||||
);
|
||||
}
|
||||
for (let counter = 1; counter <= vectorCounter; counter += 1) {
|
||||
const origin = getOwnRecordValue(origins, String(counter));
|
||||
if (typeof origin !== 'string' || origin.length === 0) {
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`dotOrigins.${deviceId}.${counter} must contain a register identity`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const deviceId of Object.keys(vector)) {
|
||||
if (!Object.hasOwn(value, deviceId)) {
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`dotOrigins.${deviceId} must cover every counter in the state vector`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertClock(value: unknown, label: string): void {
|
||||
if (!isRecord(value)) {
|
||||
throw new ConvergentSyncInvariantError(`${label} must be an object`);
|
||||
}
|
||||
assertNonNegativeInteger(value.wallTime, `${label}.wallTime`);
|
||||
assertNonNegativeInteger(value.logical, `${label}.logical`);
|
||||
}
|
||||
|
||||
function assertCoveredDot(
|
||||
value: unknown,
|
||||
state: ConvergentSyncStateV2,
|
||||
label: string,
|
||||
): asserts value is Dot {
|
||||
if (!isRecord(value)) {
|
||||
throw new ConvergentSyncInvariantError(`${label} must be a dot`);
|
||||
}
|
||||
if (typeof value.deviceId !== 'string' || value.deviceId.length === 0) {
|
||||
throw new ConvergentSyncInvariantError(`${label}.deviceId must not be empty`);
|
||||
}
|
||||
assertPositiveInteger(value.counter, `${label}.counter`);
|
||||
if ((getOwnRecordValue(state.vector, value.deviceId) ?? 0) < value.counter) {
|
||||
throw new ConvergentSyncInvariantError(`${label} is not covered by the state vector`);
|
||||
}
|
||||
}
|
||||
|
||||
function recordVectorWitness(
|
||||
witnessedDots: Map<string, Set<number>>,
|
||||
dot: Dot,
|
||||
): void {
|
||||
const counters = witnessedDots.get(dot.deviceId) ?? new Set<number>();
|
||||
counters.add(dot.counter);
|
||||
witnessedDots.set(dot.deviceId, counters);
|
||||
}
|
||||
|
||||
interface DotLocation {
|
||||
candidateLabel: string;
|
||||
registerIdentity: string;
|
||||
}
|
||||
|
||||
interface ContextReference {
|
||||
key: string;
|
||||
label: string;
|
||||
registerIdentity: string;
|
||||
}
|
||||
|
||||
function assertDotOrigin(
|
||||
state: ConvergentSyncStateV2,
|
||||
dot: Dot,
|
||||
expectedRegisterId: string,
|
||||
label: string,
|
||||
): void {
|
||||
const deviceOrigins = getOwnRecordValue(state.dotOrigins, dot.deviceId);
|
||||
const origin = deviceOrigins
|
||||
? getOwnRecordValue(deviceOrigins, String(dot.counter))
|
||||
: undefined;
|
||||
if (origin !== expectedRegisterId) {
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`${label} is assigned to a different register origin`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertVectorIsExactlyWitnessed(
|
||||
vector: VersionVector,
|
||||
witnessedDots: Map<string, Set<number>>,
|
||||
): void {
|
||||
for (const [deviceId, counter] of Object.entries(vector)) {
|
||||
const counters = witnessedDots.get(deviceId);
|
||||
if (!counters || counters.size !== counter || !counters.has(counter)) {
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`vector.${deviceId} is not witnessed by retained candidate dots and contexts`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertCandidate(
|
||||
value: unknown,
|
||||
state: ConvergentSyncStateV2,
|
||||
label: string,
|
||||
registerIdentity: string,
|
||||
globalDots: Map<string, DotLocation>,
|
||||
witnessedDots: Map<string, Set<number>>,
|
||||
contextReferences: ContextReference[],
|
||||
): asserts value is RegisterCandidate {
|
||||
if (!isRecord(value)) {
|
||||
throw new ConvergentSyncInvariantError(`${label} must contain a dot`);
|
||||
}
|
||||
const candidateDot = value.dot;
|
||||
assertCoveredDot(candidateDot, state, `${label}.dot`);
|
||||
assertDotOrigin(state, candidateDot, registerIdentity, `${label}.dot`);
|
||||
if (!Array.isArray(value.context)) {
|
||||
throw new ConvergentSyncInvariantError(`${label}.context must be an array of dots`);
|
||||
}
|
||||
const contextKeys = new Set<string>();
|
||||
value.context.forEach((contextDot, index) => {
|
||||
const contextLabel = `${label}.context[${index}]`;
|
||||
assertCoveredDot(contextDot, state, contextLabel);
|
||||
assertDotOrigin(state, contextDot, registerIdentity, contextLabel);
|
||||
const contextKey = dotKey(contextDot);
|
||||
if (contextKeys.has(contextKey)) {
|
||||
throw new ConvergentSyncInvariantError(`${label}.context contains duplicate dot ${contextKey}`);
|
||||
}
|
||||
if (contextKey === dotKey(candidateDot)) {
|
||||
throw new ConvergentSyncInvariantError(`${label}.context must not contain its own dot`);
|
||||
}
|
||||
if (
|
||||
contextDot.deviceId === candidateDot.deviceId
|
||||
&& contextDot.counter >= candidateDot.counter
|
||||
) {
|
||||
throw new ConvergentSyncInvariantError(`${contextLabel} must precede its own device dot`);
|
||||
}
|
||||
contextKeys.add(contextKey);
|
||||
contextReferences.push({ key: contextKey, label: contextLabel, registerIdentity });
|
||||
recordVectorWitness(witnessedDots, contextDot);
|
||||
});
|
||||
|
||||
const deviceId = candidateDot.deviceId;
|
||||
|
||||
assertClock(value.hlc, `${label}.hlc`);
|
||||
const candidateClock = value.hlc as { wallTime: number; logical: number };
|
||||
if (compareHybridLogicalClocks(candidateClock, state.hlc) > 0) {
|
||||
throw new ConvergentSyncInvariantError(`${label}.hlc exceeds the state clock`);
|
||||
}
|
||||
|
||||
if (
|
||||
value.tombstone !== undefined
|
||||
&& value.tombstone !== false
|
||||
&& value.tombstone !== true
|
||||
) {
|
||||
throw new ConvergentSyncInvariantError(`${label}.tombstone must be a boolean`);
|
||||
}
|
||||
const tombstone = value.tombstone === true;
|
||||
if (!tombstone && !isJsonValue(value.value)) {
|
||||
throw new ConvergentSyncInvariantError(`${label}.value must be valid JSON`);
|
||||
}
|
||||
if (tombstone && Object.prototype.hasOwnProperty.call(value, 'value')) {
|
||||
throw new ConvergentSyncInvariantError(`${label} tombstones must not contain a value`);
|
||||
}
|
||||
|
||||
const key = dotKey({ deviceId, counter: candidateDot.counter });
|
||||
const previousLocation = globalDots.get(key);
|
||||
if (previousLocation) {
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`Dot ${key} is reused by ${previousLocation.candidateLabel} and ${label}`,
|
||||
);
|
||||
}
|
||||
globalDots.set(key, { candidateLabel: label, registerIdentity });
|
||||
recordVectorWitness(witnessedDots, candidateDot);
|
||||
}
|
||||
|
||||
function assertRegister(
|
||||
value: unknown,
|
||||
state: ConvergentSyncStateV2,
|
||||
label: string,
|
||||
registerIdentity: string,
|
||||
globalDots: Map<string, DotLocation>,
|
||||
witnessedDots: Map<string, Set<number>>,
|
||||
contextReferences: ContextReference[],
|
||||
valueValidator?: (candidate: RegisterCandidate, label: string) => void,
|
||||
): asserts value is MultiValueRegister {
|
||||
if (!isRecord(value) || !Array.isArray(value.candidates) || value.candidates.length === 0) {
|
||||
throw new ConvergentSyncInvariantError(`${label} must contain at least one candidate`);
|
||||
}
|
||||
value.candidates.forEach((candidate, index) => {
|
||||
const candidateLabel = `${label}.candidates[${index}]`;
|
||||
assertCandidate(
|
||||
candidate,
|
||||
state,
|
||||
candidateLabel,
|
||||
registerIdentity,
|
||||
globalDots,
|
||||
witnessedDots,
|
||||
contextReferences,
|
||||
);
|
||||
valueValidator?.(candidate, candidateLabel);
|
||||
});
|
||||
}
|
||||
|
||||
function assertPresenceCandidate(candidate: RegisterCandidate, label: string): void {
|
||||
if (!isTombstoneCandidate(candidate) && candidate.value !== true) {
|
||||
throw new ConvergentSyncInvariantError(`${label} presence values must be true`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositionCandidate(candidate: RegisterCandidate, label: string): void {
|
||||
if (
|
||||
!isTombstoneCandidate(candidate)
|
||||
&& typeof candidate.value !== 'string'
|
||||
&& typeof candidate.value !== 'number'
|
||||
) {
|
||||
throw new ConvergentSyncInvariantError(`${label} position must be a string or number`);
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeSettingPath(path: string[]): string {
|
||||
if (path.length === 0 || path.some((segment) => segment.length === 0)) {
|
||||
throw new ConvergentSyncInvariantError('Setting paths require non-empty segments');
|
||||
}
|
||||
return `/${path.map((segment) => segment.replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}`;
|
||||
}
|
||||
|
||||
export function decodeSettingPath(encoded: string): string[] {
|
||||
if (!encoded.startsWith('/') || encoded.length === 1) {
|
||||
throw new ConvergentSyncInvariantError(`Invalid encoded setting path: ${encoded}`);
|
||||
}
|
||||
const path = encoded.slice(1).split('/').map((segment) =>
|
||||
segment.replaceAll('~1', '/').replaceAll('~0', '~'),
|
||||
);
|
||||
if (encodeSettingPath(path) !== encoded) {
|
||||
throw new ConvergentSyncInvariantError(`Non-canonical setting path: ${encoded}`);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function assertValidConvergentSyncState(
|
||||
value: unknown,
|
||||
): asserts value is ConvergentSyncStateV2 {
|
||||
if (!isRecord(value) || value.schemaVersion !== 2) {
|
||||
throw new ConvergentSyncInvariantError('Expected convergent sync schema version 2');
|
||||
}
|
||||
assertVersionVector(value.vector, 'vector');
|
||||
assertDotOrigins(value.dotOrigins, value.vector);
|
||||
assertClock(value.hlc, 'hlc');
|
||||
if (!isRecord(value.collections) || !isRecord(value.settings) || !isRecord(value.stringCollections)) {
|
||||
throw new ConvergentSyncInvariantError('Collections, settings, and stringCollections must be objects');
|
||||
}
|
||||
|
||||
const state = value as unknown as ConvergentSyncStateV2;
|
||||
const globalDots = new Map<string, DotLocation>();
|
||||
const witnessedDots = new Map<string, Set<number>>();
|
||||
const contextReferences: ContextReference[] = [];
|
||||
for (const [collectionName, collection] of Object.entries(state.collections)) {
|
||||
assertNonEmptyKey(collectionName, 'Collection name');
|
||||
if (!isRecord(collection) || !isRecord(collection.entities)) {
|
||||
throw new ConvergentSyncInvariantError(`Collection ${collectionName} must contain entities`);
|
||||
}
|
||||
for (const [entityId, entity] of Object.entries(collection.entities)) {
|
||||
assertNonEmptyKey(entityId, `Entity ID in ${collectionName}`);
|
||||
if (!isRecord(entity) || !isRecord(entity.fields)) {
|
||||
throw new ConvergentSyncInvariantError(`Entity ${collectionName}/${entityId} is invalid`);
|
||||
}
|
||||
const entityLabel = `collections.${collectionName}.${entityId}`;
|
||||
assertRegister(
|
||||
entity.presence,
|
||||
state,
|
||||
`${entityLabel}.presence`,
|
||||
registerId({ kind: 'entity-presence', collection: collectionName, entityId }),
|
||||
globalDots,
|
||||
witnessedDots,
|
||||
contextReferences,
|
||||
assertPresenceCandidate,
|
||||
);
|
||||
if (entity.position !== undefined) {
|
||||
assertRegister(
|
||||
entity.position,
|
||||
state,
|
||||
`${entityLabel}.position`,
|
||||
registerId({ kind: 'entity-position', collection: collectionName, entityId }),
|
||||
globalDots,
|
||||
witnessedDots,
|
||||
contextReferences,
|
||||
assertPositionCandidate,
|
||||
);
|
||||
}
|
||||
for (const [field, register] of Object.entries(entity.fields)) {
|
||||
assertNonEmptyKey(field, `${entityLabel} field`);
|
||||
if (field === 'id') {
|
||||
throw new ConvergentSyncInvariantError(`${entityLabel} must not store structural ID as a field`);
|
||||
}
|
||||
assertRegister(
|
||||
register,
|
||||
state,
|
||||
`${entityLabel}.fields.${field}`,
|
||||
registerId({ kind: 'entity-field', collection: collectionName, entityId, field }),
|
||||
globalDots,
|
||||
witnessedDots,
|
||||
contextReferences,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [encodedPath, register] of Object.entries(state.settings)) {
|
||||
decodeSettingPath(encodedPath);
|
||||
assertRegister(
|
||||
register,
|
||||
state,
|
||||
`settings.${encodedPath}`,
|
||||
registerId({ kind: 'setting', path: decodeSettingPath(encodedPath) }),
|
||||
globalDots,
|
||||
witnessedDots,
|
||||
contextReferences,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [collectionName, collection] of Object.entries(state.stringCollections)) {
|
||||
assertNonEmptyKey(collectionName, 'String collection name');
|
||||
if (!isRecord(collection) || !isRecord(collection.entries)) {
|
||||
throw new ConvergentSyncInvariantError(`String collection ${collectionName} must contain entries`);
|
||||
}
|
||||
for (const [entryValue, entry] of Object.entries(collection.entries)) {
|
||||
assertNonEmptyKey(entryValue, `Entry value in ${collectionName}`);
|
||||
if (!isRecord(entry)) {
|
||||
throw new ConvergentSyncInvariantError(`String entry ${collectionName}/${entryValue} is invalid`);
|
||||
}
|
||||
const entryLabel = `stringCollections.${collectionName}.${entryValue}`;
|
||||
assertRegister(
|
||||
entry.presence,
|
||||
state,
|
||||
`${entryLabel}.presence`,
|
||||
registerId({
|
||||
kind: 'string-entry-presence',
|
||||
collection: collectionName,
|
||||
value: entryValue,
|
||||
}),
|
||||
globalDots,
|
||||
witnessedDots,
|
||||
contextReferences,
|
||||
assertPresenceCandidate,
|
||||
);
|
||||
if (entry.position !== undefined) {
|
||||
assertRegister(
|
||||
entry.position,
|
||||
state,
|
||||
`${entryLabel}.position`,
|
||||
registerId({
|
||||
kind: 'string-entry-position',
|
||||
collection: collectionName,
|
||||
value: entryValue,
|
||||
}),
|
||||
globalDots,
|
||||
witnessedDots,
|
||||
contextReferences,
|
||||
assertPositionCandidate,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const reference of contextReferences) {
|
||||
const retainedLocation = globalDots.get(reference.key);
|
||||
if (retainedLocation) {
|
||||
const location = retainedLocation.registerIdentity === reference.registerIdentity
|
||||
? 'the same register'
|
||||
: 'another register';
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`${reference.label} references candidate dot ${reference.key} retained in ${location}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assertVectorIsExactlyWitnessed(state.vector, witnessedDots);
|
||||
}
|
||||
|
||||
function sortRecord<T>(record: Record<string, T>, clone: (value: T) => T): Record<string, T> {
|
||||
return Object.fromEntries(
|
||||
Object.keys(record).sort().map((key) => [key, clone(record[key])]),
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalCandidate<T extends JsonValue>(
|
||||
candidate: RegisterCandidate<T>,
|
||||
): RegisterCandidate<T> {
|
||||
const base = {
|
||||
dot: {
|
||||
deviceId: candidate.dot.deviceId,
|
||||
counter: candidate.dot.counter,
|
||||
},
|
||||
context: candidate.context
|
||||
.map((dot) => ({
|
||||
deviceId: dot.deviceId,
|
||||
counter: dot.counter,
|
||||
}))
|
||||
.sort(compareDots),
|
||||
hlc: {
|
||||
wallTime: candidate.hlc.wallTime,
|
||||
logical: candidate.hlc.logical,
|
||||
},
|
||||
};
|
||||
if (isTombstoneCandidate(candidate)) return { ...base, tombstone: true };
|
||||
return { ...base, value: canonicalizeJson(cloneJson(candidate.value)) };
|
||||
}
|
||||
|
||||
function canonicalRegister<T extends JsonValue>(
|
||||
register: MultiValueRegister<T>,
|
||||
): MultiValueRegister<T> {
|
||||
return {
|
||||
candidates: register.candidates
|
||||
.map(canonicalCandidate)
|
||||
.sort(compareCandidatesByDot),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalEntity(entity: ConvergentEntityState): ConvergentEntityState {
|
||||
return {
|
||||
presence: canonicalRegister(entity.presence),
|
||||
...(entity.position ? { position: canonicalRegister(entity.position) } : {}),
|
||||
fields: sortRecord(entity.fields, canonicalRegister),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalCollection(collection: ConvergentCollectionState): ConvergentCollectionState {
|
||||
return { entities: sortRecord(collection.entities, canonicalEntity) };
|
||||
}
|
||||
|
||||
function canonicalStringEntry(entry: ConvergentStringEntryState): ConvergentStringEntryState {
|
||||
return {
|
||||
presence: canonicalRegister(entry.presence),
|
||||
...(entry.position ? { position: canonicalRegister(entry.position) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalStringCollection(
|
||||
collection: ConvergentStringCollectionState,
|
||||
): ConvergentStringCollectionState {
|
||||
return { entries: sortRecord(collection.entries, canonicalStringEntry) };
|
||||
}
|
||||
|
||||
function canonicalDotOrigins(origins: DotOriginIndex): DotOriginIndex {
|
||||
return Object.fromEntries(
|
||||
Object.keys(origins).sort().map((deviceId) => [
|
||||
deviceId,
|
||||
Object.fromEntries(
|
||||
Object.entries(origins[deviceId])
|
||||
.sort(([left], [right]) => Number(left) - Number(right)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function canonicalizeConvergentSyncState(
|
||||
state: ConvergentSyncStateV2,
|
||||
): ConvergentSyncStateV2 {
|
||||
assertValidConvergentSyncState(state);
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
vector: sortRecord(state.vector, (counter) => counter),
|
||||
dotOrigins: canonicalDotOrigins(state.dotOrigins),
|
||||
hlc: {
|
||||
wallTime: state.hlc.wallTime,
|
||||
logical: state.hlc.logical,
|
||||
},
|
||||
collections: sortRecord(state.collections, canonicalCollection),
|
||||
settings: sortRecord(state.settings, canonicalRegister),
|
||||
stringCollections: sortRecord(state.stringCollections, canonicalStringCollection),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeConvergentSyncState(state: ConvergentSyncStateV2): string {
|
||||
return JSON.stringify(canonicalizeConvergentSyncState(state));
|
||||
}
|
||||
|
||||
export function hydrateConvergentSyncState(serialized: string): ConvergentSyncStateV2 {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(serialized) as unknown;
|
||||
} catch (error) {
|
||||
throw new ConvergentSyncInvariantError(
|
||||
`Invalid convergent sync JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
assertValidConvergentSyncState(parsed);
|
||||
return canonicalizeConvergentSyncState(parsed);
|
||||
}
|
||||
1135
domain/convergentSync/state.ts
Normal file
1135
domain/convergentSync/state.ts
Normal file
File diff suppressed because it is too large
Load Diff
261
domain/convergentSync/types.ts
Normal file
261
domain/convergentSync/types.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user