1263 lines
42 KiB
TypeScript
1263 lines
42 KiB
TypeScript
|
|
import assert from 'node:assert/strict';
|
||
|
|
import test from 'node:test';
|
||
|
|
|
||
|
|
import {
|
||
|
|
ConvergentSyncInvariantError,
|
||
|
|
applyConvergentMutations,
|
||
|
|
createConvergentSyncState,
|
||
|
|
decodeSettingPath,
|
||
|
|
encodeSettingPath,
|
||
|
|
hydrateConvergentSyncState,
|
||
|
|
materializeConvergentSyncState,
|
||
|
|
mergeConvergentSyncStates,
|
||
|
|
registerId,
|
||
|
|
serializeConvergentSyncState,
|
||
|
|
tickHybridLogicalClock,
|
||
|
|
type ConvergentSyncStateV2,
|
||
|
|
} from './index.ts';
|
||
|
|
|
||
|
|
const BASE_TIME = 1_700_000_000_000;
|
||
|
|
|
||
|
|
function hostBase() {
|
||
|
|
return applyConvergentMutations(createConvergentSyncState(), 'seed', [{
|
||
|
|
kind: 'entity-upsert',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
value: {
|
||
|
|
id: 'host-1',
|
||
|
|
label: 'Production',
|
||
|
|
hostname: 'old.example.com',
|
||
|
|
tags: ['prod'],
|
||
|
|
},
|
||
|
|
position: 0,
|
||
|
|
}], BASE_TIME);
|
||
|
|
}
|
||
|
|
|
||
|
|
test('independent entity fields merge without conflict', () => {
|
||
|
|
const base = hostBase();
|
||
|
|
const left = applyConvergentMutations(base, 'device-a', [{
|
||
|
|
kind: 'entity-field-set',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
field: 'label',
|
||
|
|
value: 'Primary',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const right = applyConvergentMutations(base, 'device-b', [{
|
||
|
|
kind: 'entity-field-set',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
field: 'hostname',
|
||
|
|
value: 'new.example.com',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
|
||
|
|
const materialized = materializeConvergentSyncState(
|
||
|
|
mergeConvergentSyncStates(left, right),
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.deepEqual(materialized.collections.hosts, [{
|
||
|
|
id: 'host-1',
|
||
|
|
hostname: 'new.example.com',
|
||
|
|
label: 'Primary',
|
||
|
|
tags: ['prod'],
|
||
|
|
}]);
|
||
|
|
assert.deepEqual(materialized.conflicts, []);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('same-field concurrent values are retained with a deterministic winner', () => {
|
||
|
|
const base = hostBase();
|
||
|
|
const left = applyConvergentMutations(base, 'device-a', [{
|
||
|
|
kind: 'entity-field-set',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
field: 'label',
|
||
|
|
value: 'Left',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const right = applyConvergentMutations(base, 'device-z', [{
|
||
|
|
kind: 'entity-field-set',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
field: 'label',
|
||
|
|
value: 'Right',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
|
||
|
|
const materialized = materializeConvergentSyncState(
|
||
|
|
mergeConvergentSyncStates(right, left),
|
||
|
|
);
|
||
|
|
const conflict = materialized.conflicts.find(
|
||
|
|
(item) => item.address.kind === 'entity-field' && item.address.field === 'label',
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.equal(materialized.collections.hosts[0]?.label, 'Right');
|
||
|
|
assert.equal(conflict?.candidates.length, 2);
|
||
|
|
assert.deepEqual(
|
||
|
|
conflict?.candidates.map((candidate) => candidate.value).sort(),
|
||
|
|
['Left', 'Right'],
|
||
|
|
);
|
||
|
|
assert.equal(conflict?.candidates.filter((candidate) => candidate.selected).length, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('concurrent delete and update remains visible as a presence conflict', () => {
|
||
|
|
const base = hostBase();
|
||
|
|
const deleted = applyConvergentMutations(base, 'device-a', [{
|
||
|
|
kind: 'entity-delete',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const updated = applyConvergentMutations(base, 'device-b', [{
|
||
|
|
kind: 'entity-field-set',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
field: 'hostname',
|
||
|
|
value: 'edited.example.com',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
|
||
|
|
const materialized = materializeConvergentSyncState(
|
||
|
|
mergeConvergentSyncStates(deleted, updated),
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.equal(materialized.collections.hosts.length, 1);
|
||
|
|
assert.equal(materialized.collections.hosts[0]?.hostname, 'edited.example.com');
|
||
|
|
assert.ok(materialized.conflicts.some(
|
||
|
|
(conflict) => conflict.address.kind === 'entity-presence',
|
||
|
|
));
|
||
|
|
});
|
||
|
|
|
||
|
|
test('deleted entities suppress stale field and position conflicts', () => {
|
||
|
|
const base = hostBase();
|
||
|
|
const left = applyConvergentMutations(base, 'device-a', [{
|
||
|
|
kind: 'entity-upsert',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
value: {
|
||
|
|
id: 'host-1',
|
||
|
|
label: 'Left',
|
||
|
|
hostname: 'old.example.com',
|
||
|
|
tags: ['prod'],
|
||
|
|
},
|
||
|
|
position: 1,
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const right = applyConvergentMutations(base, 'device-b', [{
|
||
|
|
kind: 'entity-upsert',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
value: {
|
||
|
|
id: 'host-1',
|
||
|
|
label: 'Right',
|
||
|
|
hostname: 'old.example.com',
|
||
|
|
tags: ['prod'],
|
||
|
|
},
|
||
|
|
position: 2,
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const conflicted = mergeConvergentSyncStates(left, right);
|
||
|
|
const deleted = applyConvergentMutations(conflicted, 'device-c', [{
|
||
|
|
kind: 'entity-delete',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
}], BASE_TIME + 2);
|
||
|
|
const entity = deleted.collections.hosts.entities['host-1'];
|
||
|
|
const materialized = materializeConvergentSyncState(deleted);
|
||
|
|
|
||
|
|
assert.equal(entity.fields.label.candidates.length, 2);
|
||
|
|
assert.equal(entity.position?.candidates.length, 2);
|
||
|
|
assert.deepEqual(materialized.collections.hosts, []);
|
||
|
|
assert.deepEqual(materialized.conflicts, []);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('visible entities retain internal conflicts during a presence conflict', () => {
|
||
|
|
const base = hostBase();
|
||
|
|
const left = applyConvergentMutations(base, 'device-a', [{
|
||
|
|
kind: 'entity-field-set',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
field: 'label',
|
||
|
|
value: 'Left',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const right = applyConvergentMutations(base, 'device-b', [{
|
||
|
|
kind: 'entity-field-set',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
field: 'label',
|
||
|
|
value: 'Right',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const deleted = applyConvergentMutations(base, 'device-c', [{
|
||
|
|
kind: 'entity-delete',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const materialized = materializeConvergentSyncState(
|
||
|
|
mergeConvergentSyncStates(mergeConvergentSyncStates(left, right), deleted),
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.equal(materialized.collections.hosts.length, 1);
|
||
|
|
assert.ok(materialized.conflicts.some(
|
||
|
|
(conflict) => conflict.address.kind === 'entity-presence',
|
||
|
|
));
|
||
|
|
assert.ok(materialized.conflicts.some(
|
||
|
|
(conflict) => conflict.address.kind === 'entity-field'
|
||
|
|
&& conflict.address.field === 'label',
|
||
|
|
));
|
||
|
|
});
|
||
|
|
|
||
|
|
test('full entity upserts resolve an accepted presence conflict', () => {
|
||
|
|
const base = hostBase();
|
||
|
|
const deleted = applyConvergentMutations(base, 'device-a', [{
|
||
|
|
kind: 'entity-delete',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const updated = applyConvergentMutations(base, 'device-b', [{
|
||
|
|
kind: 'entity-field-set',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
field: 'hostname',
|
||
|
|
value: 'edited.example.com',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const conflicted = mergeConvergentSyncStates(deleted, updated);
|
||
|
|
const selected = materializeConvergentSyncState(conflicted).collections.hosts[0];
|
||
|
|
const resolved = applyConvergentMutations(conflicted, 'resolver', [{
|
||
|
|
kind: 'entity-upsert',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
value: selected,
|
||
|
|
position: 0,
|
||
|
|
}], BASE_TIME + 2);
|
||
|
|
const materialized = materializeConvergentSyncState(resolved);
|
||
|
|
|
||
|
|
assert.equal(materialized.collections.hosts[0]?.hostname, 'edited.example.com');
|
||
|
|
assert.equal(materialized.conflicts.length, 0);
|
||
|
|
assert.equal(resolved.vector.resolver, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('full entity upserts resolve same-value field conflicts', () => {
|
||
|
|
const base = hostBase();
|
||
|
|
const left = applyConvergentMutations(base, 'device-a', [{
|
||
|
|
kind: 'entity-field-set',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
field: 'label',
|
||
|
|
value: 'Left',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const right = applyConvergentMutations(base, 'device-z', [{
|
||
|
|
kind: 'entity-field-set',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
field: 'label',
|
||
|
|
value: 'Right',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const conflicted = mergeConvergentSyncStates(left, right);
|
||
|
|
const selected = materializeConvergentSyncState(conflicted).collections.hosts[0];
|
||
|
|
const resolved = applyConvergentMutations(conflicted, 'resolver', [{
|
||
|
|
kind: 'entity-upsert',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
value: selected,
|
||
|
|
position: 0,
|
||
|
|
}], BASE_TIME + 2);
|
||
|
|
const materialized = materializeConvergentSyncState(resolved);
|
||
|
|
|
||
|
|
assert.equal(materialized.collections.hosts[0]?.label, 'Right');
|
||
|
|
assert.equal(materialized.conflicts.length, 0);
|
||
|
|
assert.equal(resolved.vector.resolver, 2);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('full entity upserts resolve same-value position conflicts', () => {
|
||
|
|
const base = hostBase();
|
||
|
|
const value = materializeConvergentSyncState(base).collections.hosts[0];
|
||
|
|
const left = applyConvergentMutations(base, 'device-a', [{
|
||
|
|
kind: 'entity-upsert',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
value,
|
||
|
|
position: 1,
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const right = applyConvergentMutations(base, 'device-z', [{
|
||
|
|
kind: 'entity-upsert',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
value,
|
||
|
|
position: 2,
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const conflicted = mergeConvergentSyncStates(left, right);
|
||
|
|
const selected = materializeConvergentSyncState(conflicted).collections.hosts[0];
|
||
|
|
const resolved = applyConvergentMutations(conflicted, 'resolver', [{
|
||
|
|
kind: 'entity-upsert',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
value: selected,
|
||
|
|
position: 2,
|
||
|
|
}], BASE_TIME + 2);
|
||
|
|
|
||
|
|
assert.equal(materializeConvergentSyncState(resolved).conflicts.length, 0);
|
||
|
|
assert.equal(resolved.vector.resolver, 2);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('unchanged full entity upserts do not advance the replica clock', () => {
|
||
|
|
const initial = hostBase();
|
||
|
|
const value = materializeConvergentSyncState(initial).collections.hosts[0];
|
||
|
|
const unchanged = applyConvergentMutations(initial, 'seed', [{
|
||
|
|
kind: 'entity-upsert',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
value,
|
||
|
|
position: 0,
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
|
||
|
|
assert.equal(
|
||
|
|
serializeConvergentSyncState(unchanged),
|
||
|
|
serializeConvergentSyncState(initial),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('no-op field sets do not resurrect concurrent entity deletions', () => {
|
||
|
|
const base = hostBase();
|
||
|
|
const unchanged = applyConvergentMutations(base, 'device-b', [{
|
||
|
|
kind: 'entity-field-set',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
field: 'label',
|
||
|
|
value: 'Production',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const deleted = applyConvergentMutations(base, 'device-a', [{
|
||
|
|
kind: 'entity-delete',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
|
||
|
|
assert.equal(
|
||
|
|
serializeConvergentSyncState(unchanged),
|
||
|
|
serializeConvergentSyncState(base),
|
||
|
|
);
|
||
|
|
assert.deepEqual(
|
||
|
|
materializeConvergentSyncState(
|
||
|
|
mergeConvergentSyncStates(deleted, unchanged),
|
||
|
|
).collections.hosts,
|
||
|
|
[],
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('deleting an absent field is a no-op and does not refresh presence', () => {
|
||
|
|
const base = hostBase();
|
||
|
|
const unchanged = applyConvergentMutations(base, 'device-b', [{
|
||
|
|
kind: 'entity-field-delete',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
field: 'description',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
|
||
|
|
assert.equal(
|
||
|
|
serializeConvergentSyncState(unchanged),
|
||
|
|
serializeConvergentSyncState(base),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('field deletion on a non-present entity does not recreate it', () => {
|
||
|
|
const deleted = applyConvergentMutations(hostBase(), 'device-a', [{
|
||
|
|
kind: 'entity-delete',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const fieldDeleted = applyConvergentMutations(deleted, 'device-b', [{
|
||
|
|
kind: 'entity-field-delete',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
field: 'label',
|
||
|
|
}], BASE_TIME + 2);
|
||
|
|
|
||
|
|
assert.deepEqual(
|
||
|
|
materializeConvergentSyncState(fieldDeleted).collections.hosts,
|
||
|
|
[],
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
fieldDeleted.collections.hosts.entities['host-1'].presence.candidates[0]?.tombstone,
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
fieldDeleted.collections.hosts.entities['host-1'].fields.label.candidates[0]?.tombstone,
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('causal deletion is not resurrected by a stale replica', () => {
|
||
|
|
const base = hostBase();
|
||
|
|
const deleted = applyConvergentMutations(base, 'device-a', [{
|
||
|
|
kind: 'entity-delete',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
|
||
|
|
const merged = mergeConvergentSyncStates(base, deleted);
|
||
|
|
assert.deepEqual(materializeConvergentSyncState(merged).collections.hosts, []);
|
||
|
|
assert.equal(
|
||
|
|
merged.collections.hosts.entities['host-1'].presence.candidates[0]?.tombstone,
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('explicit recreation causally dominates a tombstone', () => {
|
||
|
|
const deleted = applyConvergentMutations(hostBase(), 'device-a', [{
|
||
|
|
kind: 'entity-delete',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const recreated = applyConvergentMutations(deleted, 'device-b', [{
|
||
|
|
kind: 'entity-upsert',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
value: {
|
||
|
|
id: 'host-1',
|
||
|
|
label: 'Recreated',
|
||
|
|
hostname: 'new.example.com',
|
||
|
|
tags: [],
|
||
|
|
},
|
||
|
|
}], BASE_TIME + 2);
|
||
|
|
|
||
|
|
const materialized = materializeConvergentSyncState(
|
||
|
|
mergeConvergentSyncStates(deleted, recreated),
|
||
|
|
);
|
||
|
|
assert.equal(materialized.collections.hosts[0]?.label, 'Recreated');
|
||
|
|
assert.equal(materialized.conflicts.length, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('conflict resolution dominates all candidates and survives stale joins', () => {
|
||
|
|
const base = hostBase();
|
||
|
|
const left = applyConvergentMutations(base, 'device-a', [{
|
||
|
|
kind: 'entity-field-set',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
field: 'label',
|
||
|
|
value: 'Left',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const right = applyConvergentMutations(base, 'device-b', [{
|
||
|
|
kind: 'entity-field-set',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
field: 'label',
|
||
|
|
value: 'Right',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const conflicted = mergeConvergentSyncStates(left, right);
|
||
|
|
const resolved = applyConvergentMutations(conflicted, 'resolver', [{
|
||
|
|
kind: 'resolve-register',
|
||
|
|
address: {
|
||
|
|
kind: 'entity-field',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
field: 'label',
|
||
|
|
},
|
||
|
|
value: 'Reviewed',
|
||
|
|
}], BASE_TIME + 2);
|
||
|
|
|
||
|
|
const afterStaleJoin = materializeConvergentSyncState(
|
||
|
|
mergeConvergentSyncStates(resolved, conflicted),
|
||
|
|
);
|
||
|
|
assert.equal(afterStaleJoin.collections.hosts[0]?.label, 'Reviewed');
|
||
|
|
assert.equal(afterStaleJoin.conflicts.length, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('string collections use observed-remove presence and stable positions', () => {
|
||
|
|
const base = applyConvergentMutations(createConvergentSyncState(), 'seed', [
|
||
|
|
{ kind: 'string-entry-add', collection: 'customGroups', value: 'Beta', position: 2 },
|
||
|
|
{ kind: 'string-entry-add', collection: 'customGroups', value: 'Alpha', position: 1 },
|
||
|
|
], BASE_TIME);
|
||
|
|
const deleted = applyConvergentMutations(base, 'device-a', [{
|
||
|
|
kind: 'string-entry-delete',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
|
||
|
|
assert.deepEqual(
|
||
|
|
materializeConvergentSyncState(mergeConvergentSyncStates(base, deleted))
|
||
|
|
.stringCollections.customGroups,
|
||
|
|
['Beta'],
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('string-entry adds resolve a visible add/delete conflict', () => {
|
||
|
|
const seeded = applyConvergentMutations(createConvergentSyncState(), 'seed', [{
|
||
|
|
kind: 'string-entry-add',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const removed = applyConvergentMutations(seeded, 'device-a', [{
|
||
|
|
kind: 'string-entry-delete',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const concurrentAdd = applyConvergentMutations(createConvergentSyncState(), 'device-b', [{
|
||
|
|
kind: 'string-entry-add',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const conflicted = mergeConvergentSyncStates(removed, concurrentAdd);
|
||
|
|
const resolved = applyConvergentMutations(conflicted, 'resolver', [{
|
||
|
|
kind: 'string-entry-add',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
}], BASE_TIME + 2);
|
||
|
|
const materialized = materializeConvergentSyncState(resolved);
|
||
|
|
|
||
|
|
assert.deepEqual(materialized.stringCollections.customGroups, ['Alpha']);
|
||
|
|
assert.equal(materialized.conflicts.length, 0);
|
||
|
|
assert.equal(resolved.vector.resolver, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('string-entry adds resolve a same-value position conflict', () => {
|
||
|
|
const seeded = applyConvergentMutations(createConvergentSyncState(), 'seed', [{
|
||
|
|
kind: 'string-entry-add',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
position: 0,
|
||
|
|
}], BASE_TIME);
|
||
|
|
const left = applyConvergentMutations(seeded, 'device-a', [{
|
||
|
|
kind: 'string-entry-add',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
position: 1,
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const right = applyConvergentMutations(seeded, 'device-z', [{
|
||
|
|
kind: 'string-entry-add',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
position: 2,
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const conflicted = mergeConvergentSyncStates(left, right);
|
||
|
|
const resolved = applyConvergentMutations(conflicted, 'resolver', [{
|
||
|
|
kind: 'string-entry-add',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
position: 2,
|
||
|
|
}], BASE_TIME + 2);
|
||
|
|
const materialized = materializeConvergentSyncState(resolved);
|
||
|
|
|
||
|
|
assert.deepEqual(materialized.stringCollections.customGroups, ['Alpha']);
|
||
|
|
assert.equal(materialized.conflicts.length, 0);
|
||
|
|
assert.equal(resolved.vector.resolver, 2);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('deleted string entries suppress stale position conflicts', () => {
|
||
|
|
const seeded = applyConvergentMutations(createConvergentSyncState(), 'seed', [{
|
||
|
|
kind: 'string-entry-add',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
position: 0,
|
||
|
|
}], BASE_TIME);
|
||
|
|
const left = applyConvergentMutations(seeded, 'device-a', [{
|
||
|
|
kind: 'string-entry-add',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
position: 1,
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const right = applyConvergentMutations(seeded, 'device-b', [{
|
||
|
|
kind: 'string-entry-add',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
position: 2,
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const conflicted = mergeConvergentSyncStates(left, right);
|
||
|
|
const deleted = applyConvergentMutations(conflicted, 'device-c', [{
|
||
|
|
kind: 'string-entry-delete',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
}], BASE_TIME + 2);
|
||
|
|
const entry = deleted.stringCollections.customGroups.entries.Alpha;
|
||
|
|
const materialized = materializeConvergentSyncState(deleted);
|
||
|
|
|
||
|
|
assert.equal(entry.position?.candidates.length, 2);
|
||
|
|
assert.deepEqual(materialized.stringCollections.customGroups, []);
|
||
|
|
assert.deepEqual(materialized.conflicts, []);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('unchanged string-entry adds do not advance the replica clock', () => {
|
||
|
|
const initial = applyConvergentMutations(createConvergentSyncState(), 'device-a', [{
|
||
|
|
kind: 'string-entry-add',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
position: 0,
|
||
|
|
}], BASE_TIME);
|
||
|
|
const unchanged = applyConvergentMutations(initial, 'device-a', [{
|
||
|
|
kind: 'string-entry-add',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
position: 0,
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
|
||
|
|
assert.equal(
|
||
|
|
serializeConvergentSyncState(unchanged),
|
||
|
|
serializeConvergentSyncState(initial),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('unobserved string-entry deletes are no-ops and do not conflict with concurrent adds', () => {
|
||
|
|
const empty = createConvergentSyncState();
|
||
|
|
const deleted = applyConvergentMutations(empty, 'device-a', [{
|
||
|
|
kind: 'string-entry-delete',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const added = applyConvergentMutations(empty, 'device-b', [{
|
||
|
|
kind: 'string-entry-add',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const merged = materializeConvergentSyncState(
|
||
|
|
mergeConvergentSyncStates(deleted, added),
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.equal(
|
||
|
|
serializeConvergentSyncState(deleted),
|
||
|
|
serializeConvergentSyncState(empty),
|
||
|
|
);
|
||
|
|
assert.deepEqual(merged.stringCollections.customGroups, ['Alpha']);
|
||
|
|
assert.equal(merged.conflicts.length, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('string-entry deletes dominate a currently visible add/tombstone conflict', () => {
|
||
|
|
const seeded = applyConvergentMutations(createConvergentSyncState(), 'seed', [{
|
||
|
|
kind: 'string-entry-add',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const removed = applyConvergentMutations(seeded, 'device-a', [{
|
||
|
|
kind: 'string-entry-delete',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const concurrentAdd = applyConvergentMutations(createConvergentSyncState(), 'device-b', [{
|
||
|
|
kind: 'string-entry-add',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const conflicted = mergeConvergentSyncStates(removed, concurrentAdd);
|
||
|
|
const resolved = applyConvergentMutations(conflicted, 'device-c', [{
|
||
|
|
kind: 'string-entry-delete',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
}], BASE_TIME + 2);
|
||
|
|
const materialized = materializeConvergentSyncState(resolved);
|
||
|
|
|
||
|
|
assert.deepEqual(materialized.stringCollections.customGroups, []);
|
||
|
|
assert.equal(materialized.conflicts.length, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('repeated string-entry deletes do not advance the replica clock', () => {
|
||
|
|
const added = applyConvergentMutations(createConvergentSyncState(), 'device-a', [{
|
||
|
|
kind: 'string-entry-add',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const deleted = applyConvergentMutations(added, 'device-a', [{
|
||
|
|
kind: 'string-entry-delete',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const repeated = applyConvergentMutations(deleted, 'device-a', [{
|
||
|
|
kind: 'string-entry-delete',
|
||
|
|
collection: 'customGroups',
|
||
|
|
value: 'Alpha',
|
||
|
|
}], BASE_TIME + 2);
|
||
|
|
|
||
|
|
assert.equal(
|
||
|
|
serializeConvergentSyncState(repeated),
|
||
|
|
serializeConvergentSyncState(deleted),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('settings are leaf registers while arrays remain atomic values', () => {
|
||
|
|
const state = applyConvergentMutations(createConvergentSyncState(), 'device-a', [
|
||
|
|
{ kind: 'setting-set', path: ['terminal', 'fontSize'], value: 14 },
|
||
|
|
{ kind: 'setting-set', path: ['terminal', 'fallbackFonts'], value: ['A', 'B'] },
|
||
|
|
{ kind: 'setting-set', path: ['key/with~escapes'], value: true },
|
||
|
|
], BASE_TIME);
|
||
|
|
|
||
|
|
assert.deepEqual(materializeConvergentSyncState(state).settings, {
|
||
|
|
'key/with~escapes': true,
|
||
|
|
terminal: {
|
||
|
|
fallbackFonts: ['A', 'B'],
|
||
|
|
fontSize: 14,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
assert.equal(encodeSettingPath(['key/with~escapes']), '/key~1with~0escapes');
|
||
|
|
assert.deepEqual(decodeSettingPath('/key~1with~0escapes'), ['key/with~escapes']);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('unchanged setting writes do not advance the replica clock', () => {
|
||
|
|
const initial = applyConvergentMutations(createConvergentSyncState(), 'device-a', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['theme'],
|
||
|
|
value: 'dark',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const unchanged = applyConvergentMutations(initial, 'device-a', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['theme'],
|
||
|
|
value: 'dark',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
|
||
|
|
assert.equal(
|
||
|
|
serializeConvergentSyncState(unchanged),
|
||
|
|
serializeConvergentSyncState(initial),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('setting writes matching the selected conflict candidate still resolve the conflict', () => {
|
||
|
|
const dark = applyConvergentMutations(createConvergentSyncState(), 'device-z', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['theme'],
|
||
|
|
value: 'dark',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const light = applyConvergentMutations(createConvergentSyncState(), 'device-a', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['theme'],
|
||
|
|
value: 'light',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const conflicted = mergeConvergentSyncStates(dark, light);
|
||
|
|
const resolved = applyConvergentMutations(conflicted, 'resolver', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['theme'],
|
||
|
|
value: 'dark',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const materialized = materializeConvergentSyncState(resolved);
|
||
|
|
|
||
|
|
assert.equal(materialized.settings.theme, 'dark');
|
||
|
|
assert.equal(materialized.conflicts.length, 0);
|
||
|
|
assert.equal(resolved.vector.resolver, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('unchanged setting writes still tombstone overlapping active paths', () => {
|
||
|
|
const parent = applyConvergentMutations(createConvergentSyncState(), 'device-a', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['terminal'],
|
||
|
|
value: 'atomic',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const child = applyConvergentMutations(createConvergentSyncState(), 'device-z', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['terminal', 'fontSize'],
|
||
|
|
value: 14,
|
||
|
|
}], BASE_TIME);
|
||
|
|
const conflicted = mergeConvergentSyncStates(parent, child);
|
||
|
|
const normalized = applyConvergentMutations(conflicted, 'resolver', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['terminal', 'fontSize'],
|
||
|
|
value: 14,
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const materialized = materializeConvergentSyncState(normalized);
|
||
|
|
|
||
|
|
assert.deepEqual(materialized.settings, { terminal: { fontSize: 14 } });
|
||
|
|
assert.equal(materialized.conflicts.length, 0);
|
||
|
|
assert.equal(normalized.vector.resolver, 1);
|
||
|
|
assert.deepEqual(
|
||
|
|
normalized.settings['/terminal/fontSize'].candidates[0]?.dot,
|
||
|
|
child.settings['/terminal/fontSize'].candidates[0]?.dot,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('repeated setting deletes do not advance the replica clock', () => {
|
||
|
|
const initial = applyConvergentMutations(createConvergentSyncState(), 'device-a', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['theme'],
|
||
|
|
value: 'dark',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const deleted = applyConvergentMutations(initial, 'device-a', [{
|
||
|
|
kind: 'setting-delete',
|
||
|
|
path: ['theme'],
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const repeated = applyConvergentMutations(deleted, 'device-a', [{
|
||
|
|
kind: 'setting-delete',
|
||
|
|
path: ['theme'],
|
||
|
|
}], BASE_TIME + 2);
|
||
|
|
|
||
|
|
assert.equal(
|
||
|
|
serializeConvergentSyncState(repeated),
|
||
|
|
serializeConvergentSyncState(deleted),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('causal setting shape changes tombstone overlapping leaf paths', () => {
|
||
|
|
const nested = applyConvergentMutations(createConvergentSyncState(), 'device-a', [
|
||
|
|
{ kind: 'setting-set', path: ['terminal'], value: ['atomic'] },
|
||
|
|
{ kind: 'setting-set', path: ['terminal', 'fontSize'], value: 14 },
|
||
|
|
], BASE_TIME);
|
||
|
|
const atomic = applyConvergentMutations(nested, 'device-a', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['terminal'],
|
||
|
|
value: ['replacement'],
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
|
||
|
|
assert.deepEqual(materializeConvergentSyncState(nested).settings, {
|
||
|
|
terminal: { fontSize: 14 },
|
||
|
|
});
|
||
|
|
assert.deepEqual(materializeConvergentSyncState(atomic).settings, {
|
||
|
|
terminal: ['replacement'],
|
||
|
|
});
|
||
|
|
assert.equal(
|
||
|
|
atomic.settings['/terminal/fontSize'].candidates[0]?.tombstone,
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('causal setting deletion tombstones the complete subtree', () => {
|
||
|
|
const nested = applyConvergentMutations(createConvergentSyncState(), 'device-a', [
|
||
|
|
{ kind: 'setting-set', path: ['terminal', 'fontSize'], value: 14 },
|
||
|
|
{ kind: 'setting-set', path: ['terminal', 'fontFamily'], value: 'Mono' },
|
||
|
|
{ kind: 'setting-set', path: ['theme'], value: 'dark' },
|
||
|
|
], BASE_TIME);
|
||
|
|
const deleted = applyConvergentMutations(nested, 'device-a', [{
|
||
|
|
kind: 'setting-delete',
|
||
|
|
path: ['terminal'],
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
|
||
|
|
assert.deepEqual(materializeConvergentSyncState(deleted).settings, {
|
||
|
|
theme: 'dark',
|
||
|
|
});
|
||
|
|
assert.equal(deleted.settings['/terminal'].candidates[0]?.tombstone, true);
|
||
|
|
assert.equal(
|
||
|
|
deleted.settings['/terminal/fontFamily'].candidates[0]?.tombstone,
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
deleted.settings['/terminal/fontSize'].candidates[0]?.tombstone,
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
assert.deepEqual(
|
||
|
|
materializeConvergentSyncState(
|
||
|
|
mergeConvergentSyncStates(nested, deleted),
|
||
|
|
).settings,
|
||
|
|
{ theme: 'dark' },
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('concurrent subtree deletion and descendant update remain a conflict', () => {
|
||
|
|
const base = applyConvergentMutations(createConvergentSyncState(), 'seed', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['terminal', 'fontSize'],
|
||
|
|
value: 14,
|
||
|
|
}], BASE_TIME);
|
||
|
|
const deleted = applyConvergentMutations(base, 'device-a', [{
|
||
|
|
kind: 'setting-delete',
|
||
|
|
path: ['terminal'],
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const updated = applyConvergentMutations(base, 'device-b', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['terminal', 'fontSize'],
|
||
|
|
value: 16,
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
|
||
|
|
const materialized = materializeConvergentSyncState(
|
||
|
|
mergeConvergentSyncStates(deleted, updated),
|
||
|
|
);
|
||
|
|
const conflict = materialized.conflicts.find(
|
||
|
|
(item) => item.address.kind === 'setting'
|
||
|
|
&& item.address.path.join('/') === 'terminal/fontSize',
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.deepEqual(materialized.settings, { terminal: { fontSize: 16 } });
|
||
|
|
assert.equal(conflict?.candidates.length, 2);
|
||
|
|
assert.equal(
|
||
|
|
conflict?.candidates.some((candidate) => candidate.tombstone),
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('concurrent setting shape changes materialize deterministically and remain resolvable', () => {
|
||
|
|
const parent = applyConvergentMutations(createConvergentSyncState(), 'device-a', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['terminal'],
|
||
|
|
value: ['atomic'],
|
||
|
|
}], BASE_TIME);
|
||
|
|
const child = applyConvergentMutations(createConvergentSyncState(), 'device-z', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['terminal', 'fontSize'],
|
||
|
|
value: 14,
|
||
|
|
}], BASE_TIME);
|
||
|
|
const merged = mergeConvergentSyncStates(parent, child);
|
||
|
|
const materialized = materializeConvergentSyncState(merged);
|
||
|
|
const structureConflict = materialized.conflicts.find(
|
||
|
|
(conflict) => conflict.address.kind === 'setting-structure',
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.deepEqual(materialized.settings, { terminal: { fontSize: 14 } });
|
||
|
|
assert.deepEqual(
|
||
|
|
structureConflict?.address.kind === 'setting-structure'
|
||
|
|
? structureConflict.address.paths
|
||
|
|
: [],
|
||
|
|
[['terminal'], ['terminal', 'fontSize']],
|
||
|
|
);
|
||
|
|
assert.equal(structureConflict?.candidates.length, 2);
|
||
|
|
assert.deepEqual(
|
||
|
|
structureConflict?.candidates.filter((candidate) => candidate.selected)
|
||
|
|
.map((candidate) => candidate.settingPath),
|
||
|
|
[['terminal', 'fontSize']],
|
||
|
|
);
|
||
|
|
|
||
|
|
const resolved = applyConvergentMutations(merged, 'resolver', [{
|
||
|
|
kind: 'resolve-register',
|
||
|
|
address: { kind: 'setting', path: ['terminal'] },
|
||
|
|
value: ['reviewed'],
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const resolvedMaterialized = materializeConvergentSyncState(resolved);
|
||
|
|
assert.deepEqual(resolvedMaterialized.settings, { terminal: ['reviewed'] });
|
||
|
|
assert.equal(
|
||
|
|
resolvedMaterialized.conflicts.some(
|
||
|
|
(conflict) => conflict.address.kind === 'setting-structure',
|
||
|
|
),
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('setting structure selection keeps non-overlapping siblings', () => {
|
||
|
|
const parent = applyConvergentMutations(createConvergentSyncState(), 'device-a', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['terminal'],
|
||
|
|
value: 'atomic',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const children = applyConvergentMutations(createConvergentSyncState(), 'device-z', [
|
||
|
|
{ kind: 'setting-set', path: ['terminal', 'fontSize'], value: 14 },
|
||
|
|
{ kind: 'setting-set', path: ['terminal', 'fontFamily'], value: 'Mono' },
|
||
|
|
], BASE_TIME);
|
||
|
|
|
||
|
|
assert.deepEqual(
|
||
|
|
materializeConvergentSyncState(mergeConvergentSyncStates(parent, children)).settings,
|
||
|
|
{ terminal: { fontFamily: 'Mono', fontSize: 14 } },
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('canonical serialization is stable and hydration validates the state', () => {
|
||
|
|
const first = applyConvergentMutations(createConvergentSyncState(), 'device-a', [
|
||
|
|
{ kind: 'setting-set', path: ['z'], value: { b: 2, a: 1 } },
|
||
|
|
{ kind: 'setting-set', path: ['a'], value: ['x'] },
|
||
|
|
], BASE_TIME);
|
||
|
|
const second = hydrateConvergentSyncState(serializeConvergentSyncState(first));
|
||
|
|
|
||
|
|
assert.equal(serializeConvergentSyncState(first), serializeConvergentSyncState(second));
|
||
|
|
assert.throws(
|
||
|
|
() => hydrateConvergentSyncState('{"schemaVersion":3}'),
|
||
|
|
ConvergentSyncInvariantError,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('validation rejects vector counters without a retained causal witness', () => {
|
||
|
|
const local = applyConvergentMutations(createConvergentSyncState(), 'device-a', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['theme'],
|
||
|
|
value: 'dark',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const missingCandidate = createConvergentSyncState();
|
||
|
|
missingCandidate.vector['device-a'] = 1;
|
||
|
|
missingCandidate.dotOrigins['device-a'] = {
|
||
|
|
1: registerId({ kind: 'setting', path: ['theme'] }),
|
||
|
|
};
|
||
|
|
const inflatedVector = JSON.parse(
|
||
|
|
serializeConvergentSyncState(local),
|
||
|
|
) as ConvergentSyncStateV2;
|
||
|
|
inflatedVector.vector['device-a'] = 2;
|
||
|
|
inflatedVector.dotOrigins['device-a']['2'] = registerId({
|
||
|
|
kind: 'setting',
|
||
|
|
path: ['theme'],
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.throws(
|
||
|
|
() => hydrateConvergentSyncState(JSON.stringify(missingCandidate)),
|
||
|
|
/vector\.device-a is not witnessed/,
|
||
|
|
);
|
||
|
|
assert.throws(
|
||
|
|
() => mergeConvergentSyncStates(local, missingCandidate),
|
||
|
|
/vector\.device-a is not witnessed/,
|
||
|
|
);
|
||
|
|
assert.throws(
|
||
|
|
() => hydrateConvergentSyncState(JSON.stringify(inflatedVector)),
|
||
|
|
/vector\.device-a is not witnessed/,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('candidate context can witness causally dominated vector counters', () => {
|
||
|
|
const first = applyConvergentMutations(createConvergentSyncState(), 'device-a', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['theme'],
|
||
|
|
value: 'dark',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const overwritten = applyConvergentMutations(first, 'device-b', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['theme'],
|
||
|
|
value: 'light',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
|
||
|
|
assert.deepEqual(overwritten.settings['/theme'].candidates[0]?.context, [{
|
||
|
|
deviceId: 'device-a',
|
||
|
|
counter: 1,
|
||
|
|
}]);
|
||
|
|
assert.deepEqual(
|
||
|
|
hydrateConvergentSyncState(serializeConvergentSyncState(overwritten)),
|
||
|
|
overwritten,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('unrelated register context cannot witness an omitted register', () => {
|
||
|
|
const local = applyConvergentMutations(createConvergentSyncState(), 'device-a', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['theme'],
|
||
|
|
value: 'dark',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const remote = applyConvergentMutations(local, 'device-b', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['terminal', 'fontSize'],
|
||
|
|
value: 14,
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
delete remote.settings['/theme'];
|
||
|
|
|
||
|
|
assert.deepEqual(
|
||
|
|
remote.settings['/terminal/fontSize'].candidates[0]?.context,
|
||
|
|
[],
|
||
|
|
);
|
||
|
|
assert.throws(
|
||
|
|
() => hydrateConvergentSyncState(JSON.stringify(remote)),
|
||
|
|
/vector\.device-a is not witnessed/,
|
||
|
|
);
|
||
|
|
assert.throws(
|
||
|
|
() => mergeConvergentSyncStates(local, remote),
|
||
|
|
/vector\.device-a is not witnessed/,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('copied cross-register context cannot witness an omitted register', () => {
|
||
|
|
const state = applyConvergentMutations(createConvergentSyncState(), 'device-a', [
|
||
|
|
{ kind: 'setting-set', path: ['theme'], value: 'dark' },
|
||
|
|
{ kind: 'setting-set', path: ['terminal', 'fontSize'], value: 14 },
|
||
|
|
], BASE_TIME);
|
||
|
|
const corrupted = JSON.parse(
|
||
|
|
serializeConvergentSyncState(state),
|
||
|
|
) as ConvergentSyncStateV2;
|
||
|
|
const omittedDot = { ...corrupted.settings['/theme'].candidates[0].dot };
|
||
|
|
delete corrupted.settings['/theme'];
|
||
|
|
corrupted.settings['/terminal/fontSize'].candidates[0].context = [omittedDot];
|
||
|
|
|
||
|
|
assert.throws(
|
||
|
|
() => hydrateConvergentSyncState(JSON.stringify(corrupted)),
|
||
|
|
/context\[0\] is assigned to a different register origin/,
|
||
|
|
);
|
||
|
|
assert.throws(
|
||
|
|
() => mergeConvergentSyncStates(state, corrupted),
|
||
|
|
/context\[0\] is assigned to a different register origin/,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('exact contexts detect missing dots between writes to the same register', () => {
|
||
|
|
const state = applyConvergentMutations(createConvergentSyncState(), 'device-a', [
|
||
|
|
{ kind: 'setting-set', path: ['theme'], value: 'dark' },
|
||
|
|
{ kind: 'setting-set', path: ['terminal', 'fontSize'], value: 14 },
|
||
|
|
{ kind: 'setting-set', path: ['theme'], value: 'light' },
|
||
|
|
], BASE_TIME);
|
||
|
|
const partial = JSON.parse(
|
||
|
|
serializeConvergentSyncState(state),
|
||
|
|
) as ConvergentSyncStateV2;
|
||
|
|
delete partial.settings['/terminal/fontSize'];
|
||
|
|
|
||
|
|
assert.deepEqual(state.settings['/theme'].candidates[0]?.context, [{
|
||
|
|
deviceId: 'device-a',
|
||
|
|
counter: 1,
|
||
|
|
}]);
|
||
|
|
assert.throws(
|
||
|
|
() => hydrateConvergentSyncState(JSON.stringify(partial)),
|
||
|
|
/vector\.device-a is not witnessed/,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('validation rejects a context dot retained in another register', () => {
|
||
|
|
const state = applyConvergentMutations(createConvergentSyncState(), 'device-a', [
|
||
|
|
{ kind: 'setting-set', path: ['theme'], value: 'dark' },
|
||
|
|
{ kind: 'setting-set', path: ['terminal', 'fontSize'], value: 14 },
|
||
|
|
], BASE_TIME);
|
||
|
|
const corrupted = JSON.parse(
|
||
|
|
serializeConvergentSyncState(state),
|
||
|
|
) as ConvergentSyncStateV2;
|
||
|
|
corrupted.settings['/terminal/fontSize'].candidates[0].context = [{
|
||
|
|
...corrupted.settings['/theme'].candidates[0].dot,
|
||
|
|
}];
|
||
|
|
|
||
|
|
assert.throws(
|
||
|
|
() => hydrateConvergentSyncState(JSON.stringify(corrupted)),
|
||
|
|
/context\[0\] is assigned to a different register origin/,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('validation rejects causal cycles between retained candidates', () => {
|
||
|
|
const left = applyConvergentMutations(createConvergentSyncState(), 'device-a', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['theme'],
|
||
|
|
value: 'left',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const right = applyConvergentMutations(createConvergentSyncState(), 'device-b', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['theme'],
|
||
|
|
value: 'right',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const corrupted = mergeConvergentSyncStates(left, right);
|
||
|
|
const [first, second] = corrupted.settings['/theme'].candidates;
|
||
|
|
first.context = [{ ...second.dot }];
|
||
|
|
second.context = [{ ...first.dot }];
|
||
|
|
|
||
|
|
assert.throws(
|
||
|
|
() => hydrateConvergentSyncState(JSON.stringify(corrupted)),
|
||
|
|
/references candidate dot device-b:1 retained in the same register/,
|
||
|
|
);
|
||
|
|
assert.throws(
|
||
|
|
() => mergeConvergentSyncStates(corrupted, corrupted),
|
||
|
|
/references candidate dot device-b:1 retained in the same register/,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('candidate ordering uses locale-independent code-unit comparison', () => {
|
||
|
|
const ascii = applyConvergentMutations(createConvergentSyncState(), 'device-z', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['theme'],
|
||
|
|
value: 'ascii',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const nonAscii = applyConvergentMutations(createConvergentSyncState(), 'device-ä', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['theme'],
|
||
|
|
value: 'non-ascii',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const merged = mergeConvergentSyncStates(ascii, nonAscii);
|
||
|
|
|
||
|
|
assert.deepEqual(
|
||
|
|
merged.settings['/theme'].candidates.map((candidate) => candidate.dot.deviceId),
|
||
|
|
['device-z', 'device-ä'],
|
||
|
|
);
|
||
|
|
assert.equal(materializeConvergentSyncState(merged).settings.theme, 'non-ascii');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('candidate metadata key order cannot change canonical serialization or merge identity', () => {
|
||
|
|
const first = applyConvergentMutations(createConvergentSyncState(), 'device-a', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['theme'],
|
||
|
|
value: 'dark',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const state = applyConvergentMutations(first, 'device-b', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['theme'],
|
||
|
|
value: 'light',
|
||
|
|
}], BASE_TIME + 1);
|
||
|
|
const reordered = JSON.parse(
|
||
|
|
serializeConvergentSyncState(state),
|
||
|
|
) as ConvergentSyncStateV2;
|
||
|
|
const candidate = reordered.settings['/theme'].candidates[0];
|
||
|
|
candidate.dot = {
|
||
|
|
counter: candidate.dot.counter,
|
||
|
|
deviceId: candidate.dot.deviceId,
|
||
|
|
};
|
||
|
|
candidate.hlc = {
|
||
|
|
logical: candidate.hlc.logical,
|
||
|
|
wallTime: candidate.hlc.wallTime,
|
||
|
|
};
|
||
|
|
candidate.context[0] = {
|
||
|
|
counter: candidate.context[0].counter,
|
||
|
|
deviceId: candidate.context[0].deviceId,
|
||
|
|
};
|
||
|
|
reordered.hlc = {
|
||
|
|
logical: reordered.hlc.logical,
|
||
|
|
wallTime: reordered.hlc.wallTime,
|
||
|
|
};
|
||
|
|
|
||
|
|
assert.equal(
|
||
|
|
serializeConvergentSyncState(reordered),
|
||
|
|
serializeConvergentSyncState(state),
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
serializeConvergentSyncState(mergeConvergentSyncStates(state, reordered)),
|
||
|
|
serializeConvergentSyncState(state),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('device counters are monotonic across different register kinds', () => {
|
||
|
|
const state = applyConvergentMutations(createConvergentSyncState(), 'device-a', [
|
||
|
|
{
|
||
|
|
kind: 'entity-field-set',
|
||
|
|
collection: 'hosts',
|
||
|
|
entityId: 'host-1',
|
||
|
|
field: 'label',
|
||
|
|
value: 'Host',
|
||
|
|
},
|
||
|
|
{ kind: 'setting-set', path: ['theme'], value: 'dark' },
|
||
|
|
{ kind: 'string-entry-add', collection: 'customGroups', value: 'prod' },
|
||
|
|
], BASE_TIME);
|
||
|
|
const counters = [
|
||
|
|
state.collections.hosts.entities['host-1'].fields.label.candidates[0].dot.counter,
|
||
|
|
state.collections.hosts.entities['host-1'].presence.candidates[0].dot.counter,
|
||
|
|
state.settings['/theme'].candidates[0].dot.counter,
|
||
|
|
state.stringCollections.customGroups.entries.prod.presence.candidates[0].dot.counter,
|
||
|
|
];
|
||
|
|
|
||
|
|
assert.deepEqual(counters, [1, 2, 3, 4]);
|
||
|
|
assert.equal(state.vector['device-a'], 4);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('hydration fails closed when a dot is reused by two registers', () => {
|
||
|
|
const state = applyConvergentMutations(createConvergentSyncState(), 'device-a', [
|
||
|
|
{ kind: 'setting-set', path: ['a'], value: 1 },
|
||
|
|
{ kind: 'setting-set', path: ['b'], value: 2 },
|
||
|
|
], BASE_TIME);
|
||
|
|
const corrupted = JSON.parse(
|
||
|
|
serializeConvergentSyncState(state),
|
||
|
|
) as ConvergentSyncStateV2;
|
||
|
|
corrupted.settings['/b'].candidates[0].dot = {
|
||
|
|
...corrupted.settings['/a'].candidates[0].dot,
|
||
|
|
};
|
||
|
|
corrupted.settings['/b'].candidates[0].context =
|
||
|
|
corrupted.settings['/a'].candidates[0].context.map((dot) => ({ ...dot }));
|
||
|
|
|
||
|
|
assert.throws(
|
||
|
|
() => hydrateConvergentSyncState(JSON.stringify(corrupted)),
|
||
|
|
/dot is assigned to a different register origin/,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('merge rejects the same dot allocated to different register origins', () => {
|
||
|
|
const left = applyConvergentMutations(createConvergentSyncState(), 'device-a', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['theme'],
|
||
|
|
value: 'dark',
|
||
|
|
}], BASE_TIME);
|
||
|
|
const right = applyConvergentMutations(createConvergentSyncState(), 'device-a', [{
|
||
|
|
kind: 'setting-set',
|
||
|
|
path: ['terminal', 'fontSize'],
|
||
|
|
value: 14,
|
||
|
|
}], BASE_TIME);
|
||
|
|
|
||
|
|
assert.throws(
|
||
|
|
() => mergeConvergentSyncStates(left, right),
|
||
|
|
/Dot origin mismatch/,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('record identifiers cannot escape into the object prototype chain', () => {
|
||
|
|
const state = applyConvergentMutations(createConvergentSyncState(), '__proto__', [
|
||
|
|
{
|
||
|
|
kind: 'entity-field-set',
|
||
|
|
collection: 'constructor',
|
||
|
|
entityId: '__proto__',
|
||
|
|
field: 'constructor',
|
||
|
|
value: 'safe',
|
||
|
|
},
|
||
|
|
{ kind: 'setting-set', path: ['__proto__'], value: 'safe-setting' },
|
||
|
|
{
|
||
|
|
kind: 'string-entry-add',
|
||
|
|
collection: '__proto__',
|
||
|
|
value: 'constructor',
|
||
|
|
},
|
||
|
|
], BASE_TIME);
|
||
|
|
const materialized = materializeConvergentSyncState(state);
|
||
|
|
|
||
|
|
assert.equal(Object.hasOwn(state.vector, '__proto__'), true);
|
||
|
|
assert.equal(materialized.collections.constructor[0]?.constructor, 'safe');
|
||
|
|
assert.equal(materialized.settings.__proto__, 'safe-setting');
|
||
|
|
assert.deepEqual(materialized.stringCollections.__proto__, ['constructor']);
|
||
|
|
assert.equal(Object.getPrototypeOf(materialized.settings), Object.prototype);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('HLC stays monotonic when wall time moves backwards', () => {
|
||
|
|
const first = tickHybridLogicalClock({ wallTime: 100, logical: 4 }, 90);
|
||
|
|
const second = tickHybridLogicalClock(first, 100);
|
||
|
|
const third = tickHybridLogicalClock(second, 101);
|
||
|
|
|
||
|
|
assert.deepEqual(first, { wallTime: 100, logical: 5 });
|
||
|
|
assert.deepEqual(second, { wallTime: 100, logical: 6 });
|
||
|
|
assert.deepEqual(third, { wallTime: 101, logical: 0 });
|
||
|
|
});
|