mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-26 22:35:18 +02:00
355 lines
10 KiB
TypeScript
355 lines
10 KiB
TypeScript
import { CRDTEngine, createCRDTProvider } from './index';
|
|
import type { CRDTArray, CRDTDoc, CRDTMap } from './types';
|
|
|
|
/**
|
|
* Sync conformance tests - verify that two docs can sync manually
|
|
* by exchanging encoded states and updates.
|
|
*/
|
|
describe.each([CRDTEngine.yjs])('Sync Conformance: %s', (engine) => {
|
|
let doc1: CRDTDoc;
|
|
let doc2: CRDTDoc;
|
|
let map1: CRDTMap<unknown>;
|
|
let map2: CRDTMap<unknown>;
|
|
let arr1: CRDTArray<string>;
|
|
let arr2: CRDTArray<string>;
|
|
|
|
beforeEach(() => {
|
|
const provider = createCRDTProvider({ engine });
|
|
doc1 = provider.createDoc('doc-1');
|
|
doc2 = provider.createDoc('doc-2');
|
|
map1 = doc1.getMap('data');
|
|
map2 = doc2.getMap('data');
|
|
arr1 = doc1.getArray('items');
|
|
arr2 = doc2.getArray('items');
|
|
});
|
|
|
|
afterEach(() => {
|
|
doc1.destroy();
|
|
doc2.destroy();
|
|
});
|
|
|
|
describe('Manual Sync via encodeState/applyUpdate', () => {
|
|
it('should sync doc1 changes to doc2', () => {
|
|
map1.set('key', 'from-doc1');
|
|
|
|
const update = doc1.encodeState();
|
|
doc2.applyUpdate(update);
|
|
|
|
expect(map2.get('key')).toBe('from-doc1');
|
|
});
|
|
|
|
it('should sync doc2 changes to doc1', () => {
|
|
map2.set('key', 'from-doc2');
|
|
|
|
const update = doc2.encodeState();
|
|
doc1.applyUpdate(update);
|
|
|
|
expect(map1.get('key')).toBe('from-doc2');
|
|
});
|
|
|
|
it('should merge concurrent changes after initial sync', () => {
|
|
// Realistic collaborative pattern: one peer creates the doc,
|
|
// others join via initial sync, then concurrent edits merge correctly
|
|
map1.set('created', 'doc1');
|
|
doc2.applyUpdate(doc1.encodeState());
|
|
|
|
// Both peers make concurrent changes
|
|
map1.set('key1', 'value1');
|
|
map2.set('key2', 'value2');
|
|
|
|
// Exchange updates
|
|
doc2.applyUpdate(doc1.encodeState());
|
|
doc1.applyUpdate(doc2.encodeState());
|
|
|
|
// Both docs should have all keys
|
|
expect(map1.get('created')).toBe('doc1');
|
|
expect(map1.get('key1')).toBe('value1');
|
|
expect(map1.get('key2')).toBe('value2');
|
|
expect(map2.get('created')).toBe('doc1');
|
|
expect(map2.get('key1')).toBe('value1');
|
|
expect(map2.get('key2')).toBe('value2');
|
|
});
|
|
|
|
it('should handle conflict on same key (both converge to same value)', () => {
|
|
// Both docs set the same key to different values
|
|
map1.set('conflict', 'A');
|
|
map2.set('conflict', 'B');
|
|
|
|
// Exchange updates
|
|
const update1 = doc1.encodeState();
|
|
const update2 = doc2.encodeState();
|
|
|
|
doc2.applyUpdate(update1);
|
|
doc1.applyUpdate(update2);
|
|
|
|
// Both should converge to the same value (winner determined by CRDT)
|
|
const value1 = map1.get('conflict');
|
|
const value2 = map2.get('conflict');
|
|
expect(value1).toBe(value2);
|
|
// The value should be one of the two
|
|
expect(['A', 'B']).toContain(value1);
|
|
});
|
|
|
|
it('should sync nested object changes', () => {
|
|
map1.set('node', { position: { x: 100, y: 200 } });
|
|
|
|
const update = doc1.encodeState();
|
|
doc2.applyUpdate(update);
|
|
|
|
const node = map2.toJSON().node as { position: { x: number; y: number } };
|
|
expect(node.position.x).toBe(100);
|
|
expect(node.position.y).toBe(200);
|
|
});
|
|
|
|
it('should sync incremental nested changes by replacement', () => {
|
|
// Initial setup
|
|
map1.set('node', { position: { x: 100, y: 200 } });
|
|
doc2.applyUpdate(doc1.encodeState());
|
|
|
|
// Make nested change by replacing the whole object (no magic nesting)
|
|
map1.set('node', { position: { x: 150, y: 200 } });
|
|
|
|
// Sync
|
|
doc2.applyUpdate(doc1.encodeState());
|
|
|
|
const node2 = map2.toJSON().node as { position: { x: number; y: number } };
|
|
expect(node2.position.x).toBe(150);
|
|
expect(node2.position.y).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('Bidirectional Sync via onUpdate', () => {
|
|
it('should sync changes in real-time using onUpdate', () => {
|
|
// Set up bidirectional sync
|
|
doc1.onUpdate((update) => doc2.applyUpdate(update));
|
|
doc2.onUpdate((update) => doc1.applyUpdate(update));
|
|
|
|
// Change in doc1
|
|
map1.set('fromDoc1', 'hello');
|
|
expect(map2.get('fromDoc1')).toBe('hello');
|
|
|
|
// Change in doc2
|
|
map2.set('fromDoc2', 'world');
|
|
expect(map1.get('fromDoc2')).toBe('world');
|
|
});
|
|
|
|
it('should handle rapid sequential changes', () => {
|
|
// Set up bidirectional sync
|
|
doc1.onUpdate((update) => doc2.applyUpdate(update));
|
|
doc2.onUpdate((update) => doc1.applyUpdate(update));
|
|
|
|
// Multiple rapid changes
|
|
map1.set('a', 1);
|
|
map1.set('b', 2);
|
|
map1.set('c', 3);
|
|
map2.set('d', 4);
|
|
map2.set('e', 5);
|
|
|
|
expect(map1.toJSON()).toEqual({ a: 1, b: 2, c: 3, d: 4, e: 5 });
|
|
expect(map2.toJSON()).toEqual({ a: 1, b: 2, c: 3, d: 4, e: 5 });
|
|
});
|
|
|
|
it('should handle transacted changes', () => {
|
|
// Set up bidirectional sync
|
|
doc1.onUpdate((update) => doc2.applyUpdate(update));
|
|
doc2.onUpdate((update) => doc1.applyUpdate(update));
|
|
|
|
// Batch changes in transaction
|
|
doc1.transact(() => {
|
|
map1.set('a', 1);
|
|
map1.set('b', 2);
|
|
map1.set('c', 3);
|
|
});
|
|
|
|
expect(map2.toJSON()).toEqual({ a: 1, b: 2, c: 3 });
|
|
});
|
|
});
|
|
|
|
describe('Offline/Reconnect Simulation', () => {
|
|
it('should sync after offline changes', () => {
|
|
// Initial sync
|
|
map1.set('initial', 'value');
|
|
doc2.applyUpdate(doc1.encodeState());
|
|
|
|
// Simulate offline - make changes without syncing
|
|
map1.set('offline1', 'from-doc1');
|
|
map2.set('offline2', 'from-doc2');
|
|
|
|
// Simulate reconnect - exchange states
|
|
const state1 = doc1.encodeState();
|
|
const state2 = doc2.encodeState();
|
|
doc2.applyUpdate(state1);
|
|
doc1.applyUpdate(state2);
|
|
|
|
// Both should have all data
|
|
expect(map1.toJSON()).toEqual({
|
|
initial: 'value',
|
|
offline1: 'from-doc1',
|
|
offline2: 'from-doc2',
|
|
});
|
|
expect(map2.toJSON()).toEqual({
|
|
initial: 'value',
|
|
offline1: 'from-doc1',
|
|
offline2: 'from-doc2',
|
|
});
|
|
});
|
|
|
|
it('should resolve conflicts after offline changes to same key', () => {
|
|
// Initial sync
|
|
map1.set('shared', 'initial');
|
|
doc2.applyUpdate(doc1.encodeState());
|
|
|
|
// Both modify same key while offline
|
|
map1.set('shared', 'doc1-version');
|
|
map2.set('shared', 'doc2-version');
|
|
|
|
// Reconnect
|
|
const state1 = doc1.encodeState();
|
|
const state2 = doc2.encodeState();
|
|
doc2.applyUpdate(state1);
|
|
doc1.applyUpdate(state2);
|
|
|
|
// Both converge to same value
|
|
expect(map1.get('shared')).toBe(map2.get('shared'));
|
|
});
|
|
});
|
|
|
|
describe('Array Sync via encodeState/applyUpdate', () => {
|
|
it('should sync array from doc1 to doc2', () => {
|
|
arr1.push('a', 'b', 'c');
|
|
|
|
doc2.applyUpdate(doc1.encodeState());
|
|
|
|
expect(arr2.toArray()).toEqual(['a', 'b', 'c']);
|
|
});
|
|
|
|
it('should sync array from doc2 to doc1', () => {
|
|
arr2.push('x', 'y', 'z');
|
|
|
|
doc1.applyUpdate(doc2.encodeState());
|
|
|
|
expect(arr1.toArray()).toEqual(['x', 'y', 'z']);
|
|
});
|
|
|
|
it('should merge concurrent array pushes after initial sync', () => {
|
|
// Initial sync - establish shared history
|
|
arr1.push('initial');
|
|
doc2.applyUpdate(doc1.encodeState());
|
|
|
|
// Both make concurrent changes
|
|
arr1.push('from1');
|
|
arr2.push('from2');
|
|
|
|
// Exchange updates
|
|
const state1 = doc1.encodeState();
|
|
const state2 = doc2.encodeState();
|
|
doc2.applyUpdate(state1);
|
|
doc1.applyUpdate(state2);
|
|
|
|
// Both should have all items and converge
|
|
expect(arr1.toArray()).toContain('initial');
|
|
expect(arr1.toArray()).toContain('from1');
|
|
expect(arr1.toArray()).toContain('from2');
|
|
expect(arr2.toArray()).toEqual(arr1.toArray());
|
|
});
|
|
|
|
it('should sync insert operations', () => {
|
|
arr1.push('a', 'c');
|
|
doc2.applyUpdate(doc1.encodeState());
|
|
|
|
arr1.insert(1, 'b');
|
|
doc2.applyUpdate(doc1.encodeState());
|
|
|
|
expect(arr2.toArray()).toEqual(['a', 'b', 'c']);
|
|
});
|
|
|
|
it('should sync delete operations', () => {
|
|
arr1.push('a', 'b', 'c', 'd');
|
|
doc2.applyUpdate(doc1.encodeState());
|
|
|
|
arr1.delete(1, 2);
|
|
doc2.applyUpdate(doc1.encodeState());
|
|
|
|
expect(arr2.toArray()).toEqual(['a', 'd']);
|
|
});
|
|
|
|
it('should sync nested objects in arrays', () => {
|
|
const objArr1 = doc1.getArray<{ name: string }>('objects');
|
|
objArr1.push({ name: 'first' }, { name: 'second' });
|
|
|
|
doc2.applyUpdate(doc1.encodeState());
|
|
|
|
const objArr2 = doc2.getArray<{ name: string }>('objects');
|
|
expect(objArr2.toArray()).toEqual([{ name: 'first' }, { name: 'second' }]);
|
|
});
|
|
|
|
it('should sync plain objects in arrays', () => {
|
|
const objArr1 = doc1.getArray<{ value: number }>('objects');
|
|
const objArr2 = doc2.getArray<{ value: number }>('objects');
|
|
objArr1.push({ value: 100 });
|
|
doc2.applyUpdate(doc1.encodeState());
|
|
|
|
// Plain objects are synced as-is
|
|
const result = objArr2.get(0) as { value: number };
|
|
expect(result.value).toBe(100);
|
|
});
|
|
|
|
it('should sync plain arrays stored in maps', () => {
|
|
map1.set('list', ['item1', 'item2']);
|
|
doc2.applyUpdate(doc1.encodeState());
|
|
|
|
// Plain arrays are returned as-is
|
|
const list2 = map2.get('list') as string[];
|
|
expect(list2).toEqual(['item1', 'item2']);
|
|
});
|
|
|
|
it('should sync array replacements in maps', () => {
|
|
map1.set('list', ['a']);
|
|
doc2.applyUpdate(doc1.encodeState());
|
|
|
|
// Replace whole array to modify
|
|
map1.set('list', ['a', 'b', 'c']);
|
|
doc2.applyUpdate(doc1.encodeState());
|
|
|
|
const list2 = map2.get('list') as string[];
|
|
expect(list2).toEqual(['a', 'b', 'c']);
|
|
});
|
|
});
|
|
|
|
describe('Array Sync via onUpdate', () => {
|
|
it('should sync array changes in real-time', () => {
|
|
doc1.onUpdate((update) => doc2.applyUpdate(update));
|
|
doc2.onUpdate((update) => doc1.applyUpdate(update));
|
|
|
|
arr1.push('from1');
|
|
expect(arr2.toArray()).toContain('from1');
|
|
|
|
arr2.push('from2');
|
|
expect(arr1.toArray()).toContain('from2');
|
|
});
|
|
|
|
it('should handle rapid sequential array operations', () => {
|
|
doc1.onUpdate((update) => doc2.applyUpdate(update));
|
|
doc2.onUpdate((update) => doc1.applyUpdate(update));
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
arr1.push(`item-${i}`);
|
|
}
|
|
|
|
expect(arr2.length).toBe(10);
|
|
expect(arr2.toArray()).toEqual(arr1.toArray());
|
|
});
|
|
|
|
it('should handle transacted array changes', () => {
|
|
doc1.onUpdate((update) => doc2.applyUpdate(update));
|
|
doc2.onUpdate((update) => doc1.applyUpdate(update));
|
|
|
|
doc1.transact(() => {
|
|
arr1.push('a', 'b', 'c');
|
|
});
|
|
|
|
expect(arr2.toArray()).toEqual(['a', 'b', 'c']);
|
|
});
|
|
});
|
|
});
|