Files
n8n/packages/@n8n/crdt/src/sync/sync-provider.test.ts
T
alighasami 3d5eaf9445
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
first commit
2026-03-17 16:22:57 +03:30

756 lines
21 KiB
TypeScript

import { createNestedObject, createWorkflowData } from '../__tests__/helpers';
import { CRDTEngine, createCRDTProvider } from '../index';
import { MockTransport } from '../transports';
import type { CRDTArray, CRDTDoc, CRDTMap } from '../types';
import { createSyncProvider } from './base-sync-provider';
import type { SyncProvider } from './types';
/**
* SyncProvider tests - verify sync via transport works for both engines.
*/
describe.each([CRDTEngine.yjs])('SyncProvider Conformance: %s', (engine) => {
let doc1: CRDTDoc;
let doc2: CRDTDoc;
let map1: CRDTMap<unknown>;
let map2: CRDTMap<unknown>;
let transport1: MockTransport;
let transport2: MockTransport;
let sync1: SyncProvider;
let sync2: SyncProvider;
beforeEach(() => {
const provider = createCRDTProvider({ engine });
doc1 = provider.createDoc('doc-1');
doc2 = provider.createDoc('doc-2');
map1 = doc1.getMap('data');
map2 = doc2.getMap('data');
transport1 = new MockTransport();
transport2 = new MockTransport();
MockTransport.link(transport1, transport2);
sync1 = createSyncProvider(doc1, transport1);
sync2 = createSyncProvider(doc2, transport2);
});
afterEach(() => {
sync1.stop();
sync2.stop();
doc1.destroy();
doc2.destroy();
});
describe('Two-doc sync via transport', () => {
it('should start in non-syncing state', () => {
expect(sync1.syncing).toBe(false);
expect(sync2.syncing).toBe(false);
});
it('should report syncing after start', async () => {
await sync1.start();
expect(sync1.syncing).toBe(true);
});
it('should report not syncing after stop', async () => {
await sync1.start();
sync1.stop();
expect(sync1.syncing).toBe(false);
});
it('should sync initial state on connect', async () => {
// Doc1 has data before sync starts
map1.set('initial', 'from-doc1');
// Start sync2 first (receiver), then sync1 (sender with data)
// This simulates: new peer connects, then existing peer sends state
await sync2.start();
await sync1.start();
// Doc2 should receive doc1's initial state
expect(map2.get('initial')).toBe('from-doc1');
});
it('should sync changes after connect', async () => {
await sync1.start();
await sync2.start();
// Change in doc1 should appear in doc2
map1.set('key', 'value');
expect(map2.get('key')).toBe('value');
});
it('should sync bidirectionally', async () => {
await sync1.start();
await sync2.start();
map1.set('from1', 'hello');
map2.set('from2', 'world');
expect(map1.get('from2')).toBe('world');
expect(map2.get('from1')).toBe('hello');
});
it('should sync nested objects', async () => {
await sync1.start();
await sync2.start();
map1.set('node', { position: { x: 100, y: 200 } });
const result = map2.toJSON();
expect((result.node as { position: { x: number; y: number } }).position.x).toBe(100);
});
it('should stop syncing after stop()', async () => {
await sync1.start();
await sync2.start();
map1.set('before', 'stop');
expect(map2.get('before')).toBe('stop');
sync1.stop();
map1.set('after', 'stop');
// Doc2 should NOT receive this since sync1 stopped
expect(map2.get('after')).toBeUndefined();
});
it('should call onSyncStateChange handlers', async () => {
const states: boolean[] = [];
sync1.onSyncStateChange((syncing) => states.push(syncing));
await sync1.start();
sync1.stop();
expect(states).toEqual([true, false]);
});
it('should unsubscribe from onSyncStateChange', async () => {
const states: boolean[] = [];
const unsubscribe = sync1.onSyncStateChange((syncing) => states.push(syncing));
await sync1.start();
unsubscribe();
sync1.stop();
expect(states).toEqual([true]); // Only got the start, not the stop
});
it('should not create infinite sync loop when applying updates', async () => {
await sync1.start();
await sync2.start();
let updateCount1 = 0;
let updateCount2 = 0;
doc1.onUpdate(() => updateCount1++);
doc2.onUpdate(() => updateCount2++);
// Make a single change
map1.set('key', 'value');
// Should have limited update calls, not infinite loop
// Each doc emits 1 update for the change, possibly 1 more for sync receipt
expect(updateCount1).toBeLessThan(5);
expect(updateCount2).toBeLessThan(5);
});
});
describe('Three-doc chain sync', () => {
// Topology: doc1 <-> doc2 <-> doc3 (hub-and-spoke via doc2)
let doc3: CRDTDoc;
let map3: CRDTMap<unknown>;
let transport2to3: MockTransport;
let transport3: MockTransport;
let sync2to3: SyncProvider;
let sync3: SyncProvider;
beforeEach(() => {
const provider = createCRDTProvider({ engine });
doc3 = provider.createDoc('doc-3');
map3 = doc3.getMap('data');
transport2to3 = new MockTransport();
transport3 = new MockTransport();
MockTransport.link(transport2to3, transport3);
// Doc2 acts as hub, syncing with both doc1 and doc3
sync2to3 = createSyncProvider(doc2, transport2to3);
sync3 = createSyncProvider(doc3, transport3);
});
afterEach(() => {
sync2to3.stop();
sync3.stop();
doc3.destroy();
});
it('should propagate changes through hub (doc1 -> doc2 -> doc3)', async () => {
// Start all sync connections
await sync1.start();
await sync2.start();
await sync2to3.start();
await sync3.start();
// Change in doc1
map1.set('origin', 'doc1');
// Should propagate: doc1 -> doc2 -> doc3
expect(map2.get('origin')).toBe('doc1');
expect(map3.get('origin')).toBe('doc1');
});
it('should propagate changes from leaf (doc3 -> doc2 -> doc1)', async () => {
await sync1.start();
await sync2.start();
await sync2to3.start();
await sync3.start();
// Change in doc3
map3.set('origin', 'doc3');
// Should propagate: doc3 -> doc2 -> doc1
expect(map2.get('origin')).toBe('doc3');
expect(map1.get('origin')).toBe('doc3');
});
it('should sync all docs to same state with concurrent changes', async () => {
await sync1.start();
await sync2.start();
await sync2to3.start();
await sync3.start();
// Concurrent changes from all three docs
map1.set('from1', 'value1');
map2.set('from2', 'value2');
map3.set('from3', 'value3');
// All should converge
const expected = { from1: 'value1', from2: 'value2', from3: 'value3' };
expect(map1.toJSON()).toEqual(expected);
expect(map2.toJSON()).toEqual(expected);
expect(map3.toJSON()).toEqual(expected);
});
});
describe('Concurrent edit conflicts', () => {
it('should resolve conflict on same key (both converge)', async () => {
await sync1.start();
await sync2.start();
// Both set the same key simultaneously
map1.set('shared', 'from-doc1');
map2.set('shared', 'from-doc2');
// Both should converge to the same value (CRDT determines winner)
const value1 = map1.get('shared');
const value2 = map2.get('shared');
expect(value1).toBe(value2);
expect(['from-doc1', 'from-doc2']).toContain(value1);
});
it('should merge non-conflicting concurrent changes', async () => {
await sync1.start();
await sync2.start();
// Different keys - no conflict
map1.set('key1', 'value1');
map2.set('key2', 'value2');
// Both should have both keys
expect(map1.toJSON()).toEqual({ key1: 'value1', key2: 'value2' });
expect(map2.toJSON()).toEqual({ key1: 'value1', key2: 'value2' });
});
it('should handle rapid sequential updates to same key', async () => {
await sync1.start();
await sync2.start();
// Rapid updates from doc1
map1.set('counter', 1);
map1.set('counter', 2);
map1.set('counter', 3);
// Doc2 should see final value
expect(map2.get('counter')).toBe(3);
});
it('should handle interleaved updates from both docs', async () => {
await sync1.start();
await sync2.start();
map1.set('a', 1);
map2.set('b', 2);
map1.set('c', 3);
map2.set('d', 4);
const expected = { a: 1, b: 2, c: 3, d: 4 };
expect(map1.toJSON()).toEqual(expected);
expect(map2.toJSON()).toEqual(expected);
});
it('should handle nested object conflicts', async () => {
await sync1.start();
await sync2.start();
// Both create nested structure with different values
map1.set('node', { x: 100 });
map2.set('node', { x: 200 });
// Both should converge
const node1 = map1.toJSON().node as { x: number };
const node2 = map2.toJSON().node as { x: number };
expect(node1.x).toBe(node2.x);
expect([100, 200]).toContain(node1.x);
});
it('should handle delete during concurrent update', async () => {
// Setup initial state
map1.set('key', 'initial');
await sync2.start();
await sync1.start();
// Now both have 'key'
expect(map2.get('key')).toBe('initial');
// Concurrent: doc1 updates, doc2 deletes
map1.set('key', 'updated');
map2.delete('key');
// Both should converge (either deleted or updated, but same)
const has1 = map1.has('key');
const has2 = map2.has('key');
expect(has1).toBe(has2);
expect(map1.get('key')).toBe(map2.get('key'));
});
});
describe('Error handling', () => {
it('should call onError handler when receiving malformed update', async () => {
await sync1.start();
await sync2.start();
const errors: Error[] = [];
sync2.onError((error) => errors.push(error));
// Send garbage data directly through transport
const garbageData = new Uint8Array([1, 2, 3, 4, 5]);
transport1.send(garbageData);
// Error handler should have been called
expect(errors.length).toBe(1);
expect(errors[0]).toBeInstanceOf(Error);
});
it('should continue syncing after receiving malformed update', async () => {
await sync1.start();
await sync2.start();
// Register error handler to prevent unhandled errors
sync2.onError(() => {});
// Send garbage data
transport1.send(new Uint8Array([1, 2, 3, 4, 5]));
// Should still be syncing
expect(sync2.syncing).toBe(true);
// Valid updates should still work
map1.set('after-error', 'still-works');
expect(map2.get('after-error')).toBe('still-works');
});
it('should unsubscribe from onError handler', async () => {
await sync1.start();
await sync2.start();
const errors: Error[] = [];
const unsubscribe = sync2.onError((error) => errors.push(error));
// Send garbage, should capture error
transport1.send(new Uint8Array([1, 2, 3]));
expect(errors.length).toBe(1);
// Unsubscribe
unsubscribe();
// Send more garbage, should not capture
transport1.send(new Uint8Array([4, 5, 6]));
expect(errors.length).toBe(1);
});
});
describe('Large deeply nested data', () => {
it('should sync deeply nested object (depth=5, breadth=3)', async () => {
await sync1.start();
await sync2.start();
const deepData = createNestedObject(5, 3); // 3^5 = 243 leaf nodes
map1.set('deep', deepData);
expect(map2.toJSON()).toEqual({ deep: deepData });
});
it('should sync workflow-like structure with 50 nodes', async () => {
await sync1.start();
await sync2.start();
const workflow = createWorkflowData(50);
map1.set('workflow', workflow);
const result = map2.toJSON() as { workflow: Record<string, unknown> };
expect(result.workflow.name).toBe('Test Workflow');
expect(Object.keys(result.workflow.nodes as Record<string, unknown>).length).toBe(50);
});
it('should sync concurrent updates to different branches of nested data', async () => {
await sync1.start();
await sync2.start();
// Set initial structure
map1.set('tree', {
left: { value: 'initial-left' },
right: { value: 'initial-right' },
});
// Wait for sync - use toJSON() for comparison since get() returns nested map type
const synced = map2.toJSON() as { tree: Record<string, unknown> };
expect(synced.tree).toEqual({
left: { value: 'initial-left' },
right: { value: 'initial-right' },
});
// Concurrent updates to different branches
map1.set('tree', {
left: { value: 'updated-by-doc1' },
right: { value: 'initial-right' },
});
map2.set('tree', {
left: { value: 'initial-left' },
right: { value: 'updated-by-doc2' },
});
// Both should converge (CRDT will pick a winner for the whole object)
const result1 = map1.toJSON() as { tree: Record<string, unknown> };
const result2 = map2.toJSON() as { tree: Record<string, unknown> };
expect(result1.tree).toEqual(result2.tree);
});
it('should handle large array-like data in nested structure', async () => {
await sync1.start();
await sync2.start();
const largeData = {
items: Array.from({ length: 100 }, (_, index) => ({
id: index,
data: `item-${index}`,
metadata: { created: Date.now(), tags: [`tag${index % 5}`] },
})),
};
map1.set('collection', largeData);
const result = map2.toJSON() as { collection: { items: unknown[] } };
expect(result.collection.items.length).toBe(100);
});
it('should sync multiple large objects concurrently', async () => {
await sync1.start();
await sync2.start();
// Doc1 sets workflow data
const workflow1 = createWorkflowData(20);
map1.set('workflow1', workflow1);
// Doc2 sets different workflow data
const workflow2 = createWorkflowData(25);
map2.set('workflow2', workflow2);
// Both should have both workflows
const result1 = map1.toJSON() as Record<string, { nodes: Record<string, unknown> }>;
const result2 = map2.toJSON() as Record<string, { nodes: Record<string, unknown> }>;
expect(Object.keys(result1.workflow1.nodes).length).toBe(20);
expect(Object.keys(result1.workflow2.nodes).length).toBe(25);
expect(Object.keys(result2.workflow1.nodes).length).toBe(20);
expect(Object.keys(result2.workflow2.nodes).length).toBe(25);
});
it('should sync edits to deep nested values by replacement', async () => {
await sync1.start();
await sync2.start();
// Create initial structure on doc1
const workflow = createWorkflowData(10);
map1.set('workflow', workflow);
// Verify doc2 received it
const initialResult = map2.toJSON() as { workflow: { nodes: Record<string, unknown> } };
expect(Object.keys(initialResult.workflow.nodes).length).toBe(10);
// Edit deep nested value by replacing the whole workflow
// NOTE: Create a fresh object structure (don't reuse objects from get())
const updatedWorkflow = createWorkflowData(10);
(updatedWorkflow.nodes as Record<string, { position: { x: number; y: number } }>)[
'node-5'
].position.x = 9999;
map1.set('workflow', updatedWorkflow);
// Verify edit synced to doc2
const editedResult = map2.toJSON() as {
workflow: { nodes: Record<string, { position: { x: number } }> };
};
expect(editedResult.workflow.nodes['node-5'].position.x).toBe(9999);
});
it('should sync concurrent replacements to different keys', async () => {
await sync1.start();
await sync2.start();
// Create initial structure
map1.set('node1', { name: 'Node 1', value: 100 });
map1.set('node3', { name: 'Node 3', value: 300 });
// Both docs edit different keys
map1.set('node1', { name: 'Edited by doc1', value: 100 });
map2.set('node3', { name: 'Edited by doc2', value: 300 });
// Both should converge with both edits
const result1 = map1.toJSON() as Record<string, { name: string }>;
const result2 = map2.toJSON() as Record<string, { name: string }>;
expect(result1.node1.name).toBe('Edited by doc1');
expect(result1.node3.name).toBe('Edited by doc2');
expect(result2.node1.name).toBe('Edited by doc1');
expect(result2.node3.name).toBe('Edited by doc2');
});
it('should sync workflow replacement', async () => {
await sync1.start();
await sync2.start();
const workflow = createWorkflowData(3);
map1.set('workflow', workflow);
// Replace workflow with a fresh modified version
const modifiedWorkflow = createWorkflowData(3);
modifiedWorkflow.name = 'Modified Workflow';
map1.set('workflow', modifiedWorkflow);
// Verify change synced to doc2
const result = map2.toJSON() as { workflow: { name: string } };
expect(result.workflow.name).toBe('Modified Workflow');
});
it('should sync rapid sequential replacements', async () => {
await sync1.start();
await sync2.start();
// Rapid replacements on doc1
for (let i = 0; i < 5; i++) {
map1.set(`node-${i}`, { name: `Rapid Update ${i}`, value: i });
}
// All edits should sync to doc2
const result = map2.toJSON() as Record<string, { name: string }>;
for (let i = 0; i < 5; i++) {
expect(result[`node-${i}`].name).toBe(`Rapid Update ${i}`);
}
});
});
describe('Array sync', () => {
let arr1: CRDTArray<string>;
let arr2: CRDTArray<string>;
beforeEach(() => {
arr1 = doc1.getArray<string>('items');
arr2 = doc2.getArray<string>('items');
});
it('should sync initial array state on connect', async () => {
arr1.push('a', 'b', 'c');
await sync2.start();
await sync1.start();
expect(arr2.toArray()).toEqual(['a', 'b', 'c']);
});
it('should sync push operations', async () => {
await sync1.start();
await sync2.start();
arr1.push('x', 'y', 'z');
expect(arr2.toArray()).toEqual(['x', 'y', 'z']);
});
it('should sync insert operations', async () => {
await sync1.start();
await sync2.start();
arr1.push('a', 'c');
arr1.insert(1, 'b');
expect(arr2.toArray()).toEqual(['a', 'b', 'c']);
});
it('should sync delete operations', async () => {
await sync1.start();
await sync2.start();
arr1.push('a', 'b', 'c', 'd');
arr1.delete(1, 2);
expect(arr2.toArray()).toEqual(['a', 'd']);
});
it('should sync bidirectionally', async () => {
await sync1.start();
await sync2.start();
arr1.push('from1');
arr2.push('from2');
expect(arr1.toArray()).toContain('from1');
expect(arr1.toArray()).toContain('from2');
expect(arr2.toArray()).toContain('from1');
expect(arr2.toArray()).toContain('from2');
});
it('should sync nested objects in arrays', async () => {
const objArr1 = doc1.getArray<{ name: string }>('objects');
const objArr2 = doc2.getArray<{ name: string }>('objects');
await sync1.start();
await sync2.start();
objArr1.push({ name: 'first' }, { name: 'second' });
expect(objArr2.toArray()).toEqual([{ name: 'first' }, { name: 'second' }]);
});
it('should sync nested arrays', async () => {
const nestedArr1 = doc1.getArray<string[]>('nested');
const nestedArr2 = doc2.getArray<string[]>('nested');
await sync1.start();
await sync2.start();
nestedArr1.push(['a', 'b'], ['c', 'd']);
expect(nestedArr2.toArray()).toEqual([
['a', 'b'],
['c', 'd'],
]);
});
it('should sync plain objects in array', async () => {
const objArr1 = doc1.getArray<{ value: number }>('objects');
const objArr2 = doc2.getArray<{ value: number }>('objects');
await sync1.start();
await sync2.start();
objArr1.push({ value: 100 });
// Plain objects are synced as-is
const result = objArr2.get(0) as { value: number };
expect(result.value).toBe(100);
});
it('should sync plain array stored in map', async () => {
await sync1.start();
await sync2.start();
map1.set('list', ['item1', 'item2']);
// Plain arrays are returned as-is
const list2 = map2.get('list') as string[];
expect(list2).toEqual(['item1', 'item2']);
});
it('should sync array replacement in map', async () => {
await sync1.start();
await sync2.start();
map1.set('list', ['a']);
// Replace whole array to modify
map1.set('list', ['a', 'b', 'c']);
const list2 = map2.get('list') as string[];
expect(list2).toEqual(['a', 'b', 'c']);
});
it('should sync nested structure with arrays in map', async () => {
await sync1.start();
await sync2.start();
map1.set('node', { connections: { main: ['conn-1'] } });
// Replace the whole structure to add more connections
const node = map1.get('node') as { connections: { main: string[] } };
map1.set('node', {
connections: {
main: [...node.connections.main, 'conn-2'],
},
});
const node2 = map2.get('node') as { connections: { main: string[] } };
expect(node2.connections.main).toEqual(['conn-1', 'conn-2']);
});
it('should handle concurrent push operations (both converge)', async () => {
await sync1.start();
await sync2.start();
arr1.push('from1');
arr2.push('from2');
// Both should converge to same state
const state1 = arr1.toArray();
const state2 = arr2.toArray();
expect(state1).toEqual(state2);
expect(state1).toContain('from1');
expect(state1).toContain('from2');
});
it('should handle concurrent insert at same index', async () => {
await sync1.start();
await sync2.start();
// Setup initial data on both
arr1.push('a', 'c');
// Now both have ['a', 'c'] - insert at index 1 from both
arr1.insert(1, 'b1');
arr2.insert(1, 'b2');
// Both should converge (CRDT determines order)
const state1 = arr1.toArray();
const state2 = arr2.toArray();
expect(state1).toEqual(state2);
expect(state1).toContain('b1');
expect(state1).toContain('b2');
expect(state1.length).toBe(4);
});
it('should handle rapid sequential array operations', async () => {
await sync1.start();
await sync2.start();
for (let i = 0; i < 10; i++) {
arr1.push(`item-${i}`);
}
expect(arr2.length).toBe(10);
for (let i = 0; i < 10; i++) {
expect(arr2.toArray()).toContain(`item-${i}`);
}
});
});
});