first commit
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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
# @n8n/crdt
CRDT abstraction layer for n8n collaborative editing. Provides a unified API
built on Yjs for real-time document synchronization.
## Quick Start
```typescript
import { createCRDTProvider, CRDTEngine } from '@n8n/crdt';
// Create provider
const provider = createCRDTProvider({ engine: CRDTEngine.yjs });
// Create document
const doc = provider.createDoc('workflow-123');
// Use data structures
const nodes = doc.getMap('nodes');
nodes.set('node-1', { position: { x: 100, y: 200 } });
// Plain objects are returned as-is (no automatic CRDT wrapping)
const node = nodes.get('node-1'); // Returns { position: { x: 100, y: 200 } }
// To update nested data, replace the whole object
nodes.set('node-1', { position: { x: 150, y: 200 } });
// Observe changes
nodes.onDeepChange((changes) => {
for (const change of changes) {
console.log(change.path, change.action, change.value);
// ['node-1'], 'update', { position: { x: 150, y: 200 } }
}
});
```
## Plain Objects and Sync
Plain objects stored in CRDT structures **do sync** across peers, but they sync as
**atomic values** (last-write-wins), not as collaborative structures:
```typescript
// Both peers start synced
mapA.set('node', { x: 100, y: 200 });
// After sync: mapB.get('node') → { x: 100, y: 200 }
// Concurrent edits to the same key = conflict (one wins)
mapA.set('node', { x: 150, y: 200 }); // Peer A changes x
mapB.set('node', { x: 100, y: 250 }); // Peer B changes y
// After sync: both get { x: 150, y: 200 } OR { x: 100, y: 250 }
// One write wins entirely - changes are NOT merged
// For fine-grained collaborative editing, use explicit CRDT structures:
const nodeX = doc.getMap('node-x'); // Separate CRDT map for x values
const nodeY = doc.getMap('node-y'); // Separate CRDT map for y values
```
This "no magic" design matches raw Yjs behavior and keeps the API predictable.
## Sync
```typescript
import { createSyncProvider, MockTransport } from '@n8n/crdt';
// Create linked transports
const transportA = new MockTransport();
const transportB = new MockTransport();
MockTransport.link(transportA, transportB);
// Create sync providers
const syncA = createSyncProvider(docA, transportA);
const syncB = createSyncProvider(docB, transportB);
// Start sync
await syncA.start();
await syncB.start();
// Changes now propagate automatically
```
## API
### Core Types
- `CRDTProvider` - Factory for creating documents
- `CRDTDoc` - Document container with `getMap()`, `getArray()`, `transact()`
- `CRDTMap<T>` - Key-value CRDT structure with deep change observation
- `CRDTArray<T>` - Ordered list CRDT structure
### Change Events
- `DeepChangeEvent` - Map changes with `path`, `action`, `value`, `oldValue`
- `ArrayChangeEvent` - Array changes in Quill delta format (`retain`, `insert`, `delete`)
### Sync
- `SyncProvider` - Manages document synchronization
- `SyncTransport` - Transport interface for moving binary data
- `MockTransport` - In-memory transport for testing
- `MessagePortTransport` - SharedWorker/Worker/MessageChannel communication
- `WebSocketTransport` - Server sync with auto-reconnect
## Transports
All transports implement the same `SyncTransport` interface:
```typescript
interface SyncTransport {
send(data: Uint8Array): void;
onReceive(handler: (data: Uint8Array) => void): Unsubscribe;
connect(): Promise<void>;
disconnect(): void;
readonly connected: boolean;
}
```
### WebSocket Transport
```typescript
import { WebSocketTransport, createSyncProvider } from '@n8n/crdt';
const transport = new WebSocketTransport({
url: 'wss://server/sync',
reconnect: true,
reconnectDelay: 1000,
maxReconnectAttempts: 10,
});
transport.onConnectionChange((connected) => {
console.log('Connection state:', connected);
});
const sync = createSyncProvider(doc, transport);
await sync.start();
```
### MessagePort Transport (SharedWorker)
```typescript
import { MessagePortTransport, createSyncProvider } from '@n8n/crdt';
// In main thread
const worker = new SharedWorker('worker.js');
const transport = new MessagePortTransport(worker.port);
const sync = createSyncProvider(doc, transport);
await sync.start();
```
## Not Yet Implemented
The following CRDT types are not yet part of this abstraction:
- **Text** - For collaborative text editing (rich text, code editors)
- **Counter** - For conflict-free increment/decrement operations
These can be added in future phases if needed.
+4
View File
@@ -0,0 +1,4 @@
import { defineConfig } from 'eslint/config';
import { nodeConfig } from '@n8n/eslint-config/node';
export default defineConfig(nodeConfig);
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@n8n/crdt",
"version": "0.2.0",
"description": "CRDT abstraction layer for n8n collaborative editing",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "vitest run",
"test:unit": "vitest run",
"test:dev": "vitest --silent=false"
},
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"files": [
"dist/**/*"
],
"dependencies": {
"y-protocols": "^1.0.7",
"yjs": "^13.6.20"
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"vitest": "catalog:",
"vitest-websocket-mock": "^0.5.0"
}
}
@@ -0,0 +1,72 @@
/**
* Test helpers for CRDT conformance tests.
*/
/**
* Create a deeply nested object (maps only, no arrays).
* Useful for testing deep traversal and modification.
*
* @param depth - How many levels deep to nest
* @param breadth - How many children per level
* @returns Nested object structure with `value` at leaves
*/
export function createNestedObject(depth: number, breadth: number): Record<string, unknown> {
if (depth === 0) {
return { value: `leaf-${Math.random().toString(36).slice(2, 8)}` };
}
const result: Record<string, unknown> = {};
for (let i = 0; i < breadth; i++) {
result[`child${i}`] = createNestedObject(depth - 1, breadth);
}
return result;
}
/**
* Create a workflow-like structure using maps (nodes keyed by id).
* Simulates real n8n workflow data for testing.
*
* @param nodeCount - Number of nodes to create
* @returns Workflow structure with nodes, connections, and settings
*/
export function createWorkflowData(nodeCount: number): Record<string, unknown> {
const nodes: Record<string, unknown> = {};
const connections: Record<string, unknown> = {};
for (let i = 0; i < nodeCount; i++) {
const nodeId = `node-${i}`;
nodes[nodeId] = {
id: nodeId,
name: `Node ${i}`,
type: i % 3 === 0 ? 'trigger' : i % 3 === 1 ? 'action' : 'transform',
position: { x: i * 200, y: Math.floor(i / 5) * 150 },
parameters: {
setting1: `value-${i}`,
setting2: i * 10,
nested: {
deep: {
config: { enabled: i % 2 === 0, threshold: i * 0.1 },
},
},
},
credentials: i % 2 === 0 ? { apiKey: `key-${i}` } : null,
};
if (i > 0) {
connections[`node-${i - 1}`] = {
main: { target: nodeId, type: 'main', index: 0 },
};
}
}
return {
name: 'Test Workflow',
active: true,
nodes,
connections,
settings: {
executionOrder: 'v1',
saveExecutionProgress: true,
callerPolicy: 'workflowsFromSameOwner',
},
};
}
@@ -0,0 +1,541 @@
import { CRDTEngine, createCRDTProvider, ChangeOrigin } from '../index';
import type { CRDTDoc, CRDTAwareness, AwarenessChangeEvent, AwarenessState } from '../types';
/**
* Custom awareness state for testing
*/
interface TestAwarenessState extends AwarenessState {
user: { name: string; color: string };
cursor?: { x: number; y: number };
}
/**
* Awareness conformance test suite - runs the same tests against both providers
* to ensure they behave identically.
*/
describe.each([CRDTEngine.yjs])('Awareness Conformance: %s', (engine) => {
let doc: CRDTDoc;
let awareness: CRDTAwareness<TestAwarenessState>;
beforeEach(() => {
const provider = createCRDTProvider({ engine });
doc = provider.createDoc('test-awareness');
awareness = doc.getAwareness<TestAwarenessState>();
});
afterEach(() => {
doc.destroy();
});
describe('Client ID', () => {
it('should have a unique clientId', () => {
expect(awareness.clientId).toBeDefined();
expect(typeof awareness.clientId).toBe('number');
expect(awareness.clientId).toBeGreaterThan(0);
});
it('should return same awareness instance on multiple getAwareness() calls', () => {
const awareness2 = doc.getAwareness<TestAwarenessState>();
expect(awareness.clientId).toBe(awareness2.clientId);
});
it('should have different clientIds for different docs', () => {
const provider = createCRDTProvider({ engine });
const doc2 = provider.createDoc('test-awareness-2');
const awareness2 = doc2.getAwareness<TestAwarenessState>();
// ClientIds should be different (with very high probability)
expect(awareness.clientId).not.toBe(awareness2.clientId);
doc2.destroy();
});
});
describe('Local State', () => {
it('should return null for uninitialized local state', () => {
expect(awareness.getLocalState()).toBeNull();
});
it('should set and get local state', () => {
const state: TestAwarenessState = {
user: { name: 'Alice', color: '#ff0000' },
cursor: { x: 100, y: 200 },
};
awareness.setLocalState(state);
expect(awareness.getLocalState()).toEqual(state);
});
it('should update local state', () => {
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
awareness.setLocalState({
user: { name: 'Alice', color: '#00ff00' },
cursor: { x: 50, y: 50 },
});
const state = awareness.getLocalState();
expect(state?.user.color).toBe('#00ff00');
expect(state?.cursor).toEqual({ x: 50, y: 50 });
});
it('should set local state to null (mark offline)', () => {
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
awareness.setLocalState(null);
expect(awareness.getLocalState()).toBeNull();
});
it('should update single field with setLocalStateField', () => {
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
awareness.setLocalStateField('cursor', { x: 100, y: 200 });
const state = awareness.getLocalState();
expect(state?.user.name).toBe('Alice');
expect(state?.cursor).toEqual({ x: 100, y: 200 });
});
it('should do nothing when setLocalStateField called with null state', () => {
// Local state is null
expect(awareness.getLocalState()).toBeNull();
// This should not throw or do anything
awareness.setLocalStateField('cursor', { x: 100, y: 200 });
expect(awareness.getLocalState()).toBeNull();
});
});
describe('Getting States', () => {
it('should return empty map when no state is set', () => {
const states = awareness.getStates();
expect(states.size).toBe(0);
});
it('should include local state in getStates()', () => {
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
const states = awareness.getStates();
expect(states.size).toBe(1);
expect(states.get(awareness.clientId)).toEqual({
user: { name: 'Alice', color: '#ff0000' },
});
});
it('should not include null states in getStates()', () => {
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
awareness.setLocalState(null);
const states = awareness.getStates();
expect(states.size).toBe(0);
});
});
describe('Change Events', () => {
it('should emit change event when setting local state', () => {
const changes: Array<{ event: AwarenessChangeEvent; origin: string }> = [];
awareness.onChange((event, origin) => {
changes.push({ event, origin });
});
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
expect(changes).toHaveLength(1);
// When setting state for the first time, it can be either 'added' or 'updated'
// depending on the provider implementation
const clientInAdded = changes[0].event.added.includes(awareness.clientId);
const clientInUpdated = changes[0].event.updated.includes(awareness.clientId);
expect(clientInAdded || clientInUpdated).toBe(true);
expect(changes[0].origin).toBe(ChangeOrigin.local);
});
it('should emit change event when updating local state', () => {
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
const changes: AwarenessChangeEvent[] = [];
awareness.onChange((event) => changes.push(event));
awareness.setLocalState({
user: { name: 'Alice', color: '#00ff00' },
});
expect(changes).toHaveLength(1);
expect(changes[0].updated).toContain(awareness.clientId);
});
it('should emit removed event when setting state to null', () => {
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
const changes: AwarenessChangeEvent[] = [];
awareness.onChange((event) => changes.push(event));
awareness.setLocalState(null);
expect(changes).toHaveLength(1);
expect(changes[0].removed).toContain(awareness.clientId);
});
it('should stop emitting events after unsubscribe', () => {
const changes: AwarenessChangeEvent[] = [];
const unsubscribe = awareness.onChange((event) => changes.push(event));
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
expect(changes).toHaveLength(1);
unsubscribe();
awareness.setLocalState({
user: { name: 'Bob', color: '#0000ff' },
});
expect(changes).toHaveLength(1); // No new changes
});
});
describe('Encoding and Decoding', () => {
it('should encode state as Uint8Array', () => {
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
const encoded = awareness.encodeState();
expect(encoded).toBeInstanceOf(Uint8Array);
expect(encoded.length).toBeGreaterThan(0);
});
it('should encode only specified clients', () => {
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
const encodedAll = awareness.encodeState();
const encodedOne = awareness.encodeState([awareness.clientId]);
expect(encodedAll).toBeInstanceOf(Uint8Array);
expect(encodedOne).toBeInstanceOf(Uint8Array);
// Both should have content (exact size may vary by format)
expect(encodedAll.length).toBeGreaterThan(0);
expect(encodedOne.length).toBeGreaterThan(0);
});
it('should apply update from another awareness', () => {
const provider = createCRDTProvider({ engine });
const doc2 = provider.createDoc('test-awareness-2');
const awareness2 = doc2.getAwareness<TestAwarenessState>();
// Set state in awareness2
awareness2.setLocalState({
user: { name: 'Bob', color: '#0000ff' },
});
// Encode and apply to awareness1
const update = awareness2.encodeState([awareness2.clientId]);
awareness.applyUpdate(update);
// awareness1 should now have awareness2's state
const states = awareness.getStates();
expect(states.get(awareness2.clientId)).toEqual({
user: { name: 'Bob', color: '#0000ff' },
});
doc2.destroy();
});
it('should emit change event when applying remote update', () => {
const provider = createCRDTProvider({ engine });
const doc2 = provider.createDoc('test-awareness-2');
const awareness2 = doc2.getAwareness<TestAwarenessState>();
awareness2.setLocalState({
user: { name: 'Bob', color: '#0000ff' },
});
const changes: Array<{ event: AwarenessChangeEvent; origin: string }> = [];
awareness.onChange((event, origin) => {
changes.push({ event, origin });
});
const update = awareness2.encodeState([awareness2.clientId]);
awareness.applyUpdate(update);
expect(changes).toHaveLength(1);
expect(changes[0].event.added).toContain(awareness2.clientId);
expect(changes[0].origin).toBe(ChangeOrigin.remote);
doc2.destroy();
});
});
describe('Update Events', () => {
it('should emit update when local state changes', () => {
const updates: Array<{ data: Uint8Array; origin: string }> = [];
awareness.onUpdate((data, origin) => {
updates.push({ data, origin });
});
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
expect(updates).toHaveLength(1);
expect(updates[0].data).toBeInstanceOf(Uint8Array);
expect(updates[0].origin).toBe(ChangeOrigin.local);
});
it('should stop emitting updates after unsubscribe', () => {
const updates: Uint8Array[] = [];
const unsubscribe = awareness.onUpdate((data) => {
updates.push(data);
});
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
expect(updates).toHaveLength(1);
unsubscribe();
awareness.setLocalState({
user: { name: 'Bob', color: '#0000ff' },
});
expect(updates).toHaveLength(1); // No new updates
});
});
describe('Remove States', () => {
it('should remove specified client states', () => {
const provider = createCRDTProvider({ engine });
const doc2 = provider.createDoc('test-awareness-2');
const awareness2 = doc2.getAwareness<TestAwarenessState>();
// Add remote state
awareness2.setLocalState({
user: { name: 'Bob', color: '#0000ff' },
});
const update = awareness2.encodeState([awareness2.clientId]);
awareness.applyUpdate(update);
expect(awareness.getStates().has(awareness2.clientId)).toBe(true);
// Remove the state
awareness.removeStates([awareness2.clientId]);
expect(awareness.getStates().has(awareness2.clientId)).toBe(false);
doc2.destroy();
});
it('should emit removed event when removing states', () => {
const provider = createCRDTProvider({ engine });
const doc2 = provider.createDoc('test-awareness-2');
const awareness2 = doc2.getAwareness<TestAwarenessState>();
awareness2.setLocalState({
user: { name: 'Bob', color: '#0000ff' },
});
const update = awareness2.encodeState([awareness2.clientId]);
awareness.applyUpdate(update);
const changes: AwarenessChangeEvent[] = [];
awareness.onChange((event) => changes.push(event));
awareness.removeStates([awareness2.clientId]);
expect(changes).toHaveLength(1);
expect(changes[0].removed).toContain(awareness2.clientId);
doc2.destroy();
});
it('should not remove own state', () => {
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
// Try to remove own state via removeStates (should be ignored)
awareness.removeStates([awareness.clientId]);
// Local state should still exist
expect(awareness.getLocalState()).not.toBeNull();
});
});
describe('Destroy', () => {
it('should mark client offline when destroyed', () => {
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
expect(awareness.getLocalState()).not.toBeNull();
awareness.destroy();
// After destroy, local state should be null (marked offline)
expect(awareness.getLocalState()).toBeNull();
});
it('should emit removed event when destroyed', () => {
const changes: AwarenessChangeEvent[] = [];
awareness.onChange((event) => changes.push(event));
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
expect(changes).toHaveLength(1);
awareness.destroy();
// Destroy should emit a removed event (marking client offline)
expect(changes.length).toBeGreaterThan(1);
const lastChange = changes[changes.length - 1];
expect(lastChange.removed).toContain(awareness.clientId);
});
});
describe('Two-Way Sync', () => {
it('should sync awareness between two docs', () => {
const provider = createCRDTProvider({ engine });
const doc2 = provider.createDoc('test-awareness-2');
const awareness2 = doc2.getAwareness<TestAwarenessState>();
// Set up two-way sync
awareness.onUpdate((update) => {
awareness2.applyUpdate(update);
});
awareness2.onUpdate((update) => {
awareness.applyUpdate(update);
});
// Set states
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
awareness2.setLocalState({
user: { name: 'Bob', color: '#0000ff' },
});
// Both should see each other
const states1 = awareness.getStates();
const states2 = awareness2.getStates();
expect(states1.size).toBe(2);
expect(states2.size).toBe(2);
expect(states1.get(awareness.clientId)?.user.name).toBe('Alice');
expect(states1.get(awareness2.clientId)?.user.name).toBe('Bob');
expect(states2.get(awareness.clientId)?.user.name).toBe('Alice');
expect(states2.get(awareness2.clientId)?.user.name).toBe('Bob');
doc2.destroy();
});
it('should propagate state updates', () => {
const provider = createCRDTProvider({ engine });
const doc2 = provider.createDoc('test-awareness-2');
const awareness2 = doc2.getAwareness<TestAwarenessState>();
// Set up two-way sync
awareness.onUpdate((update) => {
awareness2.applyUpdate(update);
});
awareness2.onUpdate((update) => {
awareness.applyUpdate(update);
});
// Initial state
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
// Update state
awareness.setLocalState({
user: { name: 'Alice', color: '#00ff00' },
cursor: { x: 100, y: 200 },
});
// awareness2 should see the update
const state = awareness2.getStates().get(awareness.clientId);
expect(state?.user.color).toBe('#00ff00');
expect(state?.cursor).toEqual({ x: 100, y: 200 });
doc2.destroy();
});
it('should handle offline correctly', () => {
const provider = createCRDTProvider({ engine });
const doc2 = provider.createDoc('test-awareness-2');
const awareness2 = doc2.getAwareness<TestAwarenessState>();
// Set up two-way sync
awareness.onUpdate((update) => {
awareness2.applyUpdate(update);
});
awareness2.onUpdate((update) => {
awareness.applyUpdate(update);
});
// Set state
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' },
});
expect(awareness2.getStates().has(awareness.clientId)).toBe(true);
// Go offline
awareness.setLocalState(null);
// awareness2 should see the removal
expect(awareness2.getStates().has(awareness.clientId)).toBe(false);
doc2.destroy();
});
});
});
describe('Awareness - Yjs specific', () => {
it('should use y-protocols Awareness internally', () => {
const provider = createCRDTProvider({ engine: CRDTEngine.yjs });
const doc = provider.createDoc('test');
const awareness = doc.getAwareness();
// Yjs awareness should have a valid clientId from y-protocols
expect(awareness.clientId).toBeDefined();
expect(typeof awareness.clientId).toBe('number');
doc.destroy();
});
});
@@ -0,0 +1 @@
export { YjsAwareness } from './yjs-awareness';
@@ -0,0 +1,100 @@
import {
Awareness as YProtocolAwareness,
encodeAwarenessUpdate,
applyAwarenessUpdate,
removeAwarenessStates,
} from 'y-protocols/awareness';
import type * as Y from 'yjs';
import type {
AwarenessChangeEvent,
AwarenessClientId,
AwarenessState,
ChangeOrigin,
CRDTAwareness,
Unsubscribe,
} from '../types';
import { ChangeOrigin as ChangeOriginConst } from '../types';
type YjsChangeHandler = (
changes: { added: number[]; updated: number[]; removed: number[] },
origin: unknown,
) => void;
/**
* Yjs implementation of CRDTAwareness.
* Thin wrapper over y-protocols Awareness.
*/
export class YjsAwareness<T extends AwarenessState = AwarenessState> implements CRDTAwareness<T> {
private readonly awareness: YProtocolAwareness;
constructor(yDoc: Y.Doc) {
this.awareness = new YProtocolAwareness(yDoc);
// Yjs initializes with {} but our API expects null for uninitialized state
this.awareness.setLocalState(null);
}
get clientId(): AwarenessClientId {
return this.awareness.clientID;
}
getLocalState(): T | null {
// y-protocols returns Record<string, any> | null, cast to generic T for type safety
const state = this.awareness.getLocalState();
if (state === null) return null;
return state as T;
}
setLocalState(state: T | null): void {
this.awareness.setLocalState(state);
}
setLocalStateField<K extends keyof T>(field: K, value: T[K]): void {
this.awareness.setLocalStateField(field as string, value);
}
getStates(): Map<AwarenessClientId, T> {
// y-protocols returns Map<number, Record<string, any>>, cast for type safety
return this.awareness.getStates() as Map<AwarenessClientId, T>;
}
onChange(handler: (event: AwarenessChangeEvent, origin: ChangeOrigin) => void): Unsubscribe {
const wrappedHandler: YjsChangeHandler = (changes, origin) => {
handler(changes, origin === 'local' ? ChangeOriginConst.local : ChangeOriginConst.remote);
};
this.awareness.on('change', wrappedHandler);
return () => this.awareness.off('change', wrappedHandler);
}
encodeState(clients?: AwarenessClientId[]): Uint8Array {
const clientsToEncode = clients ?? Array.from(this.awareness.getStates().keys());
return encodeAwarenessUpdate(this.awareness, clientsToEncode);
}
applyUpdate(update: Uint8Array): void {
applyAwarenessUpdate(this.awareness, update, 'remote');
}
onUpdate(handler: (update: Uint8Array, origin: ChangeOrigin) => void): Unsubscribe {
const wrappedHandler: YjsChangeHandler = (changes, origin) => {
const changedClients = [...changes.added, ...changes.updated, ...changes.removed];
if (changedClients.length === 0) return;
const update = encodeAwarenessUpdate(this.awareness, changedClients);
handler(update, origin === 'local' ? ChangeOriginConst.local : ChangeOriginConst.remote);
};
this.awareness.on('update', wrappedHandler);
return () => this.awareness.off('update', wrappedHandler);
}
removeStates(clients: AwarenessClientId[]): void {
const clientsToRemove = clients.filter((id) => id !== this.clientId);
if (clientsToRemove.length > 0) {
removeAwarenessStates(this.awareness, clientsToRemove, 'local');
}
}
destroy(): void {
this.awareness.setLocalState(null);
this.awareness.destroy();
}
}
+659
View File
@@ -0,0 +1,659 @@
import { createNestedObject, createWorkflowData } from './__tests__/helpers';
import { ChangeAction, CRDTEngine, createCRDTProvider, isArrayChange, isMapChange } from './index';
import type {
ArrayChangeEvent,
CRDTArray,
CRDTDoc,
CRDTMap,
DeepChange,
DeepChangeEvent,
} from './types';
describe('createCRDTProvider', () => {
it('should create a Yjs provider', () => {
const provider = createCRDTProvider({ engine: CRDTEngine.yjs });
expect(provider.name).toBe('yjs');
});
});
/**
* Conformance test suite - runs the same tests against both providers
* to ensure they behave identically.
*/
describe.each([CRDTEngine.yjs])('CRDT Conformance: %s', (engine) => {
let doc: CRDTDoc;
let map: CRDTMap<unknown>;
beforeEach(() => {
const provider = createCRDTProvider({ engine });
doc = provider.createDoc('test');
map = doc.getMap('test-map');
});
afterEach(() => {
doc.destroy();
});
describe('State Encoding', () => {
it('should encode empty doc as non-empty Uint8Array', () => {
const state = doc.encodeState();
expect(state).toBeInstanceOf(Uint8Array);
expect(state.length).toBeGreaterThan(0);
});
it('should encode doc with data as larger Uint8Array', () => {
const emptyState = doc.encodeState();
map.set('key', 'value');
map.set('nested', { a: 1, b: 2 });
const dataState = doc.encodeState();
expect(dataState).toBeInstanceOf(Uint8Array);
expect(dataState.length).toBeGreaterThan(emptyState.length);
});
});
describe('Apply Update', () => {
it('should apply update from another doc and merge data', () => {
// Create a second doc
const provider = createCRDTProvider({ engine });
const doc2 = provider.createDoc('test-2');
const map2 = doc2.getMap<unknown>('test-map');
// Add data to doc2
map2.set('fromDoc2', 'hello');
// Encode doc2's state and apply to doc1
const update = doc2.encodeState();
doc.applyUpdate(update);
// doc1 should now have doc2's data
expect(map.get('fromDoc2')).toBe('hello');
doc2.destroy();
});
it('should apply same update twice idempotently', () => {
const provider = createCRDTProvider({ engine });
const doc2 = provider.createDoc('test-2');
const map2 = doc2.getMap<unknown>('test-map');
map2.set('key', 'value');
const update = doc2.encodeState();
// Apply twice
doc.applyUpdate(update);
doc.applyUpdate(update);
// Should still have the same data, no duplicates or errors
expect(map.get('key')).toBe('value');
expect(map.toJSON()).toEqual({ key: 'value' });
doc2.destroy();
});
});
describe('onUpdate', () => {
it('should call handler when doc changes', () => {
const updates: Uint8Array[] = [];
doc.onUpdate((update) => updates.push(update));
map.set('key', 'value');
expect(updates).toHaveLength(1);
expect(updates[0]).toBeInstanceOf(Uint8Array);
});
it('should emit update that can be applied to another doc', () => {
const provider = createCRDTProvider({ engine });
const doc2 = provider.createDoc('test-2');
const map2 = doc2.getMap<unknown>('test-map');
// Subscribe to updates from doc1
doc.onUpdate((update) => {
doc2.applyUpdate(update);
});
// Make change in doc1
map.set('key', 'value');
// doc2 should have the data
expect(map2.get('key')).toBe('value');
doc2.destroy();
});
it('should stop calling handler after unsubscribe', () => {
const updates: Uint8Array[] = [];
const unsubscribe = doc.onUpdate((update) => updates.push(update));
map.set('key1', 'value1');
expect(updates).toHaveLength(1);
unsubscribe();
map.set('key2', 'value2');
expect(updates).toHaveLength(1); // No new updates
});
});
describe('Basic Operations', () => {
it('should set and get primitive values', () => {
map.set('string', 'hello');
map.set('number', 42);
map.set('boolean', true);
expect(map.get('string')).toBe('hello');
expect(map.get('number')).toBe(42);
expect(map.get('boolean')).toBe(true);
});
it('should set and get object values', () => {
map.set('object', { name: 'test', value: 123 });
const result = map.toJSON();
expect(result.object).toEqual({ name: 'test', value: 123 });
});
it('should delete values', () => {
map.set('key', 'value');
expect(map.has('key')).toBe(true);
map.delete('key');
expect(map.has('key')).toBe(false);
});
it('should iterate keys, values, and entries', () => {
map.set('a', 1);
map.set('b', 2);
expect(Array.from(map.keys())).toContain('a');
expect(Array.from(map.keys())).toContain('b');
expect(Array.from(map.values())).toContain(1);
expect(Array.from(map.values())).toContain(2);
expect(Array.from(map.entries())).toContainEqual(['a', 1]);
expect(Array.from(map.entries())).toContainEqual(['b', 2]);
});
it('should batch changes in transact()', () => {
const changes: DeepChange[] = [];
map.onDeepChange((changeEvents) => changes.push(...changeEvents));
doc.transact(() => {
map.set('a', 1);
map.set('b', 2);
map.set('c', 3);
});
// All changes should be batched into a single callback invocation
expect(changes).toHaveLength(3);
expect(map.toJSON()).toEqual({ a: 1, b: 2, c: 3 });
});
it('should handle nested transactions correctly', () => {
const batches: DeepChange[][] = [];
map.onDeepChange((changeEvents) => batches.push([...changeEvents]));
doc.transact(() => {
map.set('a', 1);
doc.transact(() => {
map.set('b', 2);
});
map.set('c', 3);
});
// All changes should arrive in a single batch
expect(batches).toHaveLength(1);
expect(batches[0]).toHaveLength(3);
expect(map.toJSON()).toEqual({ a: 1, b: 2, c: 3 });
});
});
describe('Nested Data Access', () => {
it('should return plain objects as-is (no CRDT wrapping)', () => {
map.set('node', { position: { x: 100, y: 200 } });
const node = map.get('node');
expect(node).toBeDefined();
// Plain objects are returned as-is, not wrapped in CRDTMap
expect(node).toEqual({ position: { x: 100, y: 200 } });
expect(typeof (node as Record<string, unknown>).position).toBe('object');
});
it('should return plain arrays as-is (no CRDT wrapping)', () => {
map.set('items', [1, 2, 3]);
const items = map.get('items');
expect(items).toBeDefined();
// Plain arrays are returned as-is, not wrapped in CRDTArray
expect(items).toEqual([1, 2, 3]);
expect(Array.isArray(items)).toBe(true);
});
it('should replace entire nested object to modify it', () => {
map.set('node', { position: { x: 100, y: 200 } });
// To modify nested data, replace the whole object
const node = map.get('node') as { position: { x: number; y: number } };
map.set('node', { position: { x: 150, y: node.position.y } });
const result = map.toJSON();
expect((result.node as { position: { x: number } }).position.x).toBe(150);
});
});
describe('Deep Change Events', () => {
it('should emit add events', () => {
const changes: DeepChange[] = [];
map.onDeepChange((changeEvents) => changes.push(...changeEvents));
map.set('key', 'value');
expect(changes).toHaveLength(1);
expect(isMapChange(changes[0])).toBe(true);
const change = changes[0] as DeepChangeEvent;
expect(change.action).toBe(ChangeAction.add);
expect(change.path).toEqual(['key']);
expect(change.value).toBe('value');
});
it('should emit update events with oldValue', () => {
map.set('key', 'old');
const changes: DeepChange[] = [];
map.onDeepChange((changeEvents) => changes.push(...changeEvents));
map.set('key', 'new');
expect(changes).toHaveLength(1);
const change = changes[0] as DeepChangeEvent;
expect(change.action).toBe(ChangeAction.update);
expect(change.path).toEqual(['key']);
expect(change.value).toBe('new');
expect(change.oldValue).toBe('old');
});
it('should emit delete events with oldValue', () => {
map.set('key', 'value');
const changes: DeepChange[] = [];
map.onDeepChange((changeEvents) => changes.push(...changeEvents));
map.delete('key');
expect(changes).toHaveLength(1);
const change = changes[0] as DeepChangeEvent;
expect(change.action).toBe(ChangeAction.delete);
expect(change.path).toEqual(['key']);
expect(change.oldValue).toBe('value');
});
it('should emit event when replacing nested object', () => {
map.set('node', { position: { x: 100, y: 200 } });
const changes: DeepChange[] = [];
map.onDeepChange((changeEvents) => changes.push(...changeEvents));
// With no-magic API, you replace the whole object to modify nested data
map.set('node', { position: { x: 150, y: 200 } });
expect(changes).toHaveLength(1);
const change = changes[0] as DeepChangeEvent;
expect(change.path).toEqual(['node']);
expect(change.action).toBe(ChangeAction.update);
expect(change.value).toEqual({ position: { x: 150, y: 200 } });
});
it('should emit single event for full object replacement', () => {
map.set('node', { position: { x: 100, y: 200 } });
const changes: DeepChange[] = [];
map.onDeepChange((changeEvents) => changes.push(...changeEvents));
map.set('node', { position: { x: 150, y: 200 } });
expect(changes).toHaveLength(1);
const change = changes[0] as DeepChangeEvent;
expect(change.path).toEqual(['node']);
expect(change.action).toBe(ChangeAction.update);
expect(change.value).toEqual({ position: { x: 150, y: 200 } });
// Note: oldValue for full object replacement may vary by provider
// Yjs returns {} because the Y.Map is already replaced when toJSON is called
});
it('should stop emitting events after unsubscribe', () => {
const changes: DeepChange[] = [];
const unsubscribe = map.onDeepChange((changeEvents) => changes.push(...changeEvents));
map.set('key1', 'value1');
expect(changes).toHaveLength(1);
unsubscribe();
map.set('key2', 'value2');
expect(changes).toHaveLength(1); // No new changes
});
});
describe('Large Deeply Nested Data', () => {
it('should store and retrieve deeply nested object (depth=5, breadth=3)', () => {
const deepData = createNestedObject(5, 3); // 3^5 = 243 leaf nodes
map.set('deep', deepData);
expect(map.toJSON()).toEqual({ deep: deepData });
});
it('should store and retrieve workflow-like structure with 50 nodes', () => {
const workflow = createWorkflowData(50);
map.set('workflow', workflow);
const result = map.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 retrieve deep nested data as plain objects', () => {
const deepData = createNestedObject(4, 2);
map.set('deep', deepData);
// Plain objects are returned as-is
const deep = map.get('deep') as Record<string, unknown>;
expect(deep).toEqual(deepData);
expect((deep.child0 as Record<string, unknown>).child0).toBeDefined();
});
it('should modify workflow by replacing node data', () => {
const workflow = createWorkflowData(10);
map.set('workflow', workflow);
// Create a fresh modified version
const updatedWorkflow = createWorkflowData(10);
(updatedWorkflow.nodes as Record<string, { position: { x: number; y: number } }>)[
'node-5'
].position = {
x: 9999,
y: 8888,
};
map.set('workflow', updatedWorkflow);
const result = map.toJSON() as {
workflow: { nodes: Record<string, { position: { x: number; y: number } }> };
};
expect(result.workflow.nodes['node-5'].position.x).toBe(9999);
expect(result.workflow.nodes['node-5'].position.y).toBe(8888);
});
it('should emit change event when replacing workflow data', () => {
const workflow = createWorkflowData(3);
map.set('workflow', workflow);
const changes: DeepChange[] = [];
map.onDeepChange((events) => changes.push(...events));
// Modify by replacing with fresh workflow
const newWorkflow = createWorkflowData(3);
newWorkflow.name = 'Renamed Workflow';
map.set('workflow', newWorkflow);
expect(changes.length).toBeGreaterThan(0);
const workflowChange = changes.filter(isMapChange).find((c) => c.path[0] === 'workflow');
expect(workflowChange).toBeDefined();
});
});
describe('CRDTArray Basic Operations', () => {
let arr: CRDTArray<string>;
beforeEach(() => {
arr = doc.getArray<string>('test-array');
});
it('should push and get values', () => {
arr.push('a', 'b', 'c');
expect(arr.get(0)).toBe('a');
expect(arr.get(1)).toBe('b');
expect(arr.get(2)).toBe('c');
expect(arr.get(3)).toBeUndefined();
});
it('should report correct length', () => {
expect(arr.length).toBe(0);
arr.push('a');
expect(arr.length).toBe(1);
arr.push('b', 'c');
expect(arr.length).toBe(3);
});
it('should insert at index', () => {
arr.push('a', 'c');
arr.insert(1, 'b');
expect(arr.toArray()).toEqual(['a', 'b', 'c']);
});
it('should delete elements', () => {
arr.push('a', 'b', 'c', 'd');
arr.delete(1, 2);
expect(arr.toArray()).toEqual(['a', 'd']);
});
it('should convert to array and JSON', () => {
arr.push('a', 'b', 'c');
expect(arr.toArray()).toEqual(['a', 'b', 'c']);
expect(arr.toJSON()).toEqual(['a', 'b', 'c']);
});
it('should handle nested objects as plain values', () => {
const objArr = doc.getArray<{ name: string }>('obj-array');
objArr.push({ name: 'first' }, { name: 'second' });
expect(objArr.toArray()).toEqual([{ name: 'first' }, { name: 'second' }]);
// Plain objects are returned as-is, not wrapped
const first = objArr.get(0) as { name: string };
expect(first.name).toBe('first');
});
it('should handle nested arrays as plain values', () => {
const nestedArr = doc.getArray<string[]>('nested-array');
nestedArr.push(['a', 'b'], ['c', 'd']);
expect(nestedArr.toArray()).toEqual([
['a', 'b'],
['c', 'd'],
]);
// Plain arrays are returned as-is, not wrapped
const inner = nestedArr.get(0) as string[];
expect(inner[0]).toBe('a');
expect(inner.length).toBe(2);
});
});
describe('CRDTArray Change Events', () => {
let arr: CRDTArray<string>;
beforeEach(() => {
arr = doc.getArray<string>('test-array');
});
it('should emit insert delta when pushing items', () => {
const changes: DeepChange[] = [];
arr.onDeepChange((changeEvents) => changes.push(...changeEvents));
arr.push('a', 'b');
expect(changes).toHaveLength(1);
expect(isArrayChange(changes[0])).toBe(true);
const change = changes[0] as ArrayChangeEvent;
expect(change.path).toEqual([]);
expect(change.delta).toEqual([{ insert: ['a', 'b'] }]);
});
it('should emit insert delta when inserting at index', () => {
arr.push('a', 'c');
const changes: DeepChange[] = [];
arr.onDeepChange((changeEvents) => changes.push(...changeEvents));
arr.insert(1, 'b');
expect(changes).toHaveLength(1);
const change = changes[0] as ArrayChangeEvent;
expect(change.path).toEqual([]);
expect(change.delta).toEqual([{ retain: 1 }, { insert: ['b'] }]);
});
it('should emit delete delta when deleting items', () => {
arr.push('a', 'b', 'c');
const changes: DeepChange[] = [];
arr.onDeepChange((changeEvents) => changes.push(...changeEvents));
arr.delete(1, 1);
expect(changes).toHaveLength(1);
const change = changes[0] as ArrayChangeEvent;
expect(change.path).toEqual([]);
expect(change.delta).toEqual([{ retain: 1 }, { delete: 1 }]);
});
it('should stop emitting events after unsubscribe', () => {
const changes: DeepChange[] = [];
const unsubscribe = arr.onDeepChange((changeEvents) => changes.push(...changeEvents));
arr.push('a');
expect(changes).toHaveLength(1);
unsubscribe();
arr.push('b');
expect(changes).toHaveLength(1);
});
});
describe('Plain Arrays in Maps', () => {
it('should return plain array when getting array value from map', () => {
map.set('items', ['a', 'b', 'c']);
// Plain arrays are returned as-is, not wrapped in CRDTArray
const items = map.get('items') as string[];
expect(items.length).toBe(3);
expect(items[0]).toBe('a');
expect(items).toEqual(['a', 'b', 'c']);
});
it('should emit event when replacing array in map', () => {
map.set('items', ['a', 'b']);
const changes: DeepChange[] = [];
map.onDeepChange((changeEvents) => changes.push(...changeEvents));
// To modify, replace the whole array
map.set('items', ['a', 'b', 'c']);
// Should have at least 1 change (number varies by engine)
expect(changes.length).toBeGreaterThanOrEqual(1);
// Find the map change for 'items'
const itemsChange = changes.filter(isMapChange).find((c) => c.path[0] === 'items');
expect(itemsChange).toBeDefined();
// Result should reflect the new array
expect(map.get('items')).toEqual(['a', 'b', 'c']);
});
it('should return plain nested object with array', () => {
map.set('node-1', { connections: ['conn-a'] });
// Plain objects are returned as-is
const node = map.get('node-1') as { connections: string[] };
expect(node.connections).toEqual(['conn-a']);
expect(Array.isArray(node.connections)).toBe(true);
});
});
describe('CRDTDoc sync state', () => {
it('should start with synced = false', () => {
expect(doc.synced).toBe(false);
});
it('should update synced state via setSynced()', () => {
expect(doc.synced).toBe(false);
doc.setSynced(true);
expect(doc.synced).toBe(true);
doc.setSynced(false);
expect(doc.synced).toBe(false);
});
it('should notify handlers when sync state changes', () => {
const states: boolean[] = [];
doc.onSync((isSynced) => states.push(isSynced));
doc.setSynced(true);
doc.setSynced(false);
doc.setSynced(true);
expect(states).toEqual([true, false, true]);
});
it('should not notify handlers when sync state is set to same value', () => {
const states: boolean[] = [];
doc.onSync((isSynced) => states.push(isSynced));
doc.setSynced(true);
doc.setSynced(true); // Same value, should not trigger
doc.setSynced(true); // Same value, should not trigger
expect(states).toEqual([true]);
});
it('should stop notifying after unsubscribe', () => {
const states: boolean[] = [];
const unsubscribe = doc.onSync((isSynced) => states.push(isSynced));
doc.setSynced(true);
expect(states).toEqual([true]);
unsubscribe();
doc.setSynced(false);
expect(states).toEqual([true]); // No new notifications
});
it('should support multiple handlers', () => {
const states1: boolean[] = [];
const states2: boolean[] = [];
doc.onSync((isSynced) => states1.push(isSynced));
doc.onSync((isSynced) => states2.push(isSynced));
doc.setSynced(true);
expect(states1).toEqual([true]);
expect(states2).toEqual([true]);
});
it('should reset synced to false on destroy', () => {
doc.setSynced(true);
expect(doc.synced).toBe(true);
doc.destroy();
// After destroy, synced should be false
expect(doc.synced).toBe(false);
// Create new doc for cleanup (afterEach expects doc to exist)
const provider = createCRDTProvider({ engine });
doc = provider.createDoc('test-replacement');
});
});
});
+95
View File
@@ -0,0 +1,95 @@
import { YjsProvider } from './providers/yjs';
import { CRDTEngine } from './types';
import type { CRDTConfig, CRDTProvider } from './types';
// Types
export type {
Unsubscribe,
ArrayDelta,
ArrayChangeEvent,
DeepChangeEvent,
DeepChange,
TransactionBatch,
CRDTArray,
CRDTMap,
CRDTDoc,
CRDTProvider,
CRDTConfig,
// Awareness types
AwarenessClientId,
AwarenessState,
AwarenessChangeEvent,
CRDTAwareness,
// Undo manager types
UndoManagerOptions,
UndoStackChangeEvent,
CRDTUndoManager,
} from './types';
// Constants (also exports corresponding types via declaration merging)
export { ChangeAction, ChangeOrigin, CRDTEngine } from './types';
// Type guards
export { isMapChange, isArrayChange } from './types';
// Awareness implementations
export { YjsAwareness } from './awareness/yjs-awareness';
// Undo manager implementations
export { YjsUndoManager, YjsUndoManagerOrigin, YjsRemoteOrigin } from './undo/yjs-undo-manager';
// Providers
export { YjsProvider } from './providers/yjs';
// Transports
export type { SyncTransport } from './transports';
export {
MockTransport,
MessagePortTransport,
WebSocketTransport,
WorkerTransport,
BroadcastChannelTransport,
} from './transports';
export type { WebSocketTransportConfig, WorkerTransportConfig } from './transports';
// Sync
export type { SyncProvider, CreateSyncProvider } from './sync';
export { BaseSyncProvider, createSyncProvider } from './sync';
// Protocol
export {
MESSAGE_SYNC,
MESSAGE_AWARENESS,
MESSAGE_SUBSCRIBE,
MESSAGE_UNSUBSCRIBE,
MESSAGE_CONNECTED,
MESSAGE_DISCONNECTED,
MESSAGE_INITIAL_SYNC,
encodeMessage,
decodeMessage,
encodeWithDocId,
decodeWithDocId,
encodeString,
decodeString,
stripDocId,
addDocId,
} from './protocol';
// Utilities
export { seedValueDeep, toJSON, getNestedValue, setNestedValue } from './utils';
/**
* Creates a CRDT provider based on the given configuration.
* @param config - Configuration specifying which CRDT engine to use
* @returns A CRDTProvider instance for the specified engine
*/
export function createCRDTProvider(config: CRDTConfig): CRDTProvider {
switch (config.engine) {
case CRDTEngine.yjs:
return new YjsProvider();
default: {
const exhaustiveCheck: never = config.engine;
throw new Error(`Unknown CRDT engine: ${String(exhaustiveCheck)}`);
}
}
}
+258
View File
@@ -0,0 +1,258 @@
import {
MESSAGE_SYNC,
MESSAGE_AWARENESS,
MESSAGE_SUBSCRIBE,
MESSAGE_UNSUBSCRIBE,
MESSAGE_CONNECTED,
MESSAGE_DISCONNECTED,
MESSAGE_INITIAL_SYNC,
encodeMessage,
decodeMessage,
encodeWithDocId,
decodeWithDocId,
stripDocId,
addDocId,
encodeString,
decodeString,
} from './protocol';
describe('Protocol', () => {
describe('Message constants', () => {
it('should have correct message type values', () => {
expect(MESSAGE_SYNC).toBe(0);
expect(MESSAGE_AWARENESS).toBe(1);
expect(MESSAGE_SUBSCRIBE).toBe(2);
expect(MESSAGE_UNSUBSCRIBE).toBe(3);
expect(MESSAGE_CONNECTED).toBe(4);
expect(MESSAGE_DISCONNECTED).toBe(5);
expect(MESSAGE_INITIAL_SYNC).toBe(6);
});
});
describe('encodeMessage / decodeMessage', () => {
it('should encode and decode a message with payload', () => {
const payload = new Uint8Array([1, 2, 3, 4, 5]);
const encoded = encodeMessage(MESSAGE_SYNC, payload);
expect(encoded[0]).toBe(MESSAGE_SYNC);
expect(encoded.length).toBe(6); // 1 type + 5 payload
const decoded = decodeMessage(encoded);
expect(decoded.messageType).toBe(MESSAGE_SYNC);
expect(decoded.payload).toEqual(payload);
});
it('should encode and decode a message with empty payload', () => {
const payload = new Uint8Array(0);
const encoded = encodeMessage(MESSAGE_DISCONNECTED, payload);
expect(encoded.length).toBe(1); // just type byte
const decoded = decodeMessage(encoded);
expect(decoded.messageType).toBe(MESSAGE_DISCONNECTED);
expect(decoded.payload.length).toBe(0);
});
it('should throw on empty message', () => {
expect(() => decodeMessage(new Uint8Array(0))).toThrow('Empty message');
});
it('should handle all message types', () => {
const types = [
MESSAGE_SYNC,
MESSAGE_AWARENESS,
MESSAGE_SUBSCRIBE,
MESSAGE_UNSUBSCRIBE,
MESSAGE_CONNECTED,
MESSAGE_DISCONNECTED,
MESSAGE_INITIAL_SYNC,
];
types.forEach((type) => {
const payload = new Uint8Array([42]);
const encoded = encodeMessage(type, payload);
const decoded = decodeMessage(encoded);
expect(decoded.messageType).toBe(type);
});
});
});
describe('encodeWithDocId / decodeWithDocId', () => {
it('should encode and decode a message with docId and payload', () => {
const docId = 'workflow-123';
const payload = new Uint8Array([10, 20, 30]);
const encoded = encodeWithDocId(MESSAGE_SYNC, docId, payload);
const decoded = decodeWithDocId(encoded);
expect(decoded.messageType).toBe(MESSAGE_SYNC);
expect(decoded.docId).toBe(docId);
expect(decoded.payload).toEqual(payload);
});
it('should encode and decode a message with docId and no payload', () => {
const docId = 'doc-456';
const encoded = encodeWithDocId(MESSAGE_UNSUBSCRIBE, docId);
const decoded = decodeWithDocId(encoded);
expect(decoded.messageType).toBe(MESSAGE_UNSUBSCRIBE);
expect(decoded.docId).toBe(docId);
expect(decoded.payload.length).toBe(0);
});
it('should handle unicode docIds', () => {
const docId = 'workflow-αβγ-日本語';
const payload = new Uint8Array([1, 2, 3]);
const encoded = encodeWithDocId(MESSAGE_AWARENESS, docId, payload);
const decoded = decodeWithDocId(encoded);
expect(decoded.docId).toBe(docId);
});
it('should handle empty docId', () => {
const docId = '';
const payload = new Uint8Array([99]);
const encoded = encodeWithDocId(MESSAGE_SYNC, docId, payload);
const decoded = decodeWithDocId(encoded);
expect(decoded.docId).toBe('');
expect(decoded.payload).toEqual(payload);
});
it('should handle long docIds', () => {
const docId = 'a'.repeat(1000);
const encoded = encodeWithDocId(MESSAGE_SYNC, docId);
const decoded = decodeWithDocId(encoded);
expect(decoded.docId).toBe(docId);
});
it('should throw on message too short', () => {
expect(() => decodeWithDocId(new Uint8Array([0]))).toThrow(
'Message too short to contain docId',
);
expect(() => decodeWithDocId(new Uint8Array([0, 0]))).toThrow(
'Message too short to contain docId',
);
});
it('should throw on truncated docId', () => {
// Message says docId is 10 bytes but only 2 are present
const truncated = new Uint8Array([0, 0, 10, 65, 66]); // type=0, docIdLen=10, only "AB"
expect(() => decodeWithDocId(truncated)).toThrow(
'Message too short for declared docId length',
);
});
it('should correctly encode docIdLen as big-endian u16', () => {
// Test a docId longer than 255 characters (requires 2-byte length)
const docId = 'x'.repeat(300);
const encoded = encodeWithDocId(MESSAGE_SYNC, docId);
// Check the length bytes manually
const docIdLen = (encoded[1] << 8) | encoded[2];
expect(docIdLen).toBe(300);
const decoded = decodeWithDocId(encoded);
expect(decoded.docId.length).toBe(300);
});
});
describe('stripDocId', () => {
it('should strip docId from worker message', () => {
const docId = 'workflow-123';
const payload = new Uint8Array([1, 2, 3]);
const workerMsg = encodeWithDocId(MESSAGE_SYNC, docId, payload);
const serverMsg = stripDocId(workerMsg);
expect(serverMsg[0]).toBe(MESSAGE_SYNC);
expect(serverMsg.subarray(1)).toEqual(payload);
});
it('should preserve empty payload', () => {
const workerMsg = encodeWithDocId(MESSAGE_DISCONNECTED, 'doc-1');
const serverMsg = stripDocId(workerMsg);
expect(serverMsg.length).toBe(1);
expect(serverMsg[0]).toBe(MESSAGE_DISCONNECTED);
});
});
describe('addDocId', () => {
it('should add docId to server message', () => {
const payload = new Uint8Array([10, 20, 30]);
const serverMsg = encodeMessage(MESSAGE_AWARENESS, payload);
const workerMsg = addDocId('my-doc', serverMsg);
const decoded = decodeWithDocId(workerMsg);
expect(decoded.messageType).toBe(MESSAGE_AWARENESS);
expect(decoded.docId).toBe('my-doc');
expect(decoded.payload).toEqual(payload);
});
it('should handle empty payload', () => {
const serverMsg = encodeMessage(MESSAGE_CONNECTED, new Uint8Array(0));
const workerMsg = addDocId('doc-xyz', serverMsg);
const decoded = decodeWithDocId(workerMsg);
expect(decoded.messageType).toBe(MESSAGE_CONNECTED);
expect(decoded.docId).toBe('doc-xyz');
expect(decoded.payload.length).toBe(0);
});
});
describe('stripDocId and addDocId roundtrip', () => {
it('should be inverse operations', () => {
const docId = 'test-doc';
const originalPayload = new Uint8Array([5, 10, 15, 20]);
// Start with server message
const serverMsg = encodeMessage(MESSAGE_SYNC, originalPayload);
// Add docId
const workerMsg = addDocId(docId, serverMsg);
// Strip docId
const backToServer = stripDocId(workerMsg);
// Should match original
expect(backToServer).toEqual(serverMsg);
});
});
describe('encodeString / decodeString', () => {
it('should encode and decode a simple string', () => {
const str = 'wss://server.example.com/crdt';
const encoded = encodeString(str);
const decoded = decodeString(encoded);
expect(decoded).toBe(str);
});
it('should handle empty string', () => {
const str = '';
const encoded = encodeString(str);
const decoded = decodeString(encoded);
expect(decoded).toBe(str);
expect(encoded.length).toBe(0);
});
it('should handle unicode strings', () => {
const str = 'https://例え.jp/path?name=日本語';
const encoded = encodeString(str);
const decoded = decodeString(encoded);
expect(decoded).toBe(str);
});
it('should handle emoji', () => {
const str = 'doc-🚀-test';
const encoded = encodeString(str);
const decoded = decodeString(encoded);
expect(decoded).toBe(str);
});
});
});
+189
View File
@@ -0,0 +1,189 @@
/**
* WebSocket message protocol constants.
*
* Following the y-websocket protocol, messages are prefixed with a type byte
* to multiplex different message types over the same connection.
*
* Server protocol format: [messageType: u8, ...payload]
* Worker protocol format: [messageType: u8, docIdLen: u16, docId: utf8, ...payload]
*/
// =============================================================================
// Message Types
// =============================================================================
/**
* Sync message type - contains CRDT document updates.
* Payload is the raw CRDT update bytes.
*/
export const MESSAGE_SYNC = 0;
/**
* Awareness message type - contains ephemeral presence/cursor data.
* Payload is awareness update bytes.
*/
export const MESSAGE_AWARENESS = 1;
/**
* Subscribe message type - request to subscribe to a document.
* Payload is serverUrl as UTF-8 string (empty for local-only docs).
*/
export const MESSAGE_SUBSCRIBE = 2;
/**
* Unsubscribe message type - request to unsubscribe from a document.
* Payload is empty.
*/
export const MESSAGE_UNSUBSCRIBE = 3;
/**
* Connected message type - notification that server connection is established.
* Sent from worker to frontend. Payload is empty.
*/
export const MESSAGE_CONNECTED = 4;
/**
* Disconnected message type - notification that server connection was lost.
* Sent from worker to frontend. Payload is empty.
*/
export const MESSAGE_DISCONNECTED = 5;
/**
* Initial sync message type - notification that initial sync is complete.
* Sent from worker to frontend after first server data is received. Payload is empty.
*/
export const MESSAGE_INITIAL_SYNC = 6;
/**
* Encode a message with type prefix.
* @param messageType - The message type (MESSAGE_SYNC or MESSAGE_AWARENESS)
* @param payload - The message payload bytes
* @returns Encoded message with type prefix
*/
export function encodeMessage(messageType: number, payload: Uint8Array): Uint8Array {
const result = new Uint8Array(1 + payload.length);
result[0] = messageType;
result.set(payload, 1);
return result;
}
/**
* Decode a message to extract type and payload.
* @param data - The encoded message bytes
* @returns Object with messageType and payload
*/
export function decodeMessage(data: Uint8Array): { messageType: number; payload: Uint8Array } {
if (data.length === 0) {
throw new Error('Empty message');
}
return {
messageType: data[0],
payload: data.subarray(1),
};
}
// =============================================================================
// Worker Protocol (with docId for multiplexing)
// =============================================================================
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
/**
* Encode a message with docId prefix for worker routing.
* Format: [messageType: u8, docIdLen: u16, docId: utf8, ...payload]
*
* @param messageType - The message type
* @param docId - Document ID for routing
* @param payload - Optional payload bytes (default: empty)
* @returns Encoded message with docId prefix
*/
export function encodeWithDocId(
messageType: number,
docId: string,
payload: Uint8Array = new Uint8Array(0),
): Uint8Array {
const docIdBytes = textEncoder.encode(docId);
const docIdLen = docIdBytes.length;
// Format: [type: 1][docIdLen: 2][docId: N][payload: M]
const result = new Uint8Array(1 + 2 + docIdLen + payload.length);
result[0] = messageType;
// Write docIdLen as big-endian u16
result[1] = (docIdLen >> 8) & 0xff;
result[2] = docIdLen & 0xff;
result.set(docIdBytes, 3);
result.set(payload, 3 + docIdLen);
return result;
}
/**
* Decode a message with docId prefix.
* @param data - The encoded message bytes
* @returns Object with messageType, docId, and payload
*/
export function decodeWithDocId(data: Uint8Array): {
messageType: number;
docId: string;
payload: Uint8Array;
} {
if (data.length < 3) {
throw new Error('Message too short to contain docId');
}
const messageType = data[0];
// Read docIdLen as big-endian u16
const docIdLen = (data[1] << 8) | data[2];
if (data.length < 3 + docIdLen) {
throw new Error('Message too short for declared docId length');
}
const docId = textDecoder.decode(data.subarray(3, 3 + docIdLen));
const payload = data.subarray(3 + docIdLen);
return { messageType, docId, payload };
}
/**
* Strip docId from a worker message, returning server-format message.
* Converts [type, docIdLen, docId, payload] → [type, payload]
*
* @param data - Worker-format message with docId
* @returns Server-format message without docId
*/
export function stripDocId(data: Uint8Array): Uint8Array {
const { messageType, payload } = decodeWithDocId(data);
return encodeMessage(messageType, payload);
}
/**
* Add docId to a server message, returning worker-format message.
* Converts [type, payload] → [type, docIdLen, docId, payload]
*
* @param docId - Document ID to add
* @param data - Server-format message
* @returns Worker-format message with docId
*/
export function addDocId(docId: string, data: Uint8Array): Uint8Array {
const { messageType, payload } = decodeMessage(data);
return encodeWithDocId(messageType, docId, payload);
}
/**
* Encode a string as UTF-8 bytes.
* Useful for encoding serverUrl in SUBSCRIBE messages.
*/
export function encodeString(str: string): Uint8Array {
return textEncoder.encode(str);
}
/**
* Decode UTF-8 bytes as a string.
* Useful for decoding serverUrl from SUBSCRIBE messages.
*/
export function decodeString(data: Uint8Array): string {
return textDecoder.decode(data);
}
@@ -0,0 +1,604 @@
import type {
ChangeOrigin,
CRDTArray,
CRDTDoc,
CRDTMap,
DeepChange,
DeepChangeEvent,
} from '../types';
import { ChangeAction, ChangeOrigin as ChangeOriginConst } from '../types';
import { YjsProvider } from './yjs';
// Note: CRDTDoc sync state tests are in index.test.ts (multi-engine conformance tests)
describe('YjsProvider', () => {
describe('CRDTMap basic operations', () => {
let doc: CRDTDoc;
let map: CRDTMap<string>;
beforeEach(() => {
const provider = new YjsProvider();
doc = provider.createDoc('test');
map = doc.getMap<string>('test-map');
});
afterEach(() => {
doc.destroy();
});
it('should set and get values', () => {
map.set('key1', 'value1');
expect(map.get('key1')).toBe('value1');
expect(map.get('nonexistent')).toBeUndefined();
});
it('should delete values', () => {
map.set('key1', 'value1');
expect(map.has('key1')).toBe(true);
map.delete('key1');
expect(map.has('key1')).toBe(false);
expect(map.get('key1')).toBeUndefined();
});
it('should check key existence with has()', () => {
expect(map.has('key1')).toBe(false);
map.set('key1', 'value1');
expect(map.has('key1')).toBe(true);
});
it('should iterate keys', () => {
map.set('a', '1');
map.set('b', '2');
map.set('c', '3');
const keys = Array.from(map.keys());
expect(keys).toContain('a');
expect(keys).toContain('b');
expect(keys).toContain('c');
expect(keys).toHaveLength(3);
});
it('should iterate values', () => {
map.set('a', '1');
map.set('b', '2');
const values = Array.from(map.values());
expect(values).toContain('1');
expect(values).toContain('2');
expect(values).toHaveLength(2);
});
it('should iterate entries', () => {
map.set('a', '1');
map.set('b', '2');
const entries = Array.from(map.entries());
expect(entries).toContainEqual(['a', '1']);
expect(entries).toContainEqual(['b', '2']);
expect(entries).toHaveLength(2);
});
it('should convert to JSON', () => {
map.set('a', '1');
map.set('b', '2');
expect(map.toJSON()).toEqual({ a: '1', b: '2' });
});
it('should share underlying data for the same name', () => {
const map1 = doc.getMap('test-map');
const map2 = doc.getMap('test-map');
map1.set('key', 'value');
expect(map2.get('key')).toBe('value');
});
it('should return the same wrapper instance for the same map', () => {
const map1 = doc.getMap('test-map');
const map2 = doc.getMap('test-map');
expect(map1).toBe(map2);
});
it('should return the same wrapper for nested maps on multiple get() calls', () => {
const nodesMap = doc.getMap('nodes');
const nodeMap = doc.createMap();
nodeMap.set('name', 'test');
nodesMap.set('node1', nodeMap);
const retrieved1 = nodesMap.get('node1');
const retrieved2 = nodesMap.get('node1');
expect(retrieved1).toBe(retrieved2);
});
it('should batch changes in transact()', () => {
doc.transact(() => {
map.set('a', '1');
map.set('b', '2');
map.set('c', '3');
});
expect(map.toJSON()).toEqual({ a: '1', b: '2', c: '3' });
});
it('should remain usable after exception in transaction', () => {
expect(() => {
doc.transact(() => {
map.set('before-error', 'value');
throw new Error('Intentional error');
});
}).toThrow('Intentional error');
// Document should still be usable after the error
map.set('after-error', 'works');
expect(map.get('after-error')).toBe('works');
});
});
describe('CRDTMap onDeepChange', () => {
let doc: CRDTDoc;
let map: CRDTMap<unknown>;
beforeEach(() => {
const provider = new YjsProvider();
doc = provider.createDoc('test');
map = doc.getMap('test-map');
});
afterEach(() => {
doc.destroy();
});
it('should emit add event when adding a key', () => {
const changes: DeepChange[] = [];
let lastOrigin: ChangeOrigin | undefined;
map.onDeepChange((c, origin) => {
changes.push(...c);
lastOrigin = origin;
});
map.set('key1', 'value1');
expect(changes).toHaveLength(1);
expect(changes[0]).toEqual({
path: ['key1'],
action: ChangeAction.add,
value: 'value1',
});
expect(lastOrigin).toBe(ChangeOriginConst.local);
});
it('should emit update event when updating a key', () => {
map.set('key1', 'value1');
const changes: DeepChange[] = [];
map.onDeepChange((c) => changes.push(...c));
map.set('key1', 'value2');
expect(changes).toHaveLength(1);
expect(changes[0]).toEqual({
path: ['key1'],
action: ChangeAction.update,
value: 'value2',
oldValue: 'value1',
});
});
it('should emit delete event when deleting a key', () => {
map.set('key1', 'value1');
const changes: DeepChange[] = [];
map.onDeepChange((c) => changes.push(...c));
map.delete('key1');
expect(changes).toHaveLength(1);
expect(changes[0]).toEqual({
path: ['key1'],
action: ChangeAction.delete,
oldValue: 'value1',
});
});
it('should emit multiple changes in a single batch for transact', () => {
const changes: DeepChange[] = [];
map.onDeepChange((c) => changes.push(...c));
doc.transact(() => {
map.set('a', '1');
map.set('b', '2');
});
expect(changes).toHaveLength(2);
expect(changes).toContainEqual({
path: ['a'],
action: ChangeAction.add,
value: '1',
});
expect(changes).toContainEqual({
path: ['b'],
action: ChangeAction.add,
value: '2',
});
});
it('should stop emitting events after unsubscribe', () => {
const changes: DeepChange[] = [];
const unsubscribe = map.onDeepChange((c) => changes.push(...c));
map.set('key1', 'value1');
expect(changes).toHaveLength(1);
unsubscribe();
map.set('key2', 'value2');
expect(changes).toHaveLength(1); // No new changes
});
it('should emit correct path for nested object changes (full replace)', () => {
map.set('node-1', { position: { x: 100, y: 200 } });
const changes: DeepChange[] = [];
map.onDeepChange((c) => changes.push(...c));
// Update the entire node (top-level change)
map.set('node-1', { position: { x: 150, y: 200 } });
expect(changes).toHaveLength(1);
const change = changes[0] as DeepChangeEvent;
expect(change.path).toEqual(['node-1']);
expect(change.action).toBe(ChangeAction.update);
expect(change.value).toEqual({ position: { x: 150, y: 200 } });
});
it('should return plain objects (no CRDT wrapping)', () => {
map.set('node-1', { position: { x: 100, y: 200 } });
// Plain objects are returned as-is, not wrapped
const node = map.get('node-1');
expect(node).toEqual({ position: { x: 100, y: 200 } });
});
it('should return plain arrays (no CRDT wrapping)', () => {
map.set('items', ['a', 'b', 'c']);
// Plain arrays are returned as-is, not wrapped
const items = map.get('items') as string[];
expect(items.length).toBe(3);
expect(items[0]).toBe('a');
expect(items).toEqual(['a', 'b', 'c']);
});
it('should emit event when replacing nested object', () => {
map.set('node-1', { position: { x: 100, y: 200 } });
const changes: DeepChange[] = [];
map.onDeepChange((c) => changes.push(...c));
// Replace the whole object to modify nested data
map.set('node-1', { position: { x: 150, y: 200 } });
expect(changes).toHaveLength(1);
const change = changes[0] as DeepChangeEvent;
expect(change.path).toEqual(['node-1']);
expect(change.action).toBe(ChangeAction.update);
expect(change.value).toEqual({ position: { x: 150, y: 200 } });
});
});
describe('CRDTArray basic operations', () => {
let doc: CRDTDoc;
let arr: CRDTArray<string>;
beforeEach(() => {
const provider = new YjsProvider();
doc = provider.createDoc('test');
arr = doc.getArray<string>('test-array');
});
afterEach(() => {
doc.destroy();
});
it('should push and get values', () => {
arr.push('a', 'b', 'c');
expect(arr.get(0)).toBe('a');
expect(arr.get(1)).toBe('b');
expect(arr.get(2)).toBe('c');
expect(arr.get(3)).toBeUndefined();
});
it('should report correct length', () => {
expect(arr.length).toBe(0);
arr.push('a');
expect(arr.length).toBe(1);
arr.push('b', 'c');
expect(arr.length).toBe(3);
});
it('should insert at index', () => {
arr.push('a', 'c');
arr.insert(1, 'b');
expect(arr.toArray()).toEqual(['a', 'b', 'c']);
});
it('should insert multiple items at index', () => {
arr.push('a', 'd');
arr.insert(1, 'b', 'c');
expect(arr.toArray()).toEqual(['a', 'b', 'c', 'd']);
});
it('should delete single element', () => {
arr.push('a', 'b', 'c');
arr.delete(1);
expect(arr.toArray()).toEqual(['a', 'c']);
});
it('should delete multiple elements', () => {
arr.push('a', 'b', 'c', 'd');
arr.delete(1, 2);
expect(arr.toArray()).toEqual(['a', 'd']);
});
it('should convert to array', () => {
arr.push('a', 'b', 'c');
expect(arr.toArray()).toEqual(['a', 'b', 'c']);
});
it('should convert to JSON (same as toArray)', () => {
arr.push('a', 'b', 'c');
expect(arr.toJSON()).toEqual(['a', 'b', 'c']);
});
it('should share underlying data for the same name', () => {
const arr1 = doc.getArray<string>('test-array');
const arr2 = doc.getArray<string>('test-array');
arr1.push('value');
expect(arr2.get(0)).toBe('value');
});
it('should handle nested objects as plain values', () => {
const objArr = doc.getArray<{ name: string }>('obj-array');
objArr.push({ name: 'first' }, { name: 'second' });
expect(objArr.toArray()).toEqual([{ name: 'first' }, { name: 'second' }]);
// Plain objects are returned as-is, not wrapped
const first = objArr.get(0) as { name: string };
expect(first.name).toBe('first');
});
it('should handle nested arrays as plain values', () => {
const nestedArr = doc.getArray<string[]>('nested-array');
nestedArr.push(['a', 'b'], ['c', 'd']);
expect(nestedArr.toArray()).toEqual([
['a', 'b'],
['c', 'd'],
]);
// Plain arrays are returned as-is, not wrapped
const inner = nestedArr.get(0) as string[];
expect(inner[0]).toBe('a');
expect(inner.length).toBe(2);
});
});
describe('createMap and createArray', () => {
let doc: CRDTDoc;
beforeEach(() => {
const provider = new YjsProvider();
doc = provider.createDoc('test');
});
afterEach(() => {
doc.destroy();
});
it('should create a map that can be populated before attaching', () => {
const nodesMap = doc.getMap('nodes');
const nodeMap = doc.createMap<string>();
// Populate before attaching (Yjs buffers writes internally)
nodeMap.set('name', 'test-node');
nodeMap.set('type', 'action');
// Attach to document
nodesMap.set('node_1', nodeMap);
// Data is preserved after attachment
expect(nodesMap.toJSON()).toEqual({
node_1: { name: 'test-node', type: 'action' },
});
});
it('should create an array that can be populated before attaching', () => {
const dataMap = doc.getMap('data');
const items = doc.createArray<string>();
// Populate before attaching (Yjs buffers writes internally)
items.push('a', 'b');
items.insert(1, 'x');
// Attach to document
dataMap.set('items', items);
// Data is preserved after attachment
expect(dataMap.toJSON()).toEqual({ items: ['a', 'x', 'b'] });
});
it('should store standalone map in document map', () => {
const nodesMap = doc.getMap('nodes');
const nodeMap = doc.createMap<unknown>();
// Populate the standalone map
nodeMap.set('name', 'test-node');
nodeMap.set('position', { x: 100, y: 200 });
// Attach to document
nodesMap.set('node_1', nodeMap);
expect(nodesMap.toJSON()).toEqual({
node_1: {
name: 'test-node',
position: { x: 100, y: 200 },
},
});
});
it('should store standalone array in document map', () => {
const dataMap = doc.getMap('data');
const items = doc.createArray<string>();
// Populate the standalone array
items.push('a', 'b', 'c');
// Attach to document
dataMap.set('items', items);
expect(dataMap.toJSON()).toEqual({ items: ['a', 'b', 'c'] });
});
it('should store nested standalone structures', () => {
const nodesMap = doc.getMap('nodes');
const nodeMap = doc.createMap<unknown>();
const connections = doc.createArray<string>();
// Populate the nested array
connections.push('conn_1', 'conn_2');
// Attach the nested array to the standalone map
nodeMap.set('name', 'test');
nodeMap.set('connections', connections);
// Attach the map to the document
nodesMap.set('node_1', nodeMap);
expect(nodesMap.toJSON()).toEqual({
node_1: {
name: 'test',
connections: ['conn_1', 'conn_2'],
},
});
});
it('should push standalone maps to document array', () => {
const nodesArray = doc.getArray('nodes');
const node1 = doc.createMap<string>();
const node2 = doc.createMap<string>();
// Populate before attaching
node1.set('name', 'first');
node2.set('name', 'second');
// Attach to document
nodesArray.push(node1, node2);
expect(nodesArray.toJSON()).toEqual([{ name: 'first' }, { name: 'second' }]);
});
});
describe('CRDTArray onDeepChange', () => {
let doc: CRDTDoc;
let arr: CRDTArray<string>;
beforeEach(() => {
const provider = new YjsProvider();
doc = provider.createDoc('test');
arr = doc.getArray<string>('test-array');
});
afterEach(() => {
doc.destroy();
});
it('should emit insert delta when pushing items', () => {
const changes: DeepChange[] = [];
arr.onDeepChange((c) => changes.push(...c));
arr.push('a', 'b');
expect(changes).toHaveLength(1);
expect(changes[0]).toEqual({
path: [],
delta: [{ insert: ['a', 'b'] }],
});
});
it('should emit insert delta when inserting at index', () => {
arr.push('a', 'c');
const changes: DeepChange[] = [];
arr.onDeepChange((c) => changes.push(...c));
arr.insert(1, 'b');
expect(changes).toHaveLength(1);
expect(changes[0]).toEqual({
path: [],
delta: [{ retain: 1 }, { insert: ['b'] }],
});
});
it('should emit delete delta when deleting items', () => {
arr.push('a', 'b', 'c');
const changes: DeepChange[] = [];
arr.onDeepChange((c) => changes.push(...c));
arr.delete(1, 1);
expect(changes).toHaveLength(1);
expect(changes[0]).toEqual({
path: [],
delta: [{ retain: 1 }, { delete: 1 }],
});
});
it('should emit multiple deltas in a transaction', () => {
const changes: DeepChange[] = [];
arr.onDeepChange((c) => changes.push(...c));
doc.transact(() => {
arr.push('a', 'b', 'c');
});
// Should be batched into single change
expect(changes).toHaveLength(1);
expect(changes[0]).toEqual({
path: [],
delta: [{ insert: ['a', 'b', 'c'] }],
});
});
it('should stop emitting events after unsubscribe', () => {
const changes: DeepChange[] = [];
const unsubscribe = arr.onDeepChange((c) => changes.push(...c));
arr.push('a');
expect(changes).toHaveLength(1);
unsubscribe();
arr.push('b');
expect(changes).toHaveLength(1); // No new changes
});
});
});
+468
View File
@@ -0,0 +1,468 @@
import * as Y from 'yjs';
import { YjsAwareness } from '../awareness/yjs-awareness';
import type {
ArrayChangeEvent,
AwarenessState,
CRDTArray,
CRDTAwareness,
CRDTDoc,
CRDTMap,
CRDTProvider,
CRDTUndoManager,
DeepChange,
DeepChangeEvent,
TransactionBatch,
UndoManagerOptions,
Unsubscribe,
} from '../types';
import { ChangeAction, ChangeOrigin, CRDTEngine } from '../types';
import { YjsRemoteOrigin, YjsUndoManager, YjsUndoManagerOrigin } from '../undo/yjs-undo-manager';
/**
* Determine the ChangeOrigin from a Yjs transaction.
* - UndoManager transactions (origin is Y.UndoManager instance) → undoRedo
* - Local transactions → local
* - Remote transactions → remote
*/
function getChangeOrigin(transaction: Y.Transaction): ChangeOrigin {
// Undo/redo transactions have the UndoManager instance as origin
if (transaction.origin instanceof Y.UndoManager) {
return ChangeOrigin.undoRedo;
}
return transaction.local ? ChangeOrigin.local : ChangeOrigin.remote;
}
/**
* Convert a value to JSON if it's a Yjs type, otherwise return as-is.
* Used for toJSON() methods to get plain objects.
*/
function toJSONValue(value: unknown): unknown {
if (value instanceof Y.Map || value instanceof Y.Array || value instanceof Y.Text) {
return value.toJSON();
}
return value;
}
/** Symbol to store wrapper reference directly on Y types */
const WRAPPER = Symbol('crdt-wrapper');
/** Type extension for Y types to store wrapper reference */
type YTypeWithWrapper = { [WRAPPER]?: YjsMap | YjsArray };
/**
* Wrap a Yjs type in the appropriate CRDT wrapper, or return primitive as-is.
* Stores wrapper on the Y type itself to return the same instance on every get().
*/
function wrapYjsValue(value: unknown): unknown {
if (value instanceof Y.Map) {
// Store wrapper on the Y.Map itself for identity preservation
const yMap = value as Y.Map<unknown> & YTypeWithWrapper;
yMap[WRAPPER] ??= new YjsMap(value);
return yMap[WRAPPER];
}
if (value instanceof Y.Array) {
// Store wrapper on the Y.Array itself for identity preservation
const yArray = value as Y.Array<unknown> & YTypeWithWrapper;
yArray[WRAPPER] ??= new YjsArray(value);
return yArray[WRAPPER];
}
return value;
}
/**
* Yjs implementation of CRDTArray.
*/
class YjsArray<T = unknown> implements CRDTArray<T> {
constructor(private readonly yArray: Y.Array<unknown>) {}
/** Get the underlying Y.Array (for internal use) */
getYArray(): Y.Array<unknown> {
return this.yArray;
}
get length(): number {
return this.yArray.length;
}
get(index: number): T | undefined {
const value = this.yArray.get(index);
return wrapYjsValue(value) as T | undefined;
}
push(...items: T[]): void {
// Convert wrappers to their underlying Y types
const unwrapped = items.map((item) => {
if (item instanceof YjsMap) return item.getYMap();
if (item instanceof YjsArray) return item.getYArray();
return item;
});
this.yArray.push(unwrapped);
}
insert(index: number, ...items: T[]): void {
// Convert wrappers to their underlying Y types
const unwrapped = items.map((item) => {
if (item instanceof YjsMap) return item.getYMap();
if (item instanceof YjsArray) return item.getYArray();
return item;
});
this.yArray.insert(index, unwrapped);
}
delete(index: number, count = 1): void {
this.yArray.delete(index, count);
}
toArray(): T[] {
return this.yArray.toJSON() as T[];
}
toJSON(): T[] {
return this.toArray();
}
onDeepChange(handler: (changes: DeepChange[], origin: ChangeOrigin) => void): Unsubscribe {
const observer = (events: Array<Y.YEvent<Y.Array<unknown>>>, transaction: Y.Transaction) => {
const changes: DeepChange[] = [];
for (const event of events) {
if (event instanceof Y.YArrayEvent) {
// Pass through Yjs delta format directly
changes.push(arrayEventToChange(event));
} else if (event instanceof Y.YMapEvent) {
changes.push(...mapEventToChanges(event));
}
}
if (changes.length > 0) {
handler(changes, getChangeOrigin(transaction));
}
};
this.yArray.observeDeep(observer);
return () => {
this.yArray.unobserveDeep(observer);
};
}
}
/**
* Convert Yjs array event to ArrayChangeEvent (pass-through delta format).
*/
function arrayEventToChange(event: Y.YArrayEvent<unknown>): ArrayChangeEvent {
return {
path: event.path,
delta: event.delta as ArrayChangeEvent['delta'],
};
}
/**
* Convert Yjs map events to DeepChangeEvents.
*/
function mapEventToChanges(event: Y.YMapEvent<unknown>): DeepChangeEvent[] {
return Array.from(event.changes.keys, ([key, change]) => ({
path: [...event.path, key],
action: change.action,
...(change.action !== ChangeAction.delete && {
value: toJSONValue(event.target.get(key)),
}),
...(change.action !== ChangeAction.add && {
oldValue: toJSONValue(change.oldValue),
}),
}));
}
/**
* Check if a Y.Type is a descendant of a root map (or the root map itself).
* Walks up _item.parent until we find the root or reach the doc.
*/
function isDescendantOf(yType: Y.AbstractType<unknown>, rootMap: Y.Map<unknown>): boolean {
let current: Y.AbstractType<unknown> | null = yType;
while (current !== null) {
if (current === rootMap) return true;
// Access internal _item property to walk up the tree
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
const item = (current as any)._item as Y.Item | null;
if (!item) return false;
const parent = item.parent;
// parent can be AbstractType, ID, or null - we only continue if it's an AbstractType
if (parent === null || typeof parent !== 'object' || !('_item' in parent)) {
return false;
}
current = parent as Y.AbstractType<unknown>;
}
return false;
}
/**
* Yjs implementation of CRDTMap.
*/
class YjsMap<T = unknown> implements CRDTMap<T> {
constructor(private readonly yMap: Y.Map<unknown>) {}
/** Get the underlying Y.Map (for internal use) */
getYMap(): Y.Map<unknown> {
return this.yMap;
}
get(key: string): T | CRDTMap<unknown> | CRDTArray<unknown> | undefined {
const value = this.yMap.get(key);
return wrapYjsValue(value) as T | CRDTMap<unknown> | CRDTArray<unknown> | undefined;
}
set(key: string, value: T | CRDTMap<unknown> | CRDTArray<unknown>): void {
// Convert wrappers to their underlying Y types
if (value instanceof YjsMap) {
this.yMap.set(key, value.getYMap());
} else if (value instanceof YjsArray) {
this.yMap.set(key, value.getYArray());
} else {
this.yMap.set(key, value);
}
}
delete(key: string): void {
this.yMap.delete(key);
}
has(key: string): boolean {
return this.yMap.has(key);
}
keys(): IterableIterator<string> {
return this.yMap.keys();
}
*values(): IterableIterator<T | CRDTMap<unknown> | CRDTArray<unknown>> {
for (const value of this.yMap.values()) {
yield wrapYjsValue(value) as T | CRDTMap<unknown> | CRDTArray<unknown>;
}
}
*entries(): IterableIterator<[string, T | CRDTMap<unknown> | CRDTArray<unknown>]> {
for (const [key, value] of this.yMap.entries()) {
yield [key, wrapYjsValue(value) as T | CRDTMap<unknown> | CRDTArray<unknown>];
}
}
toJSON(): Record<string, T> {
return this.yMap.toJSON() as Record<string, T>;
}
onDeepChange(handler: (changes: DeepChange[], origin: ChangeOrigin) => void): Unsubscribe {
const observer = (events: Array<Y.YEvent<Y.Map<unknown>>>, transaction: Y.Transaction) => {
const changes: DeepChange[] = [];
for (const event of events) {
if (event instanceof Y.YArrayEvent) {
changes.push(arrayEventToChange(event));
} else if (event instanceof Y.YMapEvent) {
changes.push(...mapEventToChanges(event));
}
}
if (changes.length > 0) {
handler(changes, getChangeOrigin(transaction));
}
};
this.yMap.observeDeep(observer);
return () => {
this.yMap.unobserveDeep(observer);
};
}
}
/**
* Yjs implementation of CRDTDoc.
*/
class YjsDoc implements CRDTDoc {
private readonly yDoc: Y.Doc;
private awareness: YjsAwareness | null = null;
private undoManager: YjsUndoManager | null = null;
private _synced = false;
private syncHandlers = new Set<(isSynced: boolean) => void>();
constructor(readonly id: string) {
this.yDoc = new Y.Doc({ guid: id });
}
get synced(): boolean {
return this._synced;
}
setSynced(synced: boolean): void {
if (this._synced === synced) return;
this._synced = synced;
for (const handler of this.syncHandlers) {
handler(synced);
}
}
onSync(handler: (isSynced: boolean) => void): () => void {
this.syncHandlers.add(handler);
return () => {
this.syncHandlers.delete(handler);
};
}
getMap<T = unknown>(name: string): CRDTMap<T> {
return wrapYjsValue(this.yDoc.getMap(name)) as CRDTMap<T>;
}
getArray<T = unknown>(name: string): CRDTArray<T> {
return wrapYjsValue(this.yDoc.getArray(name)) as CRDTArray<T>;
}
createMap<T = unknown>(): CRDTMap<T> {
// Return a wrapped standalone Y.Map - Yjs buffers writes internally until attached
return new YjsMap<T>(new Y.Map<unknown>());
}
createArray<T = unknown>(): CRDTArray<T> {
// Return a wrapped standalone Y.Array - Yjs buffers writes internally until attached
return new YjsArray<T>(new Y.Array<unknown>());
}
transact(fn: () => void): void {
// Use the tracked origin so undo manager captures these changes
this.yDoc.transact(fn, YjsUndoManagerOrigin);
}
encodeState(): Uint8Array {
return Y.encodeStateAsUpdate(this.yDoc);
}
encodeStateVector(): Uint8Array {
return Y.encodeStateVector(this.yDoc);
}
applyUpdate(update: Uint8Array): void {
// Use remote origin so undo manager doesn't track these changes
Y.applyUpdate(this.yDoc, update, YjsRemoteOrigin);
}
onUpdate(handler: (update: Uint8Array, origin: ChangeOrigin) => void): Unsubscribe {
const wrappedHandler = (
update: Uint8Array,
_origin: unknown,
_doc: Y.Doc,
transaction: Y.Transaction,
) => {
handler(update, getChangeOrigin(transaction));
};
this.yDoc.on('update', wrappedHandler);
return () => {
this.yDoc.off('update', wrappedHandler);
};
}
getAwareness<T extends AwarenessState = AwarenessState>(): CRDTAwareness<T> {
this.awareness ??= new YjsAwareness(this.yDoc);
return this.awareness as unknown as CRDTAwareness<T>;
}
createUndoManager(options?: UndoManagerOptions): CRDTUndoManager {
if (this.undoManager) {
throw new Error('Undo manager already exists for this document');
}
this.undoManager = new YjsUndoManager(this.yDoc, options);
return this.undoManager;
}
onTransactionBatch(mapNames: string[], handler: (batch: TransactionBatch) => void): Unsubscribe {
// Build lookup: Y.Map → name
const targetMaps = new Map<Y.Map<unknown>, string>();
for (const name of mapNames) {
targetMaps.set(this.yDoc.getMap(name), name);
}
const afterTransactionHandler = (transaction: Y.Transaction) => {
const batch = new Map<string, DeepChange[]>();
// Process each root map we care about
for (const [rootYMap, mapName] of targetMaps) {
// Collect events that belong under this root map
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const allEvents: Array<Y.YEvent<any>> = [];
for (const [yType, events] of transaction.changedParentTypes) {
// Check if yType is rootYMap OR is a descendant of rootYMap
if (isDescendantOf(yType as Y.AbstractType<unknown>, rootYMap)) {
for (const event of events) {
// Only process direct events (event.target === yType)
if (event.target === yType) {
allEvents.push(event);
}
}
}
}
if (allEvents.length === 0) continue;
const changes: DeepChange[] = [];
for (const event of allEvents) {
// Use Yjs's built-in path calculation:
// 1. Set currentTarget to our root map
// 2. Clear _path to force recalculation
// 3. event.path is now computed relative to rootYMap
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(event as any).currentTarget = rootYMap;
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(event as any)._path = null;
if (event instanceof Y.YMapEvent) {
changes.push(...mapEventToChanges(event));
} else if (event instanceof Y.YArrayEvent) {
changes.push(arrayEventToChange(event));
}
}
if (changes.length > 0) {
batch.set(mapName, changes);
}
}
if (batch.size > 0) {
handler({
changes: batch,
origin: getChangeOrigin(transaction),
});
}
};
this.yDoc.on('afterTransaction', afterTransactionHandler);
return () => {
this.yDoc.off('afterTransaction', afterTransactionHandler);
};
}
destroy(): void {
if (this.undoManager) {
this.undoManager.destroy();
this.undoManager = null;
}
if (this.awareness) {
this.awareness.destroy();
this.awareness = null;
}
this.syncHandlers.clear();
this._synced = false;
this.yDoc.destroy();
}
}
/**
* Yjs implementation of CRDTProvider.
*/
export class YjsProvider implements CRDTProvider {
readonly name = CRDTEngine.yjs;
createDoc(id: string): CRDTDoc {
return new YjsDoc(id);
}
}
+354
View File
@@ -0,0 +1,354 @@
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']);
});
});
});
@@ -0,0 +1,116 @@
import type { SyncTransport } from '../transports';
import type { CRDTDoc, Unsubscribe } from '../types';
import type { SyncProvider } from './types';
type SyncStateHandler = (syncing: boolean) => void;
type ErrorHandler = (error: Error) => void;
/**
* BaseSyncProvider - Engine-agnostic sync provider implementation.
*
* Works with any CRDTDoc since it only uses the standard sync methods:
* - encodeState() for initial sync
* - applyUpdate() for incoming updates
* - onUpdate() for outgoing updates
*/
export class BaseSyncProvider implements SyncProvider {
private _syncing = false;
private stateHandlers = new Set<SyncStateHandler>();
private errorHandlers = new Set<ErrorHandler>();
private unsubscribeDoc: Unsubscribe | null = null;
private unsubscribeTransport: Unsubscribe | null = null;
constructor(
readonly doc: CRDTDoc,
readonly transport: SyncTransport,
) {}
get syncing(): boolean {
return this._syncing;
}
async start(): Promise<void> {
if (this._syncing) return;
// Connect transport
await this.transport.connect();
// Subscribe to incoming updates from transport
this.unsubscribeTransport = this.transport.onReceive((data) => {
try {
this.doc.applyUpdate(data);
} catch (error) {
this.notifyError(error instanceof Error ? error : new Error(String(error)));
}
});
// Subscribe to outgoing updates from doc
this.unsubscribeDoc = this.doc.onUpdate((update) => {
if (this.transport.connected) {
this.transport.send(update);
}
});
// Send initial state to peer
const initialState = this.doc.encodeState();
this.transport.send(initialState);
this._syncing = true;
this.notifyStateChange();
}
stop(): void {
if (!this._syncing) return;
// Unsubscribe from doc updates
if (this.unsubscribeDoc) {
this.unsubscribeDoc();
this.unsubscribeDoc = null;
}
// Unsubscribe from transport
if (this.unsubscribeTransport) {
this.unsubscribeTransport();
this.unsubscribeTransport = null;
}
// Disconnect transport
this.transport.disconnect();
this._syncing = false;
this.notifyStateChange();
}
onSyncStateChange(handler: SyncStateHandler): Unsubscribe {
this.stateHandlers.add(handler);
return () => {
this.stateHandlers.delete(handler);
};
}
onError(handler: ErrorHandler): Unsubscribe {
this.errorHandlers.add(handler);
return () => {
this.errorHandlers.delete(handler);
};
}
private notifyStateChange(): void {
for (const handler of this.stateHandlers) {
handler(this._syncing);
}
}
private notifyError(error: Error): void {
for (const handler of this.errorHandlers) {
handler(error);
}
}
}
/**
* Create a sync provider for any CRDTDoc.
*/
export function createSyncProvider(doc: CRDTDoc, transport: SyncTransport): SyncProvider {
return new BaseSyncProvider(doc, transport);
}
+2
View File
@@ -0,0 +1,2 @@
export type { SyncProvider, CreateSyncProvider } from './types';
export { BaseSyncProvider, createSyncProvider } from './base-sync-provider';
@@ -0,0 +1,755 @@
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}`);
}
});
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { SyncTransport } from '../transports';
import type { CRDTDoc, Unsubscribe } from '../types';
/**
* SyncProvider - Wires a CRDTDoc to a SyncTransport for synchronization.
*
* Responsibilities:
* - On connect: send initial state to peer
* - On local change: send update via transport
* - On remote data: apply update to doc
* - Handle connect/disconnect lifecycle
*/
export interface SyncProvider {
/** The document being synchronized */
readonly doc: CRDTDoc;
/** The transport used for communication */
readonly transport: SyncTransport;
/** Whether sync is currently active */
readonly syncing: boolean;
/** Start synchronization - connects transport and begins sync */
start(): Promise<void>;
/** Stop synchronization - disconnects and cleans up */
stop(): void;
/** Subscribe to sync state changes */
onSyncStateChange(handler: (syncing: boolean) => void): Unsubscribe;
/** Subscribe to sync errors (e.g., malformed updates from peers) */
onError(handler: (error: Error) => void): Unsubscribe;
}
/**
* Factory function type for creating SyncProviders.
*/
export type CreateSyncProvider = (doc: CRDTDoc, transport: SyncTransport) => SyncProvider;
+17
View File
@@ -0,0 +1,17 @@
import 'vitest';
interface ReceiveMessageOptions {
timeout?: number;
}
type DeserializedMessage<TMessage = object> = string | TMessage;
declare module 'vitest' {
interface Assertion<T = unknown> {
toReceiveMessage<TMessage = object>(
message: DeserializedMessage<TMessage>,
options?: ReceiveMessageOptions,
): Promise<T>;
toHaveReceivedMessages<TMessage = object>(messages: Array<DeserializedMessage<TMessage>>): T;
}
}
@@ -0,0 +1,258 @@
import { BroadcastChannelTransport } from './broadcast-channel';
describe('BroadcastChannelTransport', () => {
let transport1: BroadcastChannelTransport;
let transport2: BroadcastChannelTransport;
beforeEach(() => {
transport1 = new BroadcastChannelTransport('test-channel');
transport2 = new BroadcastChannelTransport('test-channel');
});
afterEach(() => {
transport1.disconnect();
transport2.disconnect();
});
describe('connection lifecycle', () => {
it('should start disconnected', () => {
expect(transport1.connected).toBe(false);
expect(transport2.connected).toBe(false);
});
it('should be connected after connect()', async () => {
await transport1.connect();
expect(transport1.connected).toBe(true);
});
it('should be disconnected after disconnect()', async () => {
await transport1.connect();
transport1.disconnect();
expect(transport1.connected).toBe(false);
});
it('should allow reconnection', async () => {
await transport1.connect();
transport1.disconnect();
await transport1.connect();
expect(transport1.connected).toBe(true);
});
it('should be idempotent for multiple connect() calls', async () => {
await transport1.connect();
await transport1.connect();
expect(transport1.connected).toBe(true);
});
it('should be idempotent for multiple disconnect() calls', async () => {
await transport1.connect();
transport1.disconnect();
transport1.disconnect();
expect(transport1.connected).toBe(false);
});
});
describe('send/receive', () => {
beforeEach(async () => {
await transport1.connect();
await transport2.connect();
});
it('should send data from transport1 to transport2', async () => {
const received: Uint8Array[] = [];
transport2.onReceive((data) => received.push(data));
const testData = new Uint8Array([1, 2, 3, 4, 5]);
transport1.send(testData);
// BroadcastChannel is async
await new Promise((resolve) => setTimeout(resolve, 50));
expect(received).toHaveLength(1);
expect(Array.from(received[0])).toEqual([1, 2, 3, 4, 5]);
});
it('should send data from transport2 to transport1', async () => {
const received: Uint8Array[] = [];
transport1.onReceive((data) => received.push(data));
const testData = new Uint8Array([5, 4, 3, 2, 1]);
transport2.send(testData);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(received).toHaveLength(1);
expect(Array.from(received[0])).toEqual([5, 4, 3, 2, 1]);
});
it('should support bidirectional communication', async () => {
const received1: Uint8Array[] = [];
const received2: Uint8Array[] = [];
transport1.onReceive((data) => received1.push(data));
transport2.onReceive((data) => received2.push(data));
transport1.send(new Uint8Array([1, 2, 3]));
transport2.send(new Uint8Array([4, 5, 6]));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(received1).toHaveLength(1);
expect(received2).toHaveLength(1);
expect(Array.from(received1[0])).toEqual([4, 5, 6]);
expect(Array.from(received2[0])).toEqual([1, 2, 3]);
});
it('should not receive own messages', async () => {
const received: Uint8Array[] = [];
transport1.onReceive((data) => received.push(data));
transport1.send(new Uint8Array([1, 2, 3]));
await new Promise((resolve) => setTimeout(resolve, 50));
// Should not receive own message
expect(received).toHaveLength(0);
});
it('should deliver to multiple handlers', async () => {
const received1: Uint8Array[] = [];
const received2: Uint8Array[] = [];
transport2.onReceive((data) => received1.push(data));
transport2.onReceive((data) => received2.push(data));
transport1.send(new Uint8Array([1, 2, 3]));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(received1).toHaveLength(1);
expect(received2).toHaveLength(1);
});
it('should throw when sending on disconnected transport', () => {
transport1.disconnect();
expect(() => {
transport1.send(new Uint8Array([1, 2, 3]));
}).toThrow('Transport not connected');
});
});
describe('unsubscribe', () => {
beforeEach(async () => {
await transport1.connect();
await transport2.connect();
});
it('should stop receiving after unsubscribe', async () => {
const received: Uint8Array[] = [];
const unsubscribe = transport2.onReceive((data) => received.push(data));
transport1.send(new Uint8Array([1, 2, 3]));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(received).toHaveLength(1);
unsubscribe();
transport1.send(new Uint8Array([4, 5, 6]));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(received).toHaveLength(1); // No new data
});
it('should only unsubscribe the specific handler', async () => {
const received1: Uint8Array[] = [];
const received2: Uint8Array[] = [];
const unsubscribe1 = transport2.onReceive((data) => received1.push(data));
transport2.onReceive((data) => received2.push(data));
unsubscribe1();
transport1.send(new Uint8Array([1, 2, 3]));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(received1).toHaveLength(0);
expect(received2).toHaveLength(1);
});
});
describe('channel isolation', () => {
it('should not receive messages from different channels', async () => {
const otherTransport = new BroadcastChannelTransport('other-channel');
await transport1.connect();
await otherTransport.connect();
const received: Uint8Array[] = [];
transport1.onReceive((data) => received.push(data));
otherTransport.send(new Uint8Array([1, 2, 3]));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(received).toHaveLength(0);
otherTransport.disconnect();
});
});
describe('multiple tabs simulation', () => {
it('should broadcast to all connected transports', async () => {
const transport3 = new BroadcastChannelTransport('test-channel');
await transport1.connect();
await transport2.connect();
await transport3.connect();
const received1: Uint8Array[] = [];
const received2: Uint8Array[] = [];
const received3: Uint8Array[] = [];
transport1.onReceive((data) => received1.push(data));
transport2.onReceive((data) => received2.push(data));
transport3.onReceive((data) => received3.push(data));
// Send from transport1
transport1.send(new Uint8Array([1, 2, 3]));
await new Promise((resolve) => setTimeout(resolve, 50));
// transport1 should not receive (self-filter)
expect(received1).toHaveLength(0);
// transport2 and transport3 should receive
expect(received2).toHaveLength(1);
expect(received3).toHaveLength(1);
transport3.disconnect();
});
});
describe('large data transfer', () => {
beforeEach(async () => {
await transport1.connect();
await transport2.connect();
});
it('should handle large payloads', async () => {
const received: Uint8Array[] = [];
transport2.onReceive((data) => received.push(data));
// 100KB payload (smaller than MessagePort test due to serialization overhead)
const largeData = new Uint8Array(100 * 1024);
for (let i = 0; i < largeData.length; i++) {
largeData[i] = i % 256;
}
transport1.send(largeData);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(received).toHaveLength(1);
expect(received[0].length).toBe(100 * 1024);
expect(received[0][0]).toBe(0);
expect(received[0][255]).toBe(255);
expect(received[0][256]).toBe(0);
});
});
});
@@ -0,0 +1,149 @@
import type { Unsubscribe } from '../types';
import type { SyncTransport } from './types';
type ReceiveHandler = (data: Uint8Array) => void;
/**
* Message types for BroadcastChannel communication.
*/
interface SyncMessage {
type: 'sync';
data: number[]; // BroadcastChannel can't transfer Uint8Array directly, so we use number[]
senderId: string;
}
type ChannelMessage = SyncMessage;
/**
* BroadcastChannelTransport - Transport using BroadcastChannel for cross-tab sync.
*
* This transport uses the BroadcastChannel API to synchronize CRDT documents
* across browser tabs. It's useful as a fallback when SharedWorker is not
* available (e.g., Safari).
*
* **Note:** BroadcastChannel broadcasts to ALL tabs listening on the same channel,
* including the sender. This transport filters out self-sent messages using a
* unique sender ID.
*
* **Limitations:**
* - Same-origin only (tabs must be on the same domain)
* - No persistence - if all tabs close, data is lost
* - Less efficient than SharedWorker for many tabs (each tab processes all messages)
*
* Usage:
* ```typescript
* // In each tab
* const transport = new BroadcastChannelTransport('workflow-123');
* const sync = createSyncProvider(doc, transport);
* await sync.start();
* ```
*/
export class BroadcastChannelTransport implements SyncTransport {
private channel: BroadcastChannel | null = null;
private receiveHandlers = new Set<ReceiveHandler>();
private connectionChangeHandlers = new Set<(connected: boolean) => void>();
private errorHandlers = new Set<(error: Error) => void>();
private _connected = false;
private readonly senderId: string;
constructor(private readonly channelName: string) {
// Generate unique ID for this instance to filter self-sent messages.
// crypto.randomUUID() is available in all modern browsers and Node.js 14.17+
this.senderId =
typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
get connected(): boolean {
return this._connected;
}
send(data: Uint8Array): void {
if (!this._connected || !this.channel) {
throw new Error('Transport not connected');
}
const message: SyncMessage = {
type: 'sync',
data: Array.from(data), // Convert Uint8Array to number[] for BroadcastChannel
senderId: this.senderId,
};
this.channel.postMessage(message);
}
onReceive(handler: ReceiveHandler): Unsubscribe {
this.receiveHandlers.add(handler);
return () => {
this.receiveHandlers.delete(handler);
};
}
onConnectionChange(handler: (connected: boolean) => void): Unsubscribe {
this.connectionChangeHandlers.add(handler);
return () => {
this.connectionChangeHandlers.delete(handler);
};
}
onError(handler: (error: Error) => void): Unsubscribe {
this.errorHandlers.add(handler);
return () => {
this.errorHandlers.delete(handler);
};
}
async connect(): Promise<void> {
if (this._connected) {
return await Promise.resolve();
}
this.channel = new BroadcastChannel(this.channelName);
this.channel.onmessage = (event: MessageEvent) => {
const message = event.data as ChannelMessage;
// Ignore messages from self
if (message.senderId === this.senderId) {
return;
}
if (message.type === 'sync') {
const data = new Uint8Array(message.data);
for (const handler of this.receiveHandlers) {
handler(data);
}
}
};
this.channel.onmessageerror = (event: MessageEvent) => {
const error = new Error(`BroadcastChannel message error: ${String(event.data)}`);
for (const handler of this.errorHandlers) {
handler(error);
}
};
this._connected = true;
for (const handler of this.connectionChangeHandlers) {
handler(true);
}
return await Promise.resolve();
}
disconnect(): void {
if (!this._connected) {
return;
}
if (this.channel) {
this.channel.close();
this.channel = null;
}
this._connected = false;
for (const handler of this.connectionChangeHandlers) {
handler(false);
}
}
}
@@ -0,0 +1,6 @@
export type { SyncTransport } from './types';
export { MockTransport } from './mock';
export { MessagePortTransport } from './message-port';
export { WebSocketTransport, type WebSocketTransportConfig } from './websocket';
export { WorkerTransport, type WorkerTransportConfig } from './worker';
export { BroadcastChannelTransport } from './broadcast-channel';
@@ -0,0 +1,439 @@
import WebSocketMock from 'vitest-websocket-mock';
import { CRDTEngine, createCRDTProvider } from '../index';
import { createSyncProvider } from '../sync/base-sync-provider';
import type { CRDTDoc, CRDTMap } from '../types';
import { MessagePortTransport } from './message-port';
import { WebSocketTransport } from './websocket';
/**
* Test: Does onUpdate fire for applied remote updates?
*/
describe('onUpdate behavior verification', () => {
it('should fire onUpdate for local changes', async () => {
const { createCRDTProvider, CRDTEngine } = await import('../index');
const provider = createCRDTProvider({ engine: CRDTEngine.yjs });
const doc = provider.createDoc('doc1');
const updates: Uint8Array[] = [];
doc.onUpdate((update) => {
updates.push(new Uint8Array(update));
});
const map = doc.getMap('data');
map.set('key', 'value');
expect(updates.length).toBeGreaterThan(0);
doc.destroy();
});
it('should fire onUpdate when applyUpdate is called', async () => {
const { createCRDTProvider, CRDTEngine } = await import('../index');
const provider = createCRDTProvider({ engine: CRDTEngine.yjs });
const doc1 = provider.createDoc('doc1');
const doc2 = provider.createDoc('doc2');
const doc2Updates: Uint8Array[] = [];
doc2.onUpdate((update) => {
doc2Updates.push(new Uint8Array(update));
});
// Make change on doc1
const map1 = doc1.getMap('data');
map1.set('key', 'value');
// Encode and apply to doc2
const state = doc1.encodeState();
doc2.applyUpdate(state);
// onUpdate SHOULD fire when applyUpdate is called
expect(doc2Updates.length).toBeGreaterThan(0);
doc1.destroy();
doc2.destroy();
});
it('should forward updates via onUpdate to another transport', async () => {
const { createCRDTProvider, CRDTEngine } = await import('../index');
const { MockTransport } = await import('../transports');
const { createSyncProvider } = await import('../sync/base-sync-provider');
const provider = createCRDTProvider({ engine: CRDTEngine.yjs });
const doc1 = provider.createDoc('doc1');
const doc2 = provider.createDoc('doc2');
// Set up mock transports
const transport1 = new MockTransport();
const transport2 = new MockTransport();
MockTransport.link(transport1, transport2);
// Track updates on doc2
const doc2Updates: Uint8Array[] = [];
doc2.onUpdate((update) => {
doc2Updates.push(new Uint8Array(update));
});
// Set up sync providers
const sync1 = createSyncProvider(doc1, transport1);
const sync2 = createSyncProvider(doc2, transport2);
await sync1.start();
await sync2.start();
// Make change on doc1
const map1 = doc1.getMap('data');
map1.set('key', 'value');
// doc2 should receive the change
const map2 = doc2.getMap('data');
expect(map2.get('key')).toBe('value');
// doc2's onUpdate should have fired
// Note: This may include the initial sync + the change
expect(doc2Updates.length).toBeGreaterThan(0);
sync1.stop();
sync2.stop();
doc1.destroy();
doc2.destroy();
});
});
/**
* Integration test simulating the full n8n sync architecture:
*
* ┌─────────────┐ ┌──────────────────────────────┐ ┌────────────┐
* │ UI │ │ SharedWorker │ │ Server │
* │ (doc1) │◄────►│ (doc2) │◄────►│ (doc3) │
* └─────────────┘ └──────────────────────────────┘ └────────────┘
* MessagePort WebSocket
* Transport Transport
*
* Since SharedWorker is not available in Node.js, we simulate it using
* MessageChannel which provides the same MessagePort API.
*/
describe('Integration: UI ↔ SharedWorker ↔ Server', () => {
describe.each([CRDTEngine.yjs])('Engine: %s', (engine) => {
let server: WebSocketMock;
// UI side (browser tab)
let uiDoc: CRDTDoc;
let uiMap: CRDTMap<unknown>;
let uiTransport: MessagePortTransport;
// SharedWorker side (simulated)
let workerDoc: CRDTDoc;
let workerMap: CRDTMap<unknown>;
let workerToUiTransport: MessagePortTransport;
let workerToServerTransport: WebSocketTransport;
// Server side (simulated via WebSocketMock)
let serverDoc: CRDTDoc;
let serverMap: CRDTMap<unknown>;
beforeEach(() => {
// Set up WebSocket mock server
server = new WebSocketMock('ws://localhost:1234');
const provider = createCRDTProvider({ engine });
// Create docs for each layer
uiDoc = provider.createDoc('ui-doc');
workerDoc = provider.createDoc('worker-doc');
serverDoc = provider.createDoc('server-doc');
uiMap = uiDoc.getMap('workflow');
workerMap = workerDoc.getMap('workflow');
serverMap = serverDoc.getMap('workflow');
// Set up MessageChannel to simulate UI ↔ SharedWorker communication
const channel = new MessageChannel();
uiTransport = new MessagePortTransport(channel.port1);
workerToUiTransport = new MessagePortTransport(channel.port2);
// Set up WebSocket transport for Worker ↔ Server
workerToServerTransport = new WebSocketTransport({
url: 'ws://localhost:1234',
reconnect: false,
});
});
afterEach(() => {
uiTransport.disconnect();
workerToUiTransport.disconnect();
workerToServerTransport.disconnect();
uiDoc.destroy();
workerDoc.destroy();
serverDoc.destroy();
try {
WebSocketMock.clean();
} catch {
// Ignore cleanup errors
}
});
it('should sync data from UI through SharedWorker to Server', async () => {
// Track updates on workerDoc BEFORE anything else
const workerUpdates: Uint8Array[] = [];
const unsubWorkerUpdate = workerDoc.onUpdate((update) => {
workerUpdates.push(new Uint8Array(update));
// Forward to server if connected
if (workerToServerTransport.connected) {
workerToServerTransport.send(new Uint8Array(update));
}
});
// Set up sync providers for UI ↔ Worker
const uiSync = createSyncProvider(uiDoc, uiTransport);
const workerToUiSync = createSyncProvider(workerDoc, workerToUiTransport);
// Start UI ↔ Worker sync
await uiTransport.connect();
await workerToUiTransport.connect();
await uiSync.start();
await workerToUiSync.start();
// Connect worker to server
await workerToServerTransport.connect();
await server.connected;
// Make a change in UI
uiMap.set('name', 'My Workflow');
// Wait for MessagePort propagation (async in Node.js)
await new Promise((resolve) => setTimeout(resolve, 100));
// Verify worker received the update via SyncProvider
expect(workerMap.get('name')).toBe('My Workflow');
// Verify workerDoc.onUpdate fired (includes initial sync + our change)
expect(workerUpdates.length).toBeGreaterThan(0);
// The issue: workerToServerTransport.send() was likely called BEFORE
// the websocket was connected (during initial sync)
// Let's check if we got any updates AFTER server connected
// by looking at the websocket mock
// Use a race with timeout to check for message
const messagePromise = server.nextMessage;
const timeoutPromise = new Promise<'timeout'>((resolve) =>
setTimeout(() => resolve('timeout'), 200),
);
const result = await Promise.race([messagePromise, timeoutPromise]);
if (result === 'timeout') {
throw new Error(`No WebSocket message received. Worker updates: ${workerUpdates.length}`);
}
// The message can be Uint8Array or ArrayBuffer depending on how vitest-websocket-mock handles it
const updateData =
result instanceof Uint8Array ? result : new Uint8Array(result as ArrayBuffer);
// Apply to server doc
serverDoc.applyUpdate(updateData);
// Verify server has the data
expect(serverMap.get('name')).toBe('My Workflow');
// Cleanup
unsubWorkerUpdate();
uiSync.stop();
workerToUiSync.stop();
});
it('should sync data from Server through SharedWorker to UI', async () => {
// Set up sync providers
const uiSync = createSyncProvider(uiDoc, uiTransport);
const workerToUiSync = createSyncProvider(workerDoc, workerToUiTransport);
// Start UI ↔ Worker sync
await uiTransport.connect();
await workerToUiTransport.connect();
await uiSync.start();
await workerToUiSync.start();
// Connect worker to server
await workerToServerTransport.connect();
await server.connected;
// Worker receives from server and applies to workerDoc
workerToServerTransport.onReceive((data) => {
workerDoc.applyUpdate(data);
});
// Server makes a change and sends to worker
serverMap.set('serverValue', 'from-server');
const serverUpdate = serverDoc.encodeState();
server.send(serverUpdate.buffer);
// Wait for propagation
await new Promise((resolve) => setTimeout(resolve, 100));
// Verify worker received the update
expect(workerMap.get('serverValue')).toBe('from-server');
// Verify UI received the update (through worker)
expect(uiMap.get('serverValue')).toBe('from-server');
// Cleanup
uiSync.stop();
workerToUiSync.stop();
});
it('should handle bidirectional sync across all layers', async () => {
// Track worker updates BEFORE starting sync
const unsubWorkerUpdate = workerDoc.onUpdate((update) => {
if (workerToServerTransport.connected) {
workerToServerTransport.send(new Uint8Array(update));
}
});
// Set up sync providers for UI ↔ Worker
const uiSync = createSyncProvider(uiDoc, uiTransport);
const workerToUiSync = createSyncProvider(workerDoc, workerToUiTransport);
// Start UI ↔ Worker sync
await uiTransport.connect();
await workerToUiTransport.connect();
await uiSync.start();
await workerToUiSync.start();
// Connect worker to server
await workerToServerTransport.connect();
await server.connected;
// Server → Worker: receive from server and apply to workerDoc
const unsubServerReceive = workerToServerTransport.onReceive((data) => {
workerDoc.applyUpdate(data);
});
// UI makes a change
uiMap.set('uiChange', 'from-ui');
// Wait for UI → Worker propagation
await new Promise((resolve) => setTimeout(resolve, 100));
expect(workerMap.get('uiChange')).toBe('from-ui');
// Wait for Worker → Server via nextMessage
const message = await server.nextMessage;
const updateData =
message instanceof Uint8Array ? message : new Uint8Array(message as ArrayBuffer);
serverDoc.applyUpdate(updateData);
expect(serverMap.get('uiChange')).toBe('from-ui');
// Server makes a change
serverMap.set('serverChange', 'from-server');
const serverUpdate = serverDoc.encodeState();
server.send(serverUpdate.buffer);
// Wait for Server → Worker → UI propagation
await new Promise((resolve) => setTimeout(resolve, 100));
expect(workerMap.get('serverChange')).toBe('from-server');
expect(uiMap.get('serverChange')).toBe('from-server');
// Cleanup
unsubWorkerUpdate();
unsubServerReceive();
uiSync.stop();
workerToUiSync.stop();
});
it('should sync initial state to new UI tab via SharedWorker', async () => {
// This test verifies that when a second UI tab connects to a SharedWorker,
// it receives the current state from the worker via initial sync.
// Note: Real-time relay of updates between tabs requires additional
// infrastructure beyond basic SyncProvider.
const provider = createCRDTProvider({ engine });
// First, set up UI1 ↔ Worker and make a change
const ui1Sync = createSyncProvider(uiDoc, uiTransport);
const workerToUi1Sync = createSyncProvider(workerDoc, workerToUiTransport);
await uiTransport.connect();
await workerToUiTransport.connect();
await ui1Sync.start();
await workerToUi1Sync.start();
// UI1 makes a change
uiMap.set('existingData', 'from-tab1');
// Wait for sync to worker
await new Promise((resolve) => setTimeout(resolve, 50));
expect(workerMap.get('existingData')).toBe('from-tab1');
// Now UI2 connects - it should receive existing state via initial sync
const ui2Doc = provider.createDoc('ui2-doc');
const ui2Map = ui2Doc.getMap('workflow');
const channel2 = new MessageChannel();
const ui2Transport = new MessagePortTransport(channel2.port1);
const workerToUi2Transport = new MessagePortTransport(channel2.port2);
await ui2Transport.connect();
await workerToUi2Transport.connect();
const ui2Sync = createSyncProvider(ui2Doc, ui2Transport);
const workerToUi2Sync = createSyncProvider(workerDoc, workerToUi2Transport);
await ui2Sync.start();
await workerToUi2Sync.start();
// Wait for initial sync
await new Promise((resolve) => setTimeout(resolve, 100));
// UI2 should have received the existing data via initial state sync
expect(ui2Map.get('existingData')).toBe('from-tab1');
// Cleanup
ui1Sync.stop();
ui2Sync.stop();
workerToUi1Sync.stop();
workerToUi2Sync.stop();
ui2Transport.disconnect();
workerToUi2Transport.disconnect();
ui2Doc.destroy();
});
it('should continue local sync when server disconnects', async () => {
// Set up sync providers
const uiSync = createSyncProvider(uiDoc, uiTransport);
const workerToUiSync = createSyncProvider(workerDoc, workerToUiTransport);
const workerToServerSync = createSyncProvider(workerDoc, workerToServerTransport);
// Start all connections
await uiTransport.connect();
await workerToUiTransport.connect();
await uiSync.start();
await workerToUiSync.start();
await workerToServerTransport.connect();
await server.connected;
await workerToServerSync.start();
// Verify initial sync works
uiMap.set('beforeDisconnect', 'value1');
await new Promise((resolve) => setTimeout(resolve, 50));
expect(workerMap.get('beforeDisconnect')).toBe('value1');
// Disconnect server
WebSocketMock.clean();
// UI ↔ Worker sync should still work
uiMap.set('afterDisconnect', 'value2');
await new Promise((resolve) => setTimeout(resolve, 50));
expect(workerMap.get('afterDisconnect')).toBe('value2');
// Cleanup
uiSync.stop();
workerToUiSync.stop();
workerToServerSync.stop();
});
});
});
@@ -0,0 +1,200 @@
import { MessagePortTransport } from './message-port';
describe('MessagePortTransport', () => {
let channel: MessageChannel;
let transport1: MessagePortTransport;
let transport2: MessagePortTransport;
beforeEach(() => {
channel = new MessageChannel();
transport1 = new MessagePortTransport(channel.port1);
transport2 = new MessagePortTransport(channel.port2);
});
afterEach(() => {
transport1.disconnect();
transport2.disconnect();
channel.port1.close();
channel.port2.close();
});
describe('connection lifecycle', () => {
it('should start disconnected', () => {
expect(transport1.connected).toBe(false);
expect(transport2.connected).toBe(false);
});
it('should be connected after connect()', async () => {
await transport1.connect();
expect(transport1.connected).toBe(true);
});
it('should be disconnected after disconnect()', async () => {
await transport1.connect();
transport1.disconnect();
expect(transport1.connected).toBe(false);
});
it('should allow reconnection', async () => {
await transport1.connect();
transport1.disconnect();
await transport1.connect();
expect(transport1.connected).toBe(true);
});
it('should be idempotent for multiple connect() calls', async () => {
await transport1.connect();
await transport1.connect();
expect(transport1.connected).toBe(true);
});
it('should be idempotent for multiple disconnect() calls', async () => {
await transport1.connect();
transport1.disconnect();
transport1.disconnect();
expect(transport1.connected).toBe(false);
});
});
describe('send/receive', () => {
beforeEach(async () => {
await transport1.connect();
await transport2.connect();
});
it('should send data from port1 to port2', async () => {
const received: Uint8Array[] = [];
transport2.onReceive((data) => received.push(data));
const testData = new Uint8Array([1, 2, 3, 4, 5]);
transport1.send(testData);
// MessagePort is async, need to wait
await new Promise((resolve) => setTimeout(resolve, 10));
expect(received).toHaveLength(1);
expect(Array.from(received[0])).toEqual([1, 2, 3, 4, 5]);
});
it('should send data from port2 to port1', async () => {
const received: Uint8Array[] = [];
transport1.onReceive((data) => received.push(data));
const testData = new Uint8Array([5, 4, 3, 2, 1]);
transport2.send(testData);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(received).toHaveLength(1);
expect(Array.from(received[0])).toEqual([5, 4, 3, 2, 1]);
});
it('should support bidirectional communication', async () => {
const received1: Uint8Array[] = [];
const received2: Uint8Array[] = [];
transport1.onReceive((data) => received1.push(data));
transport2.onReceive((data) => received2.push(data));
transport1.send(new Uint8Array([1, 2, 3]));
transport2.send(new Uint8Array([4, 5, 6]));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(received1).toHaveLength(1);
expect(received2).toHaveLength(1);
expect(Array.from(received1[0])).toEqual([4, 5, 6]);
expect(Array.from(received2[0])).toEqual([1, 2, 3]);
});
it('should deliver to multiple handlers', async () => {
const received1: Uint8Array[] = [];
const received2: Uint8Array[] = [];
transport2.onReceive((data) => received1.push(data));
transport2.onReceive((data) => received2.push(data));
transport1.send(new Uint8Array([1, 2, 3]));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(received1).toHaveLength(1);
expect(received2).toHaveLength(1);
});
it('should throw when sending on disconnected transport', () => {
transport1.disconnect();
expect(() => {
transport1.send(new Uint8Array([1, 2, 3]));
}).toThrow('Transport not connected');
});
});
describe('unsubscribe', () => {
beforeEach(async () => {
await transport1.connect();
await transport2.connect();
});
it('should stop receiving after unsubscribe', async () => {
const received: Uint8Array[] = [];
const unsubscribe = transport2.onReceive((data) => received.push(data));
transport1.send(new Uint8Array([1, 2, 3]));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(received).toHaveLength(1);
unsubscribe();
transport1.send(new Uint8Array([4, 5, 6]));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(received).toHaveLength(1); // No new data
});
it('should only unsubscribe the specific handler', async () => {
const received1: Uint8Array[] = [];
const received2: Uint8Array[] = [];
const unsubscribe1 = transport2.onReceive((data) => received1.push(data));
transport2.onReceive((data) => received2.push(data));
unsubscribe1();
transport1.send(new Uint8Array([1, 2, 3]));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(received1).toHaveLength(0);
expect(received2).toHaveLength(1);
});
});
describe('large data transfer', () => {
beforeEach(async () => {
await transport1.connect();
await transport2.connect();
});
it('should handle large payloads', async () => {
const received: Uint8Array[] = [];
transport2.onReceive((data) => received.push(data));
// 1MB payload
const largeData = new Uint8Array(1024 * 1024);
for (let i = 0; i < largeData.length; i++) {
largeData[i] = i % 256;
}
transport1.send(largeData);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(received).toHaveLength(1);
expect(received[0].length).toBe(1024 * 1024);
// Verify data integrity
expect(received[0][0]).toBe(0);
expect(received[0][255]).toBe(255);
expect(received[0][256]).toBe(0);
});
});
});
@@ -0,0 +1,138 @@
import type { Unsubscribe } from '../types';
import type { SyncTransport } from './types';
type ReceiveHandler = (data: Uint8Array) => void;
/**
* Message types for MessagePort communication.
* Using a discriminated union to allow future message types.
*/
interface SyncMessage {
type: 'sync';
data: Uint8Array;
}
type PortMessage = SyncMessage;
/**
* Type guard to validate incoming MessagePort messages
*/
function isPortMessage(data: unknown): data is PortMessage {
return (
typeof data === 'object' &&
data !== null &&
'type' in data &&
(data as { type: unknown }).type === 'sync'
);
}
/**
* MessagePortTransport - Transport using MessagePort for SharedWorker/Worker communication.
*
* This transport wraps a MessagePort (from SharedWorker, Worker, or MessageChannel)
* to implement the SyncTransport interface. It's engine-agnostic - the sync protocol
* is handled by SyncProvider.
*
* Usage with SharedWorker:
* ```typescript
* // In main thread
* const worker = new SharedWorker('worker.js');
* const transport = new MessagePortTransport(worker.port);
*
* // In SharedWorker
* self.onconnect = (e) => {
* const port = e.ports[0];
* const transport = new MessagePortTransport(port);
* };
* ```
*
* Usage with MessageChannel (for testing or iframe communication):
* ```typescript
* const channel = new MessageChannel();
* const transport1 = new MessagePortTransport(channel.port1);
* const transport2 = new MessagePortTransport(channel.port2);
* ```
*/
export class MessagePortTransport implements SyncTransport {
private receiveHandlers = new Set<ReceiveHandler>();
private _connected = false;
private messageHandler: ((event: MessageEvent) => void) | null = null;
constructor(private readonly port: MessagePort) {}
get connected(): boolean {
return this._connected;
}
send(data: Uint8Array): void {
if (!this._connected) {
throw new Error('Transport not connected');
}
// Copy the data to avoid transferring ownership of the original buffer.
// This is necessary because the caller may send the same data to multiple
// transports (e.g., hub-and-spoke topology in SharedWorker).
const copy = new Uint8Array(data);
const message: SyncMessage = { type: 'sync', data: copy };
// Transfer the copy's ArrayBuffer for zero-copy delivery to the receiver
this.port.postMessage(message, [copy.buffer]);
}
onReceive(handler: ReceiveHandler): Unsubscribe {
this.receiveHandlers.add(handler);
return () => {
this.receiveHandlers.delete(handler);
};
}
async connect(): Promise<void> {
if (this._connected) {
return await Promise.resolve();
}
this.messageHandler = (event: MessageEvent) => {
if (!isPortMessage(event.data)) return;
const message = event.data;
if (message.type === 'sync') {
// Ensure we have a Uint8Array (may be transferred as ArrayBuffer)
const data =
message.data instanceof Uint8Array ? message.data : new Uint8Array(message.data);
for (const handler of this.receiveHandlers) {
handler(data);
}
}
};
this.port.addEventListener('message', this.messageHandler);
this.port.start(); // Required for MessagePort to begin receiving messages
this._connected = true;
return await Promise.resolve();
}
disconnect(): void {
if (!this._connected) {
return;
}
if (this.messageHandler) {
this.port.removeEventListener('message', this.messageHandler);
this.messageHandler = null;
}
this._connected = false;
// Note: We don't close the port here as it may be reused
}
/** No-op for MessagePortTransport - connection state doesn't change unexpectedly */
onConnectionChange(_handler: (connected: boolean) => void): Unsubscribe {
return () => {};
}
/** No-op for MessagePortTransport - no transport-level errors occur */
onError(_handler: (error: Error) => void): Unsubscribe {
return () => {};
}
}
@@ -0,0 +1,82 @@
import { MockTransport } from './mock';
describe('MockTransport', () => {
let transportA: MockTransport;
let transportB: MockTransport;
beforeEach(() => {
transportA = new MockTransport();
transportB = new MockTransport();
MockTransport.link(transportA, transportB);
});
it('should start disconnected', () => {
expect(transportA.connected).toBe(false);
expect(transportB.connected).toBe(false);
});
it('should connect', async () => {
await transportA.connect();
expect(transportA.connected).toBe(true);
});
it('should disconnect', async () => {
await transportA.connect();
transportA.disconnect();
expect(transportA.connected).toBe(false);
});
it('should throw when sending while disconnected', () => {
const data = new Uint8Array([1, 2, 3]);
expect(() => transportA.send(data)).toThrow('Transport not connected');
});
it('should deliver data to peer', async () => {
await transportA.connect();
await transportB.connect();
const received: Uint8Array[] = [];
transportB.onReceive((data) => received.push(data));
const data = new Uint8Array([1, 2, 3]);
transportA.send(data);
expect(received).toHaveLength(1);
expect(received[0]).toEqual(data);
});
it('should support bidirectional communication', async () => {
await transportA.connect();
await transportB.connect();
const receivedA: Uint8Array[] = [];
const receivedB: Uint8Array[] = [];
transportA.onReceive((data) => receivedA.push(data));
transportB.onReceive((data) => receivedB.push(data));
transportA.send(new Uint8Array([1]));
transportB.send(new Uint8Array([2]));
expect(receivedA).toHaveLength(1);
expect(receivedB).toHaveLength(1);
expect(receivedA[0]).toEqual(new Uint8Array([2]));
expect(receivedB[0]).toEqual(new Uint8Array([1]));
});
it('should stop receiving after unsubscribe', async () => {
await transportA.connect();
await transportB.connect();
const received: Uint8Array[] = [];
const unsubscribe = transportB.onReceive((data) => received.push(data));
transportA.send(new Uint8Array([1]));
expect(received).toHaveLength(1);
unsubscribe();
transportA.send(new Uint8Array([2]));
expect(received).toHaveLength(1); // No new data
});
});
+78
View File
@@ -0,0 +1,78 @@
import type { Unsubscribe } from '../types';
import type { SyncTransport } from './types';
type ReceiveHandler = (data: Uint8Array) => void;
/**
* MockTransport - In-memory transport for testing sync flows.
*
* Two MockTransports can be linked together to simulate a bidirectional
* connection. Data sent on one transport is received by the other.
*
* **TESTING ONLY:** Data delivery is synchronous for deterministic tests.
* Real transports (WebSocket, SharedWorker) will deliver asynchronously.
* Code that relies on synchronous delivery may have race conditions in production.
*/
export class MockTransport implements SyncTransport {
private peer: MockTransport | null = null;
private receiveHandlers = new Set<ReceiveHandler>();
private _connected = false;
get connected(): boolean {
return this._connected;
}
/**
* Link two transports together for bidirectional communication.
*/
static link(a: MockTransport, b: MockTransport): void {
a.peer = b;
b.peer = a;
}
send(data: Uint8Array): void {
if (!this._connected) {
throw new Error('Transport not connected');
}
if (!this.peer) {
throw new Error('Transport has no peer');
}
// Simulate async delivery (but synchronous for deterministic tests)
this.peer.deliver(data);
}
onReceive(handler: ReceiveHandler): Unsubscribe {
this.receiveHandlers.add(handler);
return () => {
this.receiveHandlers.delete(handler);
};
}
async connect(): Promise<void> {
this._connected = true;
return await Promise.resolve();
}
disconnect(): void {
this._connected = false;
}
/** No-op for MockTransport - connection state doesn't change unexpectedly */
onConnectionChange(_handler: (connected: boolean) => void): Unsubscribe {
return () => {};
}
/** No-op for MockTransport - no transport-level errors occur */
onError(_handler: (error: Error) => void): Unsubscribe {
return () => {};
}
/**
* Deliver data to all receive handlers (called by peer).
*/
private deliver(data: Uint8Array): void {
for (const handler of this.receiveHandlers) {
handler(data);
}
}
}
@@ -0,0 +1,29 @@
import type { Unsubscribe } from '../types';
/**
* Transport interface for moving binary data between CRDT documents.
* Transports are "dumb pipes" - they just move Uint8Array bytes without
* understanding the content. The sync protocol logic lives in SyncProvider.
*/
export interface SyncTransport {
/** Send binary data to the peer */
send(data: Uint8Array): void;
/** Subscribe to incoming data from the peer */
onReceive(handler: (data: Uint8Array) => void): Unsubscribe;
/** Establish connection to the peer */
connect(): Promise<void>;
/** Close the connection */
disconnect(): void;
/** Whether currently connected */
readonly connected: boolean;
/** Subscribe to connection state changes */
onConnectionChange(handler: (connected: boolean) => void): Unsubscribe;
/** Subscribe to transport-level errors (e.g., connection failures) */
onError(handler: (error: Error) => void): Unsubscribe;
}
@@ -0,0 +1,362 @@
import WebSocketMock from 'vitest-websocket-mock';
import { WebSocketTransport } from './websocket';
describe('WebSocketTransport', () => {
let server: WebSocketMock;
beforeEach(() => {
server = new WebSocketMock('ws://localhost:1234');
});
afterEach(() => {
try {
WebSocketMock.clean();
} catch {
// Ignore cleanup errors
}
});
describe('connection lifecycle', () => {
it('should start disconnected', () => {
const transport = new WebSocketTransport({ url: 'ws://localhost:1234' });
expect(transport.connected).toBe(false);
});
it('should connect successfully', async () => {
const transport = new WebSocketTransport({ url: 'ws://localhost:1234' });
await transport.connect();
await server.connected;
expect(transport.connected).toBe(true);
transport.disconnect();
});
it('should disconnect', async () => {
const transport = new WebSocketTransport({ url: 'ws://localhost:1234' });
await transport.connect();
await server.connected;
transport.disconnect();
expect(transport.connected).toBe(false);
});
it('should be idempotent for multiple connect() calls', async () => {
const transport = new WebSocketTransport({ url: 'ws://localhost:1234' });
await transport.connect();
await server.connected;
await transport.connect();
expect(transport.connected).toBe(true);
transport.disconnect();
});
it('should allow reconnection after disconnect', async () => {
const transport = new WebSocketTransport({
url: 'ws://localhost:1234',
reconnect: false,
});
await transport.connect();
await server.connected;
transport.disconnect();
WebSocketMock.clean();
server = new WebSocketMock('ws://localhost:1234');
await transport.connect();
await server.connected;
expect(transport.connected).toBe(true);
transport.disconnect();
});
it('should notify connection state changes', async () => {
const transport = new WebSocketTransport({
url: 'ws://localhost:1234',
reconnect: false,
});
const states: boolean[] = [];
transport.onConnectionChange((connected) => states.push(connected));
await transport.connect();
await server.connected;
transport.disconnect();
expect(states).toEqual([true, false]);
});
it('should mark as disconnected when server closes connection', async () => {
WebSocketMock.clean();
server = new WebSocketMock('ws://localhost:1234');
server.on('connection', (socket) => {
socket.close({ code: 1011, reason: 'Server error', wasClean: false });
});
const transport = new WebSocketTransport({
url: 'ws://localhost:1234',
reconnect: false,
});
await transport.connect();
await server.connected;
await new Promise((resolve) => setTimeout(resolve, 50));
expect(transport.connected).toBe(false);
});
it('should timeout on slow connection', async () => {
WebSocketMock.clean();
const transport = new WebSocketTransport({
url: 'ws://localhost:9999',
reconnect: false,
connectionTimeout: 100,
});
await expect(transport.connect()).rejects.toThrow();
transport.disconnect();
}, 5000);
});
describe('send/receive', () => {
let transport: WebSocketTransport;
beforeEach(async () => {
transport = new WebSocketTransport({
url: 'ws://localhost:1234',
reconnect: false,
});
await transport.connect();
await server.connected;
});
afterEach(() => {
transport.disconnect();
});
it('should send data', async () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
transport.send(data);
await expect(server).toReceiveMessage(data);
});
it('should receive data', async () => {
const received: Uint8Array[] = [];
transport.onReceive((data) => received.push(data));
const testData = new Uint8Array([1, 2, 3]);
server.send(testData.buffer);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(received).toHaveLength(1);
expect(Array.from(received[0])).toEqual([1, 2, 3]);
});
it('should deliver to multiple handlers', async () => {
const received1: Uint8Array[] = [];
const received2: Uint8Array[] = [];
transport.onReceive((data) => received1.push(data));
transport.onReceive((data) => received2.push(data));
const testData = new Uint8Array([1, 2, 3]);
server.send(testData.buffer);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(received1).toHaveLength(1);
expect(received2).toHaveLength(1);
});
it('should throw when sending on disconnected transport', () => {
transport.disconnect();
expect(() => {
transport.send(new Uint8Array([1, 2, 3]));
}).toThrow('Transport not connected');
});
});
describe('unsubscribe', () => {
let transport: WebSocketTransport;
beforeEach(async () => {
transport = new WebSocketTransport({
url: 'ws://localhost:1234',
reconnect: false,
});
await transport.connect();
await server.connected;
});
afterEach(() => {
transport.disconnect();
});
it('should stop receiving after unsubscribe', async () => {
const received: Uint8Array[] = [];
const unsubscribe = transport.onReceive((data) => received.push(data));
server.send(new Uint8Array([1, 2, 3]).buffer);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(received).toHaveLength(1);
unsubscribe();
server.send(new Uint8Array([4, 5, 6]).buffer);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(received).toHaveLength(1);
});
it('should stop connection notifications after unsubscribe', () => {
const states: boolean[] = [];
const unsubscribe = transport.onConnectionChange((connected) => states.push(connected));
unsubscribe();
transport.disconnect();
expect(states).toEqual([]);
});
});
describe('reconnection', () => {
it('should reconnect after connection loss', async () => {
const transport = new WebSocketTransport({
url: 'ws://localhost:1234',
reconnect: true,
reconnectDelay: 10,
});
await transport.connect();
await server.connected;
WebSocketMock.clean();
server = new WebSocketMock('ws://localhost:1234');
await server.connected;
expect(transport.connected).toBe(true);
transport.disconnect();
});
it('should not reconnect when disabled', async () => {
const transport = new WebSocketTransport({
url: 'ws://localhost:1234',
reconnect: false,
});
await transport.connect();
await server.connected;
const connectionChanges: boolean[] = [];
transport.onConnectionChange((c) => connectionChanges.push(c));
WebSocketMock.clean();
await new Promise((resolve) => setTimeout(resolve, 100));
expect(transport.connected).toBe(false);
expect(connectionChanges).toContain(false);
});
it('should stop reconnecting after disconnect()', async () => {
const transport = new WebSocketTransport({
url: 'ws://localhost:1234',
reconnect: true,
reconnectDelay: 50,
});
await transport.connect();
await server.connected;
WebSocketMock.clean();
transport.disconnect();
server = new WebSocketMock('ws://localhost:1234');
await new Promise((resolve) => setTimeout(resolve, 100));
expect(transport.connected).toBe(false);
});
it('should use exponential backoff', async () => {
const transport = new WebSocketTransport({
url: 'ws://localhost:1234',
reconnect: true,
reconnectDelay: 10,
reconnectBackoff: 2,
maxReconnectDelay: 1000,
});
await transport.connect();
await server.connected;
WebSocketMock.clean();
await new Promise((resolve) => setTimeout(resolve, 50));
transport.disconnect();
});
it('should respect maxReconnectAttempts', async () => {
const errors: Error[] = [];
const transport = new WebSocketTransport({
url: 'ws://localhost:1234',
reconnect: true,
reconnectDelay: 10,
maxReconnectAttempts: 2,
});
transport.onError((error) => errors.push(error));
await transport.connect();
await server.connected;
WebSocketMock.clean();
await new Promise((resolve) => setTimeout(resolve, 200));
const maxAttemptsError = errors.find((e) => e.message.includes('Max reconnection attempts'));
expect(maxAttemptsError).toBeDefined();
transport.disconnect();
});
});
describe('error handling', () => {
it('should notify error handlers on connection failure', async () => {
WebSocketMock.clean();
const errors: Error[] = [];
const transport = new WebSocketTransport({
url: 'ws://localhost:9999',
reconnect: false,
connectionTimeout: 100,
});
transport.onError((error) => errors.push(error));
try {
await transport.connect();
} catch {
// Expected
}
expect(errors.length).toBeGreaterThan(0);
});
});
});
@@ -0,0 +1,266 @@
import type { Unsubscribe } from '../types';
import type { SyncTransport } from './types';
type ReceiveHandler = (data: Uint8Array) => void;
type ConnectionHandler = (connected: boolean) => void;
type ErrorHandler = (error: Error) => void;
export interface WebSocketTransportConfig {
/** WebSocket URL (ws:// or wss://) */
url: string;
/** Enable automatic reconnection (default: true) */
reconnect?: boolean;
/** Maximum reconnection attempts (default: Infinity) */
maxReconnectAttempts?: number;
/** Initial reconnection delay in ms (default: 1000) */
reconnectDelay?: number;
/** Maximum reconnection delay in ms (default: 30000) */
maxReconnectDelay?: number;
/** Reconnection backoff multiplier (default: 2) */
reconnectBackoff?: number;
/** Connection timeout in ms (default: 10000) */
connectionTimeout?: number;
}
/**
* WebSocketTransport - Transport using WebSocket for server communication.
*
* Features:
* - Automatic reconnection with exponential backoff
* - Connection timeout handling
* - Binary message support (Uint8Array)
*
* Usage:
* ```typescript
* const transport = new WebSocketTransport({
* url: 'wss://server/sync',
* reconnect: true,
* });
*
* transport.onConnectionChange((connected) => {
* console.log('Connection state:', connected);
* });
*
* await transport.connect();
* ```
*/
export class WebSocketTransport implements SyncTransport {
private ws: WebSocket | null = null;
private receiveHandlers = new Set<ReceiveHandler>();
private connectionHandlers = new Set<ConnectionHandler>();
private errorHandlers = new Set<ErrorHandler>();
private _connected = false;
private reconnectAttempts = 0;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private shouldReconnect = false;
private isConnecting = false;
private connectionPromise: Promise<void> | null = null;
private readonly config: Required<WebSocketTransportConfig>;
constructor(config: WebSocketTransportConfig) {
this.config = {
url: config.url,
reconnect: config.reconnect ?? true,
maxReconnectAttempts: config.maxReconnectAttempts ?? Infinity,
reconnectDelay: config.reconnectDelay ?? 1000,
maxReconnectDelay: config.maxReconnectDelay ?? 30000,
reconnectBackoff: config.reconnectBackoff ?? 2,
connectionTimeout: config.connectionTimeout ?? 10000,
};
}
get connected(): boolean {
return this._connected;
}
send(data: Uint8Array): void {
if (!this._connected || !this.ws) {
throw new Error('Transport not connected');
}
this.ws.send(data);
}
onReceive(handler: ReceiveHandler): Unsubscribe {
this.receiveHandlers.add(handler);
return () => {
this.receiveHandlers.delete(handler);
};
}
/**
* Subscribe to connection state changes.
*/
onConnectionChange(handler: ConnectionHandler): Unsubscribe {
this.connectionHandlers.add(handler);
return () => {
this.connectionHandlers.delete(handler);
};
}
/**
* Subscribe to connection errors.
*/
onError(handler: ErrorHandler): Unsubscribe {
this.errorHandlers.add(handler);
return () => {
this.errorHandlers.delete(handler);
};
}
async connect(): Promise<void> {
// Clear any pending reconnect to avoid parallel WebSocket connections
this.clearReconnectTimeout();
if (this._connected) {
return await Promise.resolve();
}
if (this.isConnecting && this.connectionPromise) {
return await this.connectionPromise;
}
this.shouldReconnect = this.config.reconnect;
this.connectionPromise = this.doConnect();
return await this.connectionPromise;
}
disconnect(): void {
this.shouldReconnect = false;
this.clearReconnectTimeout();
if (this.ws) {
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.onmessage = null;
this.ws.onopen = null;
this.ws.close();
this.ws = null;
}
if (this._connected) {
this._connected = false;
this.notifyConnectionChange(false);
}
this.isConnecting = false;
this.connectionPromise = null;
}
private async doConnect(): Promise<void> {
this.isConnecting = true;
return await new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
if (this.ws) {
this.ws.close();
}
reject(new Error('Connection timeout'));
}, this.config.connectionTimeout);
try {
this.ws = new WebSocket(this.config.url);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
clearTimeout(timeoutId);
this._connected = true;
this.isConnecting = false;
this.reconnectAttempts = 0;
this.notifyConnectionChange(true);
resolve();
};
this.ws.onclose = () => {
clearTimeout(timeoutId);
const wasConnected = this._connected;
this._connected = false;
this.isConnecting = false;
if (wasConnected) {
this.notifyConnectionChange(false);
}
if (this.shouldReconnect) {
this.scheduleReconnect();
}
};
this.ws.onerror = () => {
clearTimeout(timeoutId);
const error = new Error('WebSocket error');
this.notifyError(error);
if (this.isConnecting) {
this.isConnecting = false;
reject(error);
}
};
this.ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
const data = new Uint8Array(event.data);
for (const handler of this.receiveHandlers) {
handler(data);
}
}
};
} catch (error) {
clearTimeout(timeoutId);
this.isConnecting = false;
reject(error instanceof Error ? error : new Error(String(error)));
}
});
}
private scheduleReconnect(): void {
if (!this.shouldReconnect) {
return;
}
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
this.notifyError(new Error('Max reconnection attempts reached'));
return;
}
const delay = Math.min(
this.config.reconnectDelay * Math.pow(this.config.reconnectBackoff, this.reconnectAttempts),
this.config.maxReconnectDelay,
);
this.reconnectAttempts++;
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null;
this.doConnect().catch((error: Error) => {
this.notifyError(error);
});
}, delay);
}
private clearReconnectTimeout(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
}
private notifyConnectionChange(connected: boolean): void {
for (const handler of this.connectionHandlers) {
handler(connected);
}
}
private notifyError(error: Error): void {
for (const handler of this.errorHandlers) {
handler(error);
}
}
}
@@ -0,0 +1,352 @@
import {
MESSAGE_SYNC,
MESSAGE_SUBSCRIBE,
MESSAGE_UNSUBSCRIBE,
MESSAGE_CONNECTED,
MESSAGE_DISCONNECTED,
MESSAGE_INITIAL_SYNC,
encodeWithDocId,
decodeWithDocId,
decodeString,
} from '../protocol';
import { WorkerTransport } from './worker';
/**
* Mock MessagePort for testing WorkerTransport.
* Simulates the behavior of a SharedWorker port or regular Worker.
*/
class MockPort {
private handlers: Set<(event: MessageEvent) => void> = new Set();
private started = false;
sentMessages: Uint8Array[] = [];
otherPort: MockPort | null = null;
addEventListener(type: string, handler: EventListener) {
if (type === 'message') {
this.handlers.add(handler as (event: MessageEvent) => void);
}
}
removeEventListener(type: string, handler: EventListener) {
if (type === 'message') {
this.handlers.delete(handler as (event: MessageEvent) => void);
}
}
start() {
this.started = true;
}
postMessage(data: Uint8Array) {
this.sentMessages.push(new Uint8Array(data));
}
/**
* Simulate receiving a message from the worker.
*/
simulateMessage(data: Uint8Array) {
const event = { data } as MessageEvent;
for (const handler of this.handlers) {
handler(event);
}
}
get isStarted() {
return this.started;
}
get handlerCount() {
return this.handlers.size;
}
}
describe('WorkerTransport', () => {
let port: MockPort;
let transport: WorkerTransport;
const testDocId = 'workflow-123';
const testServerUrl = 'wss://server.example.com/crdt';
beforeEach(() => {
port = new MockPort();
transport = new WorkerTransport({
port: port as unknown as MessagePort,
docId: testDocId,
serverUrl: testServerUrl,
});
});
afterEach(() => {
transport.disconnect();
});
describe('constructor', () => {
it('should initialize with connected = false', () => {
expect(transport.connected).toBe(false);
});
});
describe('connect', () => {
it('should send SUBSCRIBE message with docId and serverUrl', async () => {
// Start connect (doesn't await - we need to simulate response)
const connectPromise = transport.connect();
// Verify SUBSCRIBE message was sent
expect(port.sentMessages.length).toBe(1);
const msg = decodeWithDocId(port.sentMessages[0]);
expect(msg.messageType).toBe(MESSAGE_SUBSCRIBE);
expect(msg.docId).toBe(testDocId);
expect(decodeString(msg.payload)).toBe(testServerUrl);
// Simulate INITIAL_SYNC response from worker
const initialSyncMsg = encodeWithDocId(MESSAGE_INITIAL_SYNC, testDocId);
port.simulateMessage(initialSyncMsg);
await connectPromise;
expect(transport.connected).toBe(true);
});
it('should start MessagePort if it has start method', async () => {
const connectPromise = transport.connect();
expect(port.isStarted).toBe(true);
// Complete connection
port.simulateMessage(encodeWithDocId(MESSAGE_INITIAL_SYNC, testDocId));
await connectPromise;
});
it('should return immediately if already connected', async () => {
// First connection
const connectPromise1 = transport.connect();
port.simulateMessage(encodeWithDocId(MESSAGE_INITIAL_SYNC, testDocId));
await connectPromise1;
// Second connect should return immediately
const connectPromise2 = transport.connect();
await connectPromise2;
// Only one SUBSCRIBE message should have been sent
expect(port.sentMessages.length).toBe(1);
});
it('should not send multiple SUBSCRIBE messages if connection is in progress', async () => {
const connectPromise1 = transport.connect();
const connectPromise2 = transport.connect();
// Only one SUBSCRIBE should be sent
expect(port.sentMessages.length).toBe(1);
port.simulateMessage(encodeWithDocId(MESSAGE_INITIAL_SYNC, testDocId));
await Promise.all([connectPromise1, connectPromise2]);
// Still only one SUBSCRIBE
expect(port.sentMessages.length).toBe(1);
});
});
describe('disconnect', () => {
it('should send UNSUBSCRIBE message', async () => {
// Connect first
const connectPromise = transport.connect();
port.simulateMessage(encodeWithDocId(MESSAGE_INITIAL_SYNC, testDocId));
await connectPromise;
transport.disconnect();
// Should have SUBSCRIBE and UNSUBSCRIBE
expect(port.sentMessages.length).toBe(2);
const unsubMsg = decodeWithDocId(port.sentMessages[1]);
expect(unsubMsg.messageType).toBe(MESSAGE_UNSUBSCRIBE);
expect(unsubMsg.docId).toBe(testDocId);
});
it('should set connected to false', async () => {
const connectPromise = transport.connect();
port.simulateMessage(encodeWithDocId(MESSAGE_INITIAL_SYNC, testDocId));
await connectPromise;
expect(transport.connected).toBe(true);
transport.disconnect();
expect(transport.connected).toBe(false);
});
it('should remove message handler', async () => {
const connectPromise = transport.connect();
port.simulateMessage(encodeWithDocId(MESSAGE_INITIAL_SYNC, testDocId));
await connectPromise;
expect(port.handlerCount).toBe(1);
transport.disconnect();
expect(port.handlerCount).toBe(0);
});
it('should do nothing if not connected', () => {
transport.disconnect(); // Should not throw
expect(port.sentMessages.length).toBe(0);
});
});
describe('send', () => {
it('should throw if not connected', () => {
const data = new Uint8Array([MESSAGE_SYNC, 1, 2, 3]);
expect(() => transport.send(data)).toThrow('Transport not connected');
});
it('should add docId to outgoing messages', async () => {
// Connect
const connectPromise = transport.connect();
port.simulateMessage(encodeWithDocId(MESSAGE_INITIAL_SYNC, testDocId));
await connectPromise;
// Send a message (in server format: [type, payload])
const payload = new Uint8Array([10, 20, 30]);
const serverFormatMsg = new Uint8Array([MESSAGE_SYNC, ...payload]);
transport.send(serverFormatMsg);
// Verify it was encoded with docId
expect(port.sentMessages.length).toBe(2); // SUBSCRIBE + our message
const sentMsg = decodeWithDocId(port.sentMessages[1]);
expect(sentMsg.messageType).toBe(MESSAGE_SYNC);
expect(sentMsg.docId).toBe(testDocId);
expect(sentMsg.payload).toEqual(payload);
});
});
describe('onReceive', () => {
it('should forward messages for this docId to handlers', async () => {
const received: Uint8Array[] = [];
transport.onReceive((data) => received.push(new Uint8Array(data)));
// Connect
const connectPromise = transport.connect();
port.simulateMessage(encodeWithDocId(MESSAGE_INITIAL_SYNC, testDocId));
await connectPromise;
// Simulate incoming SYNC message
const payload = new Uint8Array([1, 2, 3, 4, 5]);
const workerMsg = encodeWithDocId(MESSAGE_SYNC, testDocId, payload);
port.simulateMessage(workerMsg);
// Handler should receive server-format message
expect(received.length).toBe(2); // INITIAL_SYNC + SYNC
expect(received[1][0]).toBe(MESSAGE_SYNC);
expect(received[1].subarray(1)).toEqual(payload);
});
it('should ignore messages for other docIds', async () => {
const received: Uint8Array[] = [];
transport.onReceive((data) => received.push(new Uint8Array(data)));
// Connect
const connectPromise = transport.connect();
port.simulateMessage(encodeWithDocId(MESSAGE_INITIAL_SYNC, testDocId));
await connectPromise;
// Simulate incoming message for different docId
const otherDocMsg = encodeWithDocId(MESSAGE_SYNC, 'other-doc-id', new Uint8Array([99]));
port.simulateMessage(otherDocMsg);
// Should only have the INITIAL_SYNC message
expect(received.length).toBe(1);
});
it('should support multiple handlers', async () => {
const received1: Uint8Array[] = [];
const received2: Uint8Array[] = [];
transport.onReceive((data) => received1.push(new Uint8Array(data)));
transport.onReceive((data) => received2.push(new Uint8Array(data)));
const connectPromise = transport.connect();
port.simulateMessage(encodeWithDocId(MESSAGE_INITIAL_SYNC, testDocId));
await connectPromise;
const workerMsg = encodeWithDocId(MESSAGE_SYNC, testDocId, new Uint8Array([42]));
port.simulateMessage(workerMsg);
expect(received1.length).toBe(2);
expect(received2.length).toBe(2);
});
it('should return unsubscribe function', async () => {
const received: Uint8Array[] = [];
const unsubscribe = transport.onReceive((data) => received.push(new Uint8Array(data)));
const connectPromise = transport.connect();
port.simulateMessage(encodeWithDocId(MESSAGE_INITIAL_SYNC, testDocId));
await connectPromise;
// First message should be received
port.simulateMessage(encodeWithDocId(MESSAGE_SYNC, testDocId, new Uint8Array([1])));
expect(received.length).toBe(2);
// Unsubscribe
unsubscribe();
// Second message should not be received
port.simulateMessage(encodeWithDocId(MESSAGE_SYNC, testDocId, new Uint8Array([2])));
expect(received.length).toBe(2); // Still 2
});
});
describe('control messages', () => {
it('should update connected state on MESSAGE_CONNECTED', async () => {
const connectPromise = transport.connect();
// Simulate CONNECTED message
port.simulateMessage(encodeWithDocId(MESSAGE_CONNECTED, testDocId));
expect(transport.connected).toBe(true);
// Still need INITIAL_SYNC to resolve connect promise
port.simulateMessage(encodeWithDocId(MESSAGE_INITIAL_SYNC, testDocId));
await connectPromise;
});
it('should update connected state on MESSAGE_DISCONNECTED', async () => {
const connectPromise = transport.connect();
port.simulateMessage(encodeWithDocId(MESSAGE_INITIAL_SYNC, testDocId));
await connectPromise;
expect(transport.connected).toBe(true);
// Simulate DISCONNECTED message
port.simulateMessage(encodeWithDocId(MESSAGE_DISCONNECTED, testDocId));
expect(transport.connected).toBe(false);
});
it('should forward control messages to handlers', async () => {
const received: Uint8Array[] = [];
transport.onReceive((data) => received.push(new Uint8Array(data)));
const connectPromise = transport.connect();
port.simulateMessage(encodeWithDocId(MESSAGE_INITIAL_SYNC, testDocId));
await connectPromise;
// INITIAL_SYNC should have been forwarded
expect(received.length).toBe(1);
expect(received[0][0]).toBe(MESSAGE_INITIAL_SYNC);
// DISCONNECTED should also be forwarded
port.simulateMessage(encodeWithDocId(MESSAGE_DISCONNECTED, testDocId));
expect(received.length).toBe(2);
expect(received[1][0]).toBe(MESSAGE_DISCONNECTED);
});
});
describe('error handling', () => {
it('should ignore malformed messages', async () => {
const received: Uint8Array[] = [];
transport.onReceive((data) => received.push(new Uint8Array(data)));
const connectPromise = transport.connect();
port.simulateMessage(encodeWithDocId(MESSAGE_INITIAL_SYNC, testDocId));
await connectPromise;
// Send a malformed message (too short to decode)
port.simulateMessage(new Uint8Array([0]));
// Should not crash, and no new messages received
expect(received.length).toBe(1); // Just INITIAL_SYNC
});
});
});
+207
View File
@@ -0,0 +1,207 @@
import type { Unsubscribe } from '../types';
import type { SyncTransport } from './types';
import {
MESSAGE_SUBSCRIBE,
MESSAGE_UNSUBSCRIBE,
MESSAGE_CONNECTED,
MESSAGE_DISCONNECTED,
MESSAGE_INITIAL_SYNC,
encodeWithDocId,
decodeWithDocId,
encodeString,
} from '../protocol';
type ReceiveHandler = (data: Uint8Array) => void;
export interface WorkerTransportConfig {
/** The MessagePort or Worker to communicate through */
port: MessagePort | Worker;
/** Document ID for routing */
docId: string;
/** Server URL for WebSocket connection (empty string for local-only) */
serverUrl: string;
}
/**
* WorkerTransport - Transport using SharedWorker/Worker for CRDT sync.
*
* This transport wraps communication with a SharedWorker or Worker,
* adding docId to messages for multiplexing multiple documents over
* a single worker connection.
*
* Message format sent to worker:
* [messageType: u8, docIdLen: u16, docId: utf8, payload]
*
* The worker strips docId when forwarding to server, and adds it back
* when forwarding server responses to the correct tab.
*
* All messages (including control messages like INITIAL_SYNC) are forwarded
* to onReceive handlers. The consumer is responsible for interpreting them.
*
* Usage:
* ```typescript
* const worker = new SharedWorker('crdt.shared-worker.js');
* const transport = new WorkerTransport({
* port: worker.port,
* docId: 'workflow-123',
* serverUrl: 'wss://server/crdt',
* });
*
* transport.onReceive((data) => {
* // Handle all messages (sync, awareness, initial-sync, etc.)
* });
*
* await transport.connect();
* ```
*/
export class WorkerTransport implements SyncTransport {
private receiveHandlers = new Set<ReceiveHandler>();
private _connected = false;
private messageHandler: ((event: MessageEvent) => void) | null = null;
private connectPromise: Promise<void> | null = null;
private connectResolve: (() => void) | null = null;
private readonly port: MessagePort | Worker;
private readonly docId: string;
private readonly serverUrl: string;
constructor(config: WorkerTransportConfig) {
this.port = config.port;
this.docId = config.docId;
this.serverUrl = config.serverUrl;
}
get connected(): boolean {
return this._connected;
}
send(data: Uint8Array): void {
if (!this._connected) {
throw new Error('Transport not connected');
}
// Add docId prefix for worker routing
// Data is already in server format [type, payload], we need to inject docId
const messageType = data[0];
const payload = data.subarray(1);
const message = encodeWithDocId(messageType, this.docId, payload);
this.port.postMessage(message);
}
onReceive(handler: ReceiveHandler): Unsubscribe {
this.receiveHandlers.add(handler);
return () => {
this.receiveHandlers.delete(handler);
};
}
async connect(): Promise<void> {
if (this._connected) {
return await Promise.resolve();
}
if (this.connectPromise) {
return await this.connectPromise;
}
this.connectPromise = new Promise<void>((resolve) => {
this.connectResolve = resolve;
// Set up message handler
this.messageHandler = (event: MessageEvent<Uint8Array | ArrayBuffer>) => {
const data: Uint8Array | ArrayBuffer = event.data;
// Handle binary messages with docId
if (data instanceof Uint8Array || data instanceof ArrayBuffer) {
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
this.handleBinaryMessage(bytes);
}
};
this.port.addEventListener('message', this.messageHandler as EventListener);
// Start the port if it's a MessagePort
if ('start' in this.port) {
this.port.start();
}
// Send subscribe message
const subscribeMessage = encodeWithDocId(
MESSAGE_SUBSCRIBE,
this.docId,
encodeString(this.serverUrl),
);
this.port.postMessage(subscribeMessage);
});
return await this.connectPromise;
}
disconnect(): void {
if (!this._connected && !this.connectPromise) {
return;
}
// Send unsubscribe message
const unsubscribeMessage = encodeWithDocId(MESSAGE_UNSUBSCRIBE, this.docId);
this.port.postMessage(unsubscribeMessage);
// Clean up
if (this.messageHandler) {
this.port.removeEventListener('message', this.messageHandler as EventListener);
this.messageHandler = null;
}
this._connected = false;
this.connectPromise = null;
this.connectResolve = null;
}
/** No-op for WorkerTransport - connection state changes are handled via protocol messages */
onConnectionChange(_handler: (connected: boolean) => void): Unsubscribe {
return () => {};
}
/** No-op for WorkerTransport - no transport-level errors occur */
onError(_handler: (error: Error) => void): Unsubscribe {
return () => {};
}
private handleBinaryMessage(data: Uint8Array): void {
try {
const { messageType, docId, payload } = decodeWithDocId(data);
// Only process messages for our document
if (docId !== this.docId) {
return;
}
// Handle connection state internally
if (messageType === MESSAGE_CONNECTED) {
this._connected = true;
} else if (messageType === MESSAGE_DISCONNECTED) {
this._connected = false;
}
// Resolve connect promise on initial sync (transport is ready)
if (messageType === MESSAGE_INITIAL_SYNC && this.connectResolve) {
this._connected = true;
this.connectResolve();
this.connectResolve = null;
}
// Forward ALL messages to handlers (including control messages)
// Reconstruct server format [type, payload] for handlers
const serverFormat = new Uint8Array(1 + payload.length);
serverFormat[0] = messageType;
serverFormat.set(payload, 1);
for (const handler of this.receiveHandlers) {
handler(serverFormat);
}
} catch {
// Ignore malformed messages
}
}
}
+501
View File
@@ -0,0 +1,501 @@
/**
* Function returned by event subscriptions to unsubscribe from events.
*/
export type Unsubscribe = () => void;
/**
* Change action types for deep change events.
*/
export const ChangeAction = {
add: 'add',
update: 'update',
delete: 'delete',
} as const;
export type ChangeAction = (typeof ChangeAction)[keyof typeof ChangeAction];
/**
* Represents a deep change event emitted when nested data in a CRDT structure changes.
* Used for Map changes (key-value updates).
*/
export interface DeepChangeEvent {
/** Full path to changed value, e.g., ['node-1', 'position', 'x'] */
path: Array<string | number>;
/** Type of change */
action: ChangeAction;
/** New value (for add/update) */
value?: unknown;
/** Previous value (for update/delete) */
oldValue?: unknown;
}
/**
* Delta operation for array changes (Quill delta format).
* At most one of insert/retain/delete should be set.
*/
export interface ArrayDelta {
/** Items to insert at current position */
insert?: unknown[];
/** Number of items to skip/retain */
retain?: number;
/** Number of items to delete */
delete?: number;
}
/**
* Array change event using delta format.
*/
export interface ArrayChangeEvent {
/** Path to the array that changed */
path: Array<string | number>;
/** Delta operations describing the change */
delta: ArrayDelta[];
}
/**
* Union type for deep change events from observeDeep.
* Array mutations emit ArrayChangeEvent (delta format).
* Map mutations emit DeepChangeEvent (action format).
*/
export type DeepChange = ArrayChangeEvent | DeepChangeEvent;
/**
* Batched changes from a single CRDT transaction.
* Contains all changes grouped by their source root map name.
*
* Use with `CRDTDoc.onTransactionBatch()` to receive all changes from
* multiple maps in a single callback, properly ordered within the transaction.
*/
export interface TransactionBatch {
/** Changes grouped by root-level map name (e.g., 'nodes', 'edges') */
changes: Map<string, DeepChange[]>;
/** Origin of the transaction */
origin: ChangeOrigin;
}
/**
* Origin constants for identifying the source of CRDT changes.
*/
export const ChangeOrigin = {
/** Change originated from local user action */
local: 'local',
/** Change originated from remote peer (network sync) */
remote: 'remote',
/** Change originated from local undo/redo operation */
undoRedo: 'undoRedo',
} as const;
export type ChangeOrigin = (typeof ChangeOrigin)[keyof typeof ChangeOrigin];
/**
* Type guard to check if a DeepChange is a DeepChangeEvent (map change).
*/
export function isMapChange(change: DeepChange): change is DeepChangeEvent {
return 'action' in change;
}
/**
* Type guard to check if a DeepChange is an ArrayChangeEvent (array change).
*/
export function isArrayChange(change: DeepChange): change is ArrayChangeEvent {
return 'delta' in change;
}
/**
* CRDT Array data structure - an ordered list with deep change observation.
* Supports standard array operations and emits change events for mutations.
*
* Note on bounds checking: Behavior for out-of-bounds indices may vary by provider.
* Use valid indices (0 <= index <= length) for consistent cross-provider behavior.
*
* Note on nested values: Plain JS objects/arrays stored in the array are returned as-is.
* For collaborative editing of nested structures, use doc.getMap()/getArray() with explicit paths.
*/
export interface CRDTArray<T = unknown> {
/** Get the number of elements */
readonly length: number;
/** Get element at index. Returns the value as stored (plain objects stay plain). */
get(index: number): T | undefined;
/** Append element(s) to end */
push(...items: T[]): void;
/** Insert element(s) at index. Use index <= length for consistent behavior. */
insert(index: number, ...items: T[]): void;
/** Delete count elements starting at index. Use valid indices for consistent behavior. */
delete(index: number, count?: number): void;
/** Convert to plain JavaScript array */
toArray(): T[];
/** Convert to JSON (alias for toArray) */
toJSON(): T[];
/** Subscribe to deep changes (this array and all nested structures) */
onDeepChange(handler: (changes: DeepChange[], origin: ChangeOrigin) => void): Unsubscribe;
}
/**
* CRDT Map data structure - a key-value store with deep change observation.
*
* Supports nested CRDT structures: get() returns CRDTMap/CRDTArray if that's
* what was stored, otherwise returns plain values. Use toJSON() to convert
* the entire structure to plain objects.
*/
export interface CRDTMap<T = unknown> {
/** Get value by key. Returns CRDTMap/CRDTArray if stored, otherwise plain value. */
get(key: string): T | CRDTMap<unknown> | CRDTArray<unknown> | undefined;
/** Set value for key */
set(key: string, value: T | CRDTMap<unknown> | CRDTArray<unknown>): void;
/** Delete key */
delete(key: string): void;
/** Check if key exists */
has(key: string): boolean;
/** Get all keys */
keys(): IterableIterator<string>;
/** Get all values (includes CRDTMap/CRDTArray instances) */
values(): IterableIterator<T | CRDTMap<unknown> | CRDTArray<unknown>>;
/** Get all entries (includes CRDTMap/CRDTArray instances) */
entries(): IterableIterator<[string, T | CRDTMap<unknown> | CRDTArray<unknown>]>;
/** Convert to plain JSON object (recursively converts nested CRDT types) */
toJSON(): Record<string, T>;
/** Subscribe to deep changes (this map and all nested structures) */
onDeepChange(handler: (changes: DeepChange[], origin: ChangeOrigin) => void): Unsubscribe;
}
/**
* CRDT Document - container for multiple CRDT data structures.
*/
export interface CRDTDoc {
/** Unique document identifier */
readonly id: string;
/**
* Whether the document has completed initial sync with remote peers.
* True when SyncStep2 has been received (like y-websocket's synced property).
*/
readonly synced: boolean;
/** Get or create a named Map at the document root */
getMap<T = unknown>(name: string): CRDTMap<T>;
/** Get or create a named Array at the document root */
getArray<T = unknown>(name: string): CRDTArray<T>;
/** Create a standalone CRDTMap that can be stored in other maps/arrays */
createMap<T = unknown>(): CRDTMap<T>;
/** Create a standalone CRDTArray that can be stored in other maps/arrays */
createArray<T = unknown>(): CRDTArray<T>;
/** Execute changes in a transaction (batched, atomic) */
transact(fn: () => void): void;
/** Encode the full document state as a binary update */
encodeState(): Uint8Array;
/**
* Encode the state vector as a compact binary representation.
* State vectors are small fingerprints (~100 bytes) that represent
* which updates have been applied to the document.
* Useful for efficient change detection - compare vectors to check if state changed.
*/
encodeStateVector(): Uint8Array;
/** Apply an update (or full state) from another document */
applyUpdate(update: Uint8Array): void;
/** Subscribe to outgoing updates. Only fires for local changes (origin='local'). */
onUpdate(handler: (update: Uint8Array, origin: ChangeOrigin) => void): Unsubscribe;
/**
* Subscribe to sync state changes.
* Like y-websocket's 'sync' event - fires when initial sync completes or connection is lost.
* @param handler Called with true when synced, false when disconnected
*/
onSync(handler: (isSynced: boolean) => void): Unsubscribe;
/**
* Mark the document as synced or not synced.
* Called by transport/provider when sync state changes.
*/
setSynced(synced: boolean): void;
/**
* Get the awareness instance for this document.
* Awareness is created lazily on first access.
* Used for ephemeral state like presence and cursors.
*/
getAwareness<T extends AwarenessState = AwarenessState>(): CRDTAwareness<T>;
/**
* Create an undo manager for this document.
* Tracks all local changes and provides undo/redo functionality.
*
* Only one undo manager should be active per document.
* Remote changes are not tracked and do not affect the undo/redo stacks.
*
* @param options Configuration options
* @returns A new undo manager instance
*/
createUndoManager(options?: UndoManagerOptions): CRDTUndoManager;
/**
* Subscribe to batched changes from transactions.
*
* Unlike individual map observers (which fire separately for each map),
* this fires ONCE per transaction with all changes from the specified maps
* batched together. Uses Yjs's `afterTransaction` event internally.
*
* Use this when you need coordinated updates across multiple data types
* (e.g., nodes and edges that must be processed together).
*
* @param mapNames Names of root-level maps to observe (e.g., ['nodes', 'edges'])
* @param handler Called once per transaction with all changes batched
* @returns Unsubscribe function
*/
onTransactionBatch(mapNames: string[], handler: (batch: TransactionBatch) => void): Unsubscribe;
/** Clean up resources */
destroy(): void;
}
/**
* CRDT Provider - factory for creating documents.
*/
export interface CRDTProvider {
/** Provider name (for logging/debugging) */
readonly name: string;
/** Create a new document */
createDoc(id: string): CRDTDoc;
}
/**
* Available CRDT engine types.
*/
export const CRDTEngine = {
yjs: 'yjs',
} as const;
export type CRDTEngine = (typeof CRDTEngine)[keyof typeof CRDTEngine];
/**
* Configuration for creating a CRDT provider.
*/
export interface CRDTConfig {
/** Which CRDT engine to use */
engine: CRDTEngine;
}
// =============================================================================
// Awareness Types
// =============================================================================
/**
* Unique identifier for a client/user in the awareness system.
* Each browser tab or connection gets its own client ID.
*/
export type AwarenessClientId = number;
/**
* User-defined awareness state. Typically includes user info and cursor/selection.
* The shape is defined by the application.
*
* @example
* ```typescript
* interface MyAwarenessState {
* user: { name: string; color: string };
* cursor?: { nodeId: string; position: { x: number; y: number } };
* }
* ```
*/
export type AwarenessState = Record<string, unknown>;
/**
* Event emitted when awareness states change.
* Contains arrays of client IDs that were added, updated, or removed.
*/
export interface AwarenessChangeEvent {
/** Clients that came online or became visible */
added: AwarenessClientId[];
/** Clients whose state was updated */
updated: AwarenessClientId[];
/** Clients that went offline or were removed */
removed: AwarenessClientId[];
}
/**
* CRDT Awareness - ephemeral state for user presence and cursors.
*
* Awareness is separate from the persistent CRDT document. It's used for:
* - User presence (who is online)
* - Cursor positions
* - Selection highlights
* - Typing indicators
*
* Unlike document state, awareness is ephemeral and not persisted.
* Clients are automatically marked offline after a timeout (typically 30s).
*/
export interface CRDTAwareness<T extends AwarenessState = AwarenessState> {
/** This client's unique identifier */
readonly clientId: AwarenessClientId;
/**
* Get this client's current awareness state.
* Returns null if the client has been marked offline.
*/
getLocalState(): T | null;
/**
* Set this client's awareness state.
* Pass null to mark this client as offline.
* State is immediately broadcast to other clients.
*/
setLocalState(state: T | null): void;
/**
* Update a single field in the local awareness state.
* Does nothing if local state is null.
* More efficient than setLocalState for partial updates.
*/
setLocalStateField<K extends keyof T>(field: K, value: T[K]): void;
/**
* Get all awareness states (local and remote).
* Maps from client ID to their awareness state.
* Clients marked offline are not included.
*/
getStates(): Map<AwarenessClientId, T>;
/**
* Subscribe to awareness changes.
* Called when clients come online, update state, or go offline.
* The 'origin' parameter indicates the source of the change.
*/
onChange(handler: (event: AwarenessChangeEvent, origin: ChangeOrigin) => void): Unsubscribe;
/**
* Encode awareness state for specific clients as binary.
* Used for sending awareness updates over the network.
* If no clients specified, encodes all known clients.
*/
encodeState(clients?: AwarenessClientId[]): Uint8Array;
/**
* Apply an awareness update received from the network.
*/
applyUpdate(update: Uint8Array): void;
/**
* Subscribe to outgoing awareness updates.
* Fires when local state changes and needs to be sent to peers.
*/
onUpdate(handler: (update: Uint8Array, origin: ChangeOrigin) => void): Unsubscribe;
/**
* Mark specific clients as offline/removed.
* Useful when a peer disconnects.
*/
removeStates(clients: AwarenessClientId[]): void;
/**
* Clean up resources.
* Marks this client as offline and removes all handlers.
*/
destroy(): void;
}
// =============================================================================
// Undo Manager Types
// =============================================================================
/**
* Options for creating an undo manager.
*/
export interface UndoManagerOptions {
/**
* Time in milliseconds to group consecutive changes into a single undo item.
* Changes made within this window are merged into one undoable operation.
* @default 500
*/
captureTimeout?: number;
}
/**
* Event data emitted when the undo/redo stack changes.
*/
export interface UndoStackChangeEvent {
/** Whether undo is currently possible */
canUndo: boolean;
/** Whether redo is currently possible */
canRedo: boolean;
}
/**
* CRDT Undo Manager - provides undo/redo functionality for document changes.
*
* Only tracks local changes (not remote peer changes from sync).
* Remote changes do NOT clear the redo stack, allowing proper collaborative undo.
*
* @example
* ```typescript
* const undoManager = doc.createUndoManager({ captureTimeout: 500 });
*
* // Make changes
* doc.getMap('data').set('key', 'value');
*
* // Undo the change
* if (undoManager.canUndo()) {
* undoManager.undo();
* }
*
* // Clean up
* undoManager.destroy();
* ```
*/
export interface CRDTUndoManager {
/**
* Undo the last undoable change.
* @returns true if an undo was performed, false if nothing to undo
*/
undo(): boolean;
/**
* Redo the last undone change.
* @returns true if a redo was performed, false if nothing to redo
*/
redo(): boolean;
/**
* Check if undo is possible.
*/
canUndo(): boolean;
/**
* Check if redo is possible.
*/
canRedo(): boolean;
/**
* Force the next change to start a new undo item instead of merging
* with the previous one. Useful for marking logical boundaries
* (e.g., after user completes an operation).
*/
stopCapturing(): void;
/**
* Clear the entire undo and redo history.
*/
clear(): void;
/**
* Subscribe to stack changes. Called when canUndo/canRedo state changes.
* Useful for updating UI button states.
*
* @param handler Called when stack state changes
* @returns Unsubscribe function
*/
onStackChange(handler: (event: UndoStackChangeEvent) => void): Unsubscribe;
/**
* Store metadata on the current undo item.
* Useful for storing cursor/selection state to restore after undo.
*
* @param key Metadata key
* @param value Metadata value
*/
setMeta<V>(key: string, value: V): void;
/**
* Retrieve metadata from the most recently undone/redone item.
* Returns undefined if no metadata exists for the key.
*
* @param key Metadata key
* @returns The stored value or undefined
*/
getMeta<V>(key: string): V | undefined;
/**
* Clean up resources. The undo manager cannot be used after calling destroy.
*/
destroy(): void;
}
+1
View File
@@ -0,0 +1 @@
export { YjsUndoManager, YjsUndoManagerOrigin } from './yjs-undo-manager';
@@ -0,0 +1,831 @@
import { YjsProvider } from '../providers/yjs';
import type {
CRDTDoc,
CRDTMap,
CRDTUndoManager,
DeepChange,
DeepChangeEvent,
TransactionBatch,
UndoStackChangeEvent,
} from '../types';
// Run tests for all providers
describe.each([['Yjs', () => new YjsProvider()]])('%s UndoManager', (_name, createProvider) => {
let doc: CRDTDoc;
let map: CRDTMap<string>;
let undoManager: CRDTUndoManager;
beforeEach(() => {
const provider = createProvider();
doc = provider.createDoc('test');
map = doc.getMap<string>('test-map');
undoManager = doc.createUndoManager({ captureTimeout: 0 }); // Disable grouping for tests
});
afterEach(() => {
undoManager.destroy();
doc.destroy();
});
describe('basic undo/redo', () => {
it('should undo a single change', () => {
map.set('key', 'value');
expect(map.get('key')).toBe('value');
expect(undoManager.canUndo()).toBe(true);
const result = undoManager.undo();
expect(result).toBe(true);
expect(map.get('key')).toBeUndefined();
expect(undoManager.canUndo()).toBe(false);
});
it('should redo an undone change', () => {
map.set('key', 'value');
undoManager.undo();
expect(map.get('key')).toBeUndefined();
expect(undoManager.canRedo()).toBe(true);
const result = undoManager.redo();
expect(result).toBe(true);
expect(map.get('key')).toBe('value');
expect(undoManager.canRedo()).toBe(false);
});
it('should handle multiple undo operations', () => {
map.set('key', 'value1');
undoManager.stopCapturing();
map.set('key', 'value2');
undoManager.stopCapturing();
map.set('key', 'value3');
expect(map.get('key')).toBe('value3');
undoManager.undo();
expect(map.get('key')).toBe('value2');
undoManager.undo();
expect(map.get('key')).toBe('value1');
undoManager.undo();
expect(map.get('key')).toBeUndefined();
});
it('should handle multiple redo operations', () => {
map.set('key', 'value1');
undoManager.stopCapturing();
map.set('key', 'value2');
undoManager.stopCapturing();
map.set('key', 'value3');
// Undo all
undoManager.undo();
undoManager.undo();
undoManager.undo();
expect(map.get('key')).toBeUndefined();
// Redo all
undoManager.redo();
expect(map.get('key')).toBe('value1');
undoManager.redo();
expect(map.get('key')).toBe('value2');
undoManager.redo();
expect(map.get('key')).toBe('value3');
});
it('should clear redo stack on new change', () => {
map.set('key', 'value1');
undoManager.stopCapturing();
map.set('key', 'value2');
undoManager.undo();
expect(undoManager.canRedo()).toBe(true);
map.set('key', 'value3');
expect(undoManager.canRedo()).toBe(false);
});
it('should return false when nothing to undo', () => {
expect(undoManager.canUndo()).toBe(false);
expect(undoManager.undo()).toBe(false);
});
it('should return false when nothing to redo', () => {
expect(undoManager.canRedo()).toBe(false);
expect(undoManager.redo()).toBe(false);
});
it('should handle delete operations', () => {
map.set('key', 'value');
undoManager.stopCapturing();
map.delete('key');
expect(map.has('key')).toBe(false);
undoManager.undo();
expect(map.get('key')).toBe('value');
undoManager.redo();
expect(map.has('key')).toBe(false);
});
it('should handle multiple keys in transaction', () => {
doc.transact(() => {
map.set('a', '1');
map.set('b', '2');
});
undoManager.stopCapturing();
doc.transact(() => {
map.set('c', '3');
map.delete('a');
});
expect(map.toJSON()).toEqual({ b: '2', c: '3' });
undoManager.undo();
expect(map.toJSON()).toEqual({ a: '1', b: '2' });
});
});
describe('transactions', () => {
it('should undo entire transaction as one operation', () => {
doc.transact(() => {
map.set('a', '1');
map.set('b', '2');
map.set('c', '3');
});
expect(map.toJSON()).toEqual({ a: '1', b: '2', c: '3' });
undoManager.undo();
expect(map.toJSON()).toEqual({});
});
it('should redo entire transaction as one operation', () => {
doc.transact(() => {
map.set('a', '1');
map.set('b', '2');
});
undoManager.undo();
expect(map.toJSON()).toEqual({});
undoManager.redo();
expect(map.toJSON()).toEqual({ a: '1', b: '2' });
});
it('should handle nested transactions', () => {
doc.transact(() => {
map.set('a', '1');
doc.transact(() => {
map.set('b', '2');
});
map.set('c', '3');
});
expect(map.toJSON()).toEqual({ a: '1', b: '2', c: '3' });
// Should undo entire outer transaction as one operation
undoManager.undo();
expect(map.toJSON()).toEqual({});
});
});
describe('capture timeout', () => {
it('should merge rapid changes with zero timeout disabled', () => {
// With captureTimeout: 0, each change should be separate
// But since we're using the default undoManager with captureTimeout: 0,
// we're already testing this in basic undo/redo tests
// This test verifies that with default behavior,
// rapid changes are still grouped logically
map.set('key', 'value1');
map.set('key', 'value2');
map.set('key', 'value3');
// With captureTimeout: 0, these should still be in one undo item
// because they happen in the same tick
undoManager.undo();
// May undo all or just the last one depending on implementation
// The key point is undo works without error
expect(undoManager.canRedo()).toBe(true);
});
it('should separate changes when stopCapturing is called', () => {
// Test the same behavior using stopCapturing instead of timeout
map.set('key', 'value1');
undoManager.stopCapturing(); // Force separate undo item
map.set('key', 'value2');
undoManager.undo();
expect(map.get('key')).toBe('value1');
});
});
describe('stopCapturing', () => {
it('should force next change to be separate undo item', () => {
map.set('key', 'value1');
undoManager.stopCapturing();
map.set('key', 'value2');
undoManager.undo();
expect(map.get('key')).toBe('value1');
undoManager.undo();
expect(map.get('key')).toBeUndefined();
});
it('should work multiple times', () => {
map.set('a', '1');
undoManager.stopCapturing();
map.set('b', '2');
undoManager.stopCapturing();
map.set('c', '3');
// Three separate undo items
expect(undoManager.canUndo()).toBe(true);
undoManager.undo();
expect(map.toJSON()).toEqual({ a: '1', b: '2' });
undoManager.undo();
expect(map.toJSON()).toEqual({ a: '1' });
undoManager.undo();
expect(map.toJSON()).toEqual({});
});
});
describe('clear', () => {
it('should clear undo stack', () => {
map.set('key', 'value1');
undoManager.stopCapturing();
map.set('key', 'value2');
expect(undoManager.canUndo()).toBe(true);
undoManager.clear();
expect(undoManager.canUndo()).toBe(false);
// Value should remain
expect(map.get('key')).toBe('value2');
});
it('should clear redo stack', () => {
map.set('key', 'value1');
undoManager.undo();
expect(undoManager.canRedo()).toBe(true);
undoManager.clear();
expect(undoManager.canRedo()).toBe(false);
});
it('should clear both stacks', () => {
map.set('key', 'value1');
undoManager.stopCapturing();
map.set('key', 'value2');
undoManager.undo();
expect(undoManager.canUndo()).toBe(true);
expect(undoManager.canRedo()).toBe(true);
undoManager.clear();
expect(undoManager.canUndo()).toBe(false);
expect(undoManager.canRedo()).toBe(false);
});
});
describe('onStackChange', () => {
it('should emit event when undo becomes available', () => {
const events: UndoStackChangeEvent[] = [];
undoManager.onStackChange((e) => events.push({ ...e }));
map.set('key', 'value');
expect(events).toContainEqual({ canUndo: true, canRedo: false });
});
it('should emit event when redo becomes available', () => {
const events: UndoStackChangeEvent[] = [];
map.set('key', 'value');
undoManager.onStackChange((e) => events.push({ ...e }));
undoManager.undo();
expect(events).toContainEqual({ canUndo: false, canRedo: true });
});
it('should emit event when stacks are cleared', () => {
const events: UndoStackChangeEvent[] = [];
map.set('key', 'value');
undoManager.undo();
undoManager.onStackChange((e) => events.push({ ...e }));
undoManager.clear();
expect(events).toContainEqual({ canUndo: false, canRedo: false });
});
it('should stop emitting after unsubscribe', () => {
const events: UndoStackChangeEvent[] = [];
const unsubscribe = undoManager.onStackChange((e) => events.push({ ...e }));
map.set('key', 'value1');
const countAfterFirst = events.length;
expect(countAfterFirst).toBeGreaterThan(0);
unsubscribe();
undoManager.stopCapturing();
map.set('key', 'value2');
expect(events.length).toBe(countAfterFirst);
});
});
describe('metadata', () => {
it('should store and retrieve metadata on undo', () => {
map.set('key', 'value');
undoManager.setMeta('cursor', { x: 10, y: 20 });
undoManager.undo();
expect(undoManager.getMeta('cursor')).toEqual({ x: 10, y: 20 });
});
it('should retrieve metadata from the last undone operation', () => {
map.set('key', 'value1');
undoManager.setMeta('cursor', { x: 10, y: 10 });
undoManager.stopCapturing();
map.set('key', 'value2');
undoManager.setMeta('cursor', { x: 20, y: 20 });
// Undo the second change
undoManager.undo();
expect(undoManager.getMeta('cursor')).toEqual({ x: 20, y: 20 });
// Undo the first change
undoManager.undo();
expect(undoManager.getMeta('cursor')).toEqual({ x: 10, y: 10 });
});
it('should return undefined for non-existent key', () => {
map.set('key', 'value');
undoManager.undo();
expect(undoManager.getMeta('nonexistent')).toBeUndefined();
});
it('should return undefined when no undo has been performed', () => {
expect(undoManager.getMeta('anything')).toBeUndefined();
});
});
describe('remote changes', () => {
it('should not clear redo stack on remote change', () => {
map.set('key', 'value1');
undoManager.stopCapturing();
map.set('key', 'value2');
undoManager.undo();
expect(undoManager.canRedo()).toBe(true);
// Simulate remote change - use a different doc ID (simulating another client)
const remoteProvider = createProvider();
const remoteDoc = remoteProvider.createDoc('remote-peer');
remoteDoc.getMap<string>('test-map').set('remote-key', 'remote-value');
doc.applyUpdate(remoteDoc.encodeState());
remoteDoc.destroy();
// Redo stack should still be intact (this is the key assertion for undo manager)
expect(undoManager.canRedo()).toBe(true);
// Note: In CRDT implementations, merging docs with different IDs may behave differently
// The key test here is that redo stack is preserved, not the merge semantics
});
it('should not track remote changes in undo stack', () => {
// Start with empty stack
expect(undoManager.canUndo()).toBe(false);
// Apply remote change - use a different doc ID (simulating another client)
const remoteProvider = createProvider();
const remoteDoc = remoteProvider.createDoc('remote-peer');
remoteDoc.getMap<string>('test-map').set('remote-key', 'remote-value');
doc.applyUpdate(remoteDoc.encodeState());
remoteDoc.destroy();
// Remote changes should not be tracked - this is the key assertion
expect(undoManager.canUndo()).toBe(false);
});
});
describe('destroy', () => {
it('should not allow operations after destroy', () => {
map.set('key', 'value');
undoManager.destroy();
expect(undoManager.undo()).toBe(false);
expect(undoManager.redo()).toBe(false);
expect(undoManager.canUndo()).toBe(false);
expect(undoManager.canRedo()).toBe(false);
});
it('should not emit events after destroy', () => {
const events: UndoStackChangeEvent[] = [];
undoManager.onStackChange((e) => events.push({ ...e }));
undoManager.destroy();
// Make a change - should not trigger events
map.set('key', 'value');
// No events after destroy
expect(events.length).toBe(0);
});
});
describe('edge cases', () => {
it('should handle undo/redo cycle without errors', () => {
map.set('key', 'value1');
undoManager.stopCapturing();
map.set('key', 'value2');
undoManager.stopCapturing();
map.set('key', 'value3');
// Full cycle
undoManager.undo();
undoManager.undo();
undoManager.undo();
undoManager.redo();
undoManager.redo();
undoManager.redo();
undoManager.undo();
expect(map.get('key')).toBe('value2');
});
it('should handle empty document undo', () => {
// No changes made
expect(undoManager.undo()).toBe(false);
expect(undoManager.canUndo()).toBe(false);
});
it('should work with the same map', () => {
// Note: Y.UndoManager only tracks types in its scope at creation time
// This test uses the map that was created before the undo manager
map.set('a', '1');
undoManager.stopCapturing();
map.set('b', '2');
undoManager.undo();
expect(map.get('a')).toBe('1');
expect(map.get('b')).toBeUndefined();
undoManager.undo();
expect(map.get('a')).toBeUndefined();
});
});
});
describe('UndoManager - provider-specific tests', () => {
describe('Yjs - only one undo manager per document', () => {
it('should throw when creating second undo manager', () => {
const provider = new YjsProvider();
const doc = provider.createDoc('test');
const um1 = doc.createUndoManager();
expect(() => doc.createUndoManager()).toThrow('Undo manager already exists');
um1.destroy();
doc.destroy();
});
});
describe('Yjs - doc.transact integration', () => {
it('should track changes made via doc.transact', () => {
const provider = new YjsProvider();
const doc = provider.createDoc('test');
const map = doc.getMap<string>('test-map');
const undoManager = doc.createUndoManager({ captureTimeout: 0 });
// Use doc.transact like the real app does
doc.transact(() => {
map.set('key', 'value1');
});
expect(undoManager.canUndo()).toBe(true);
expect(map.get('key')).toBe('value1');
// Undo should revert the change
const undoResult = undoManager.undo();
expect(undoResult).toBe(true);
expect(map.get('key')).toBeUndefined();
// Redo should restore it
const redoResult = undoManager.redo();
expect(redoResult).toBe(true);
expect(map.get('key')).toBe('value1');
undoManager.destroy();
doc.destroy();
});
it('should track nested map changes via doc.transact', () => {
const provider = new YjsProvider();
const doc = provider.createDoc('test');
const nodesMap = doc.getMap('nodes');
const undoManager = doc.createUndoManager({ captureTimeout: 0 });
// Simulate adding a node (like useCrdtWorkflowDoc does)
doc.transact(() => {
const nodeMap = doc.createMap();
nodeMap.set('position', [100, 200]);
nodeMap.set('name', 'Test Node');
nodesMap.set('node-1', nodeMap);
});
expect(undoManager.canUndo()).toBe(true);
expect(nodesMap.get('node-1')).toBeDefined();
// Undo should remove the node
undoManager.undo();
expect(nodesMap.get('node-1')).toBeUndefined();
// Redo should restore it
undoManager.redo();
const node = nodesMap.get('node-1');
expect(node).toBeDefined();
undoManager.destroy();
doc.destroy();
});
it('should track position update via doc.transact', () => {
const provider = new YjsProvider();
const doc = provider.createDoc('test');
const nodesMap = doc.getMap('nodes');
const undoManager = doc.createUndoManager({ captureTimeout: 0 });
// Add a node first
doc.transact(() => {
const nodeMap = doc.createMap();
nodeMap.set('position', [100, 200]);
nodesMap.set('node-1', nodeMap);
});
undoManager.stopCapturing();
// Get the node and update position (like drag does)
const nodeMap = nodesMap.get('node-1') as CRDTMap<unknown>;
doc.transact(() => {
nodeMap.set('position', [300, 400]);
});
expect(nodeMap.get('position')).toEqual([300, 400]);
expect(undoManager.canUndo()).toBe(true);
// Undo should revert position
undoManager.undo();
expect(nodeMap.get('position')).toEqual([100, 200]);
undoManager.destroy();
doc.destroy();
});
it('should work when undo manager is created after maps exist', () => {
const provider = new YjsProvider();
const doc = provider.createDoc('test');
// Access maps BEFORE creating undo manager (like real app flow)
const nodesMap = doc.getMap('nodes');
const edgesMap = doc.getMap('edges');
// Now create undo manager (should include nodes and edges in scope)
const undoManager = doc.createUndoManager({ captureTimeout: 0 });
// Make a change
doc.transact(() => {
const nodeMap = doc.createMap();
nodeMap.set('position', [100, 200]);
nodesMap.set('node-1', nodeMap);
});
expect(undoManager.canUndo()).toBe(true);
expect(nodesMap.get('node-1')).toBeDefined();
// Undo should work
undoManager.undo();
expect(nodesMap.get('node-1')).toBeUndefined();
// Suppress unused variable warning
void edgesMap;
undoManager.destroy();
doc.destroy();
});
it('should emit undoRedo origin when undo/redo is triggered', async () => {
const { ChangeOrigin } = await import('../types');
const provider = new YjsProvider();
const doc = provider.createDoc('test');
const nodesMap = doc.getMap('nodes');
const undoManager = doc.createUndoManager({ captureTimeout: 0 });
// Track origins from onTransactionBatch
const origins: string[] = [];
doc.onTransactionBatch(['nodes'], (batch) => {
origins.push(batch.origin);
});
// Make a change (should be 'local')
doc.transact(() => {
const nodeMap = doc.createMap();
nodeMap.set('position', [100, 200]);
nodesMap.set('node-1', nodeMap);
});
expect(origins).toContain(ChangeOrigin.local);
origins.length = 0; // Clear
// Undo (should be 'undoRedo')
undoManager.undo();
expect(origins).toContain(ChangeOrigin.undoRedo);
origins.length = 0;
// Redo (should be 'undoRedo')
undoManager.redo();
expect(origins).toContain(ChangeOrigin.undoRedo);
undoManager.destroy();
doc.destroy();
});
it('should emit position changes on undo via onTransactionBatch', async () => {
const { ChangeOrigin, isMapChange } = await import('../types');
const provider = new YjsProvider();
const doc = provider.createDoc('test');
const nodesMap = doc.getMap('nodes');
const undoManager = doc.createUndoManager({ captureTimeout: 0 });
// Track changes from onTransactionBatch
interface TrackedChange {
origin: string;
changes: DeepChange[];
}
const tracked: TrackedChange[] = [];
doc.onTransactionBatch(['nodes'], (batch: TransactionBatch) => {
const allChanges: DeepChange[] = [];
for (const changes of batch.changes.values()) {
allChanges.push(...changes);
}
tracked.push({
origin: batch.origin,
changes: allChanges,
});
});
// Add a node
doc.transact(() => {
const nodeMap = doc.createMap();
nodeMap.set('position', [100, 200]);
nodesMap.set('node-1', nodeMap);
});
undoManager.stopCapturing();
tracked.length = 0; // Clear initial add
// Get the node and update position
const nodeMap = nodesMap.get('node-1') as CRDTMap<unknown>;
doc.transact(() => {
nodeMap.set('position', [300, 400]);
});
// Verify we got the position change
expect(tracked.length).toBe(1);
expect(tracked[0].origin).toBe(ChangeOrigin.local);
const positionChange = tracked[0].changes.find(
(c) => isMapChange(c) && c.path.includes('position'),
);
expect(positionChange).toBeDefined();
tracked.length = 0;
// Undo the position change
undoManager.undo();
// Verify we got the undo change with undoRedo origin
expect(tracked.length).toBe(1);
expect(tracked[0].origin).toBe(ChangeOrigin.undoRedo);
const undoChange = tracked[0].changes.find(
(c) => isMapChange(c) && c.path.includes('position'),
);
expect(undoChange).toBeDefined();
expect(isMapChange(undoChange!)).toBe(true);
expect((undoChange as DeepChangeEvent).value).toEqual([100, 200]); // Original position restored
undoManager.destroy();
doc.destroy();
});
it('should emit position changes with correct path structure', async () => {
const { isMapChange } = await import('../types');
const provider = new YjsProvider();
const doc = provider.createDoc('test');
const nodesMap = doc.getMap('nodes');
// Track all changes
const changes: DeepChange[] = [];
doc.onTransactionBatch(['nodes'], (batch) => {
for (const [, mapChanges] of batch.changes) {
for (const change of mapChanges) {
changes.push(change);
}
}
});
// Add a node
doc.transact(() => {
const nodeMap = doc.createMap();
nodeMap.set('position', [100, 200]);
nodesMap.set('node-1', nodeMap);
});
// Find the node add change
const nodeAddChange = changes.find(
(c) => isMapChange(c) && c.path.length === 1 && c.path[0] === 'node-1',
);
expect(nodeAddChange).toBeDefined();
expect(isMapChange(nodeAddChange!)).toBe(true);
expect((nodeAddChange as DeepChangeEvent).action).toBe('add');
changes.length = 0;
// Update position on existing node
const nodeMap = nodesMap.get('node-1') as CRDTMap<unknown>;
doc.transact(() => {
nodeMap.set('position', [300, 400]);
});
// onTransactionBatch uses changedParentTypes with target filtering
// to only get direct changes, avoiding duplicate propagated events
expect(changes.length).toBe(1);
const posChange = changes.find(
(c) =>
isMapChange(c) &&
c.path.length === 2 &&
c.path[0] === 'node-1' &&
c.path[1] === 'position',
);
expect(posChange).toBeDefined();
expect(isMapChange(posChange!)).toBe(true);
expect((posChange as DeepChangeEvent).action).toBe('update');
expect((posChange as DeepChangeEvent).value).toEqual([300, 400]);
doc.destroy();
});
it('should receive nested changes via observeDeep on the root map', async () => {
const { isMapChange } = await import('../types');
const provider = new YjsProvider();
const doc = provider.createDoc('test');
const nodesMap = doc.getMap('nodes');
// Track changes using onDeepChange (which uses observeDeep internally)
const changes: DeepChange[] = [];
nodesMap.onDeepChange((changesFromDeep, _origin) => {
changes.push(...changesFromDeep);
});
// Add a node
doc.transact(() => {
const nodeMap = doc.createMap();
nodeMap.set('position', [100, 200]);
nodesMap.set('node-1', nodeMap);
});
changes.length = 0;
// Update position on existing node
const nodeMap = nodesMap.get('node-1') as CRDTMap<unknown>;
doc.transact(() => {
nodeMap.set('position', [300, 400]);
});
// observeDeep DOES receive nested changes with path relative to observed type
const posChange = changes.find(
(c) =>
isMapChange(c) &&
c.path.length === 2 &&
c.path[0] === 'node-1' &&
c.path[1] === 'position',
);
expect(posChange).toBeDefined();
expect(isMapChange(posChange!)).toBe(true);
expect((posChange as DeepChangeEvent).action).toBe('update');
expect((posChange as DeepChangeEvent).value).toEqual([300, 400]);
doc.destroy();
});
});
});
@@ -0,0 +1,178 @@
import * as Y from 'yjs';
import type {
CRDTUndoManager,
UndoManagerOptions,
UndoStackChangeEvent,
Unsubscribe,
} from '../types';
/** Origin symbol used for undo manager to track local changes */
export const YjsUndoManagerOrigin = Symbol('local-undo-tracked');
/** Origin symbol used for remote changes that should NOT be tracked */
export const YjsRemoteOrigin = Symbol('remote-no-track');
/**
* Stack item type from Y.UndoManager events.
*/
interface YjsStackItem {
meta: Map<string, unknown>;
}
/**
* Event type from Y.UndoManager stack events.
*/
interface YjsStackEvent {
stackItem: YjsStackItem;
type: 'undo' | 'redo';
}
/**
* Yjs implementation of CRDTUndoManager.
* Wraps Y.UndoManager with document-wide scope.
*/
export class YjsUndoManager implements CRDTUndoManager {
private readonly undoManager: Y.UndoManager;
private readonly stackChangeHandlers = new Set<(event: UndoStackChangeEvent) => void>();
private destroyed = false;
/** Metadata from the last undo/redo operation */
private lastMeta: Map<string, unknown> | null = null;
/** Previous stack state for change detection */
private prevCanUndo = false;
private prevCanRedo = false;
constructor(yDoc: Y.Doc, options: UndoManagerOptions = {}) {
const { captureTimeout = 500 } = options;
// Create UndoManager scoped to all root-level types in the document
// This provides document-wide undo rather than per-structure
// Y.UndoManager accepts AbstractType or array of AbstractType
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const scope: Array<Y.AbstractType<Y.YEvent<any>>> = [];
// Get all named maps and arrays at the root
const typeNames: string[] = [];
yDoc.share.forEach((type, name) => {
scope.push(type);
typeNames.push(name);
});
// If no types exist yet, we still need a valid scope
// Create a dummy type that will be included when real types are added
if (scope.length === 0) {
// Use a hidden map as the scope - any changes will still be tracked
// because Y.UndoManager observes the doc's transaction events.
// Double cast required: Y.Map extends AbstractType at runtime, but TypeScript's
// event type hierarchy (YMapEvent vs YEvent) prevents direct assignment
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dummyScope = yDoc.getMap('__undo_scope__') as unknown as Y.AbstractType<Y.YEvent<any>>;
scope.push(dummyScope);
}
this.undoManager = new Y.UndoManager(scope, {
captureTimeout,
// Track changes from our explicit local origin AND null origin (direct mutations).
// Remote changes use YjsRemoteOrigin which is NOT in this set.
trackedOrigins: new Set([YjsUndoManagerOrigin, null]),
});
// Listen for stack changes to notify handlers
this.undoManager.on('stack-item-added', this.handleStackChange);
this.undoManager.on('stack-item-popped', this.handleStackItemPopped);
}
private handleStackChange = (): void => {
if (this.destroyed) return;
this.notifyIfChanged();
};
private handleStackItemPopped = (event: YjsStackEvent): void => {
if (this.destroyed) return;
// Capture metadata from popped items
this.lastMeta = event.stackItem.meta;
this.notifyIfChanged();
};
private notifyIfChanged(): void {
const canUndo = this.canUndo();
const canRedo = this.canRedo();
// Only notify if state actually changed
if (canUndo !== this.prevCanUndo || canRedo !== this.prevCanRedo) {
this.prevCanUndo = canUndo;
this.prevCanRedo = canRedo;
const event: UndoStackChangeEvent = { canUndo, canRedo };
for (const handler of this.stackChangeHandlers) {
handler(event);
}
}
}
undo(): boolean {
if (this.destroyed || !this.canUndo()) return false;
this.undoManager.undo();
return true;
}
redo(): boolean {
if (this.destroyed || !this.canRedo()) return false;
this.undoManager.redo();
return true;
}
canUndo(): boolean {
if (this.destroyed) return false;
return this.undoManager.undoStack.length > 0;
}
canRedo(): boolean {
if (this.destroyed) return false;
return this.undoManager.redoStack.length > 0;
}
stopCapturing(): void {
this.undoManager.stopCapturing();
}
clear(): void {
this.undoManager.clear();
this.lastMeta = null;
this.notifyIfChanged();
}
onStackChange(handler: (event: UndoStackChangeEvent) => void): Unsubscribe {
this.stackChangeHandlers.add(handler);
return () => {
this.stackChangeHandlers.delete(handler);
};
}
setMeta<V>(key: string, value: V): void {
// Store metadata on the current (most recent) stack item
const currentItem = this.undoManager.undoStack[this.undoManager.undoStack.length - 1] as
| YjsStackItem
| undefined;
if (currentItem) {
currentItem.meta.set(key, value);
}
}
getMeta<V>(key: string): V | undefined {
return this.lastMeta?.get(key) as V | undefined;
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.undoManager.off('stack-item-added', this.handleStackChange);
this.undoManager.off('stack-item-popped', this.handleStackItemPopped);
this.undoManager.destroy();
this.stackChangeHandlers.clear();
this.lastMeta = null;
}
}
+469
View File
@@ -0,0 +1,469 @@
import { CRDTEngine, createCRDTProvider } from './index';
import type { CRDTArray, CRDTDoc, CRDTMap } from './types';
import { seedValueDeep, toJSON, getNestedValue, setNestedValue } from './utils';
describe('Utils', () => {
let doc: CRDTDoc;
beforeEach(() => {
const provider = createCRDTProvider({ engine: CRDTEngine.yjs });
doc = provider.createDoc('test-utils');
});
afterEach(() => {
doc.destroy();
});
describe('seedValueDeep', () => {
it('should return primitives as-is', () => {
expect(seedValueDeep(doc, 'hello')).toBe('hello');
expect(seedValueDeep(doc, 42)).toBe(42);
expect(seedValueDeep(doc, true)).toBe(true);
expect(seedValueDeep(doc, false)).toBe(false);
expect(seedValueDeep(doc, null)).toBe(null);
expect(seedValueDeep(doc, undefined)).toBe(undefined);
});
it('should convert plain object to CRDTMap', () => {
const result = seedValueDeep(doc, { name: 'test', value: 123 });
expect(result).toBeDefined();
expect(typeof (result as CRDTMap).get).toBe('function');
// Attach to document to verify values persist
const root = doc.getMap('test');
root.set('data', result as CRDTMap);
const attached = root.get('data') as CRDTMap<string | number>;
expect(attached.get('name')).toBe('test');
expect(attached.get('value')).toBe(123);
});
it('should convert plain array to CRDTArray', () => {
const result = seedValueDeep(doc, [1, 2, 3]);
expect(result).toBeDefined();
expect(typeof (result as CRDTArray).get).toBe('function');
// Attach to document to verify values persist
const root = doc.getMap('test');
root.set('data', result as CRDTArray);
const attached = root.get('data') as CRDTArray<number>;
expect(attached.get(0)).toBe(1);
expect(attached.get(1)).toBe(2);
expect(attached.get(2)).toBe(3);
expect(attached.length).toBe(3);
});
it('should handle nested objects', () => {
const result = seedValueDeep(doc, {
user: {
name: 'Alice',
settings: {
theme: 'dark',
},
},
});
// Attach to document
const root = doc.getMap('test');
root.set('data', result as CRDTMap);
const map = root.get('data') as CRDTMap;
const user = map.get('user') as CRDTMap;
expect(user.get('name')).toBe('Alice');
const settings = user.get('settings') as CRDTMap;
expect(settings.get('theme')).toBe('dark');
});
it('should handle nested arrays', () => {
const result = seedValueDeep(doc, {
items: [
{ id: 1, name: 'first' },
{ id: 2, name: 'second' },
],
});
// Attach to document
const root = doc.getMap('test');
root.set('data', result as CRDTMap);
const map = root.get('data') as CRDTMap;
const items = map.get('items') as CRDTArray;
expect(items.length).toBe(2);
const first = items.get(0) as CRDTMap;
expect(first.get('id')).toBe(1);
expect(first.get('name')).toBe('first');
const second = items.get(1) as CRDTMap;
expect(second.get('id')).toBe(2);
expect(second.get('name')).toBe('second');
});
it('should handle arrays containing arrays', () => {
const result = seedValueDeep(doc, [
[1, 2],
[3, 4],
]);
// Attach to document
const root = doc.getMap('test');
root.set('data', result as CRDTArray);
const arr = root.get('data') as CRDTArray;
expect(arr.length).toBe(2);
const inner1 = arr.get(0) as CRDTArray<number>;
expect(inner1.get(0)).toBe(1);
expect(inner1.get(1)).toBe(2);
const inner2 = arr.get(1) as CRDTArray<number>;
expect(inner2.get(0)).toBe(3);
expect(inner2.get(1)).toBe(4);
});
it('should handle empty objects', () => {
const result = seedValueDeep(doc, {});
expect(result).toBeDefined();
expect(typeof (result as CRDTMap).get).toBe('function');
});
it('should handle empty arrays', () => {
const result = seedValueDeep(doc, []);
expect(result).toBeDefined();
expect((result as CRDTArray).length).toBe(0);
});
it('should handle mixed nested structures', () => {
const result = seedValueDeep(doc, {
name: 'workflow',
nodes: [
{
id: 'node1',
params: {
values: [10, 20, 30],
},
},
],
});
// Attach to document
const root = doc.getMap('test');
root.set('data', result as CRDTMap);
const map = root.get('data') as CRDTMap;
expect(map.get('name')).toBe('workflow');
const nodes = map.get('nodes') as CRDTArray;
const node = nodes.get(0) as CRDTMap;
expect(node.get('id')).toBe('node1');
const params = node.get('params') as CRDTMap;
const values = params.get('values') as CRDTArray<number>;
expect(values.get(0)).toBe(10);
expect(values.get(1)).toBe(20);
expect(values.get(2)).toBe(30);
});
});
describe('toJSON', () => {
it('should return primitives as-is', () => {
expect(toJSON('hello')).toBe('hello');
expect(toJSON(42)).toBe(42);
expect(toJSON(true)).toBe(true);
expect(toJSON(false)).toBe(false);
expect(toJSON(null)).toBe(null);
expect(toJSON(undefined)).toBe(undefined);
});
it('should convert CRDTMap to plain object', () => {
// Use named root map (attached to document)
const map = doc.getMap<string | number>('testMap');
map.set('name', 'test');
map.set('value', 123);
const result = toJSON(map);
expect(result).toEqual({ name: 'test', value: 123 });
});
it('should convert CRDTArray to plain array', () => {
// Use named root array (attached to document)
const arr = doc.getArray<number>('testArray');
arr.push(1, 2, 3);
const result = toJSON(arr);
expect(result).toEqual([1, 2, 3]);
});
it('should handle objects with toJSON method', () => {
const obj = {
toJSON: () => ({ converted: true }),
};
const result = toJSON(obj);
expect(result).toEqual({ converted: true });
});
it('should return plain objects as-is', () => {
const obj = { name: 'plain', value: 42 };
const result = toJSON(obj);
expect(result).toBe(obj);
});
it('should return plain arrays as-is', () => {
const arr = [1, 2, 3];
const result = toJSON(arr);
expect(result).toBe(arr);
});
});
describe('seedValueDeep and toJSON roundtrip', () => {
it('should roundtrip simple objects', () => {
const original = { name: 'test', value: 42 };
const seeded = seedValueDeep(doc, original);
// Attach to document before roundtrip
const root = doc.getMap('roundtrip');
root.set('data', seeded as CRDTMap);
const attached = root.get('data') as CRDTMap;
const result = toJSON(attached);
expect(result).toEqual(original);
});
it('should roundtrip nested structures', () => {
const original = {
workflow: {
name: 'My Workflow',
nodes: [
{ id: 'node1', type: 'trigger' },
{ id: 'node2', type: 'action' },
],
settings: {
timezone: 'UTC',
saveExecutionProgress: true,
},
},
};
const seeded = seedValueDeep(doc, original);
// Attach to document before roundtrip
const root = doc.getMap('roundtrip');
root.set('data', seeded as CRDTMap);
const attached = root.get('data') as CRDTMap;
const result = toJSON(attached);
expect(result).toEqual(original);
});
it('should roundtrip arrays with mixed types', () => {
const original = ['string', 42, true, null, { nested: 'object' }];
const seeded = seedValueDeep(doc, original);
// Attach to document before roundtrip
const root = doc.getMap('roundtrip');
root.set('data', seeded as CRDTArray);
const attached = root.get('data') as CRDTArray;
const result = toJSON(attached);
expect(result).toEqual(original);
});
});
describe('getNestedValue', () => {
it('should get a value at a shallow path', () => {
const root = doc.getMap<string>('test');
root.set('name', 'Alice');
expect(getNestedValue(root, ['name'])).toBe('Alice');
});
it('should get a value at a deep path', () => {
const root = doc.getMap('test');
const seeded = seedValueDeep(doc, {
user: {
profile: {
name: 'Bob',
},
},
});
root.set('data', seeded as CRDTMap);
const data = root.get('data') as CRDTMap;
expect(getNestedValue(data, ['user', 'profile', 'name'])).toBe('Bob');
});
it('should return undefined for non-existent path', () => {
const root = doc.getMap<string>('test');
root.set('name', 'Alice');
expect(getNestedValue(root, ['nonexistent'])).toBeUndefined();
expect(getNestedValue(root, ['name', 'nested'])).toBeUndefined();
});
it('should handle empty path', () => {
const root = doc.getMap<string>('test');
root.set('name', 'Alice');
expect(getNestedValue(root, [])).toBe(root);
});
it('should work with plain objects', () => {
const obj = { user: { name: 'Charlie' } };
expect(getNestedValue(obj, ['user', 'name'])).toBe('Charlie');
});
it('should work with plain arrays', () => {
const obj = { items: ['a', 'b', 'c'] };
expect(getNestedValue(obj, ['items', '1'])).toBe('b');
});
it('should return undefined for null/undefined in path', () => {
const root = doc.getMap('test');
root.set('nullValue', null);
expect(getNestedValue(root, ['nullValue', 'nested'])).toBeUndefined();
});
it('should handle CRDTArray indices', () => {
const root = doc.getMap('test');
const items = doc.createArray<string>();
items.push('first', 'second', 'third');
root.set('items', items);
expect(getNestedValue(root, ['items', '1'])).toBe('second');
});
});
describe('setNestedValue', () => {
it('should set a value at a shallow path', () => {
const root = doc.getMap('test');
setNestedValue(doc, root, ['name'], 'Alice');
expect(root.get('name')).toBe('Alice');
});
it('should set a value at a deep path, creating intermediate maps', () => {
const root = doc.getMap('test');
setNestedValue(doc, root, ['user', 'profile', 'name'], 'Bob');
const user = root.get('user') as CRDTMap;
expect(user).toBeDefined();
const profile = user.get('profile') as CRDTMap;
expect(profile).toBeDefined();
expect(profile.get('name')).toBe('Bob');
});
it('should overwrite existing values', () => {
const root = doc.getMap('test');
root.set('name', 'Old');
setNestedValue(doc, root, ['name'], 'New');
expect(root.get('name')).toBe('New');
});
it('should deep-seed objects when setting', () => {
const root = doc.getMap('test');
setNestedValue(doc, root, ['config'], { theme: 'dark', fontSize: 14 });
const config = root.get('config') as CRDTMap;
expect(config.get('theme')).toBe('dark');
expect(config.get('fontSize')).toBe(14);
});
it('should deep-seed arrays when setting', () => {
const root = doc.getMap('test');
setNestedValue(doc, root, ['items'], [1, 2, 3]);
const items = root.get('items') as CRDTArray<number>;
expect(items.length).toBe(3);
expect(items.get(0)).toBe(1);
expect(items.get(1)).toBe(2);
expect(items.get(2)).toBe(3);
});
it('should do nothing for empty path', () => {
const root = doc.getMap('test');
setNestedValue(doc, root, [], 'value');
expect([...root.keys()].length).toBe(0);
});
it('should navigate through existing maps', () => {
const root = doc.getMap('test');
const user = doc.createMap<string>();
user.set('existing', 'value');
root.set('user', user);
setNestedValue(doc, root, ['user', 'name'], 'Alice');
const updatedUser = root.get('user') as CRDTMap<string>;
expect(updatedUser.get('existing')).toBe('value');
expect(updatedUser.get('name')).toBe('Alice');
});
it('should stop if path encounters a non-map value', () => {
const root = doc.getMap('test');
root.set('primitive', 'string');
// This should silently fail since 'primitive' is not a map
setNestedValue(doc, root, ['primitive', 'nested'], 'value');
// The primitive should remain unchanged
expect(root.get('primitive')).toBe('string');
});
});
describe('getNestedValue and setNestedValue roundtrip', () => {
it('should get what was set', () => {
const root = doc.getMap('test');
setNestedValue(doc, root, ['a', 'b', 'c'], 'deep value');
expect(getNestedValue(root, ['a', 'b', 'c'])).toBe('deep value');
});
it('should work with complex nested structures', () => {
const root = doc.getMap('test');
setNestedValue(doc, root, ['workflow', 'nodes', 'node1'], {
id: 'node1',
type: 'trigger',
params: { interval: 60 },
});
const node = getNestedValue(root, ['workflow', 'nodes', 'node1']) as CRDTMap;
expect(toJSON(node)).toEqual({
id: 'node1',
type: 'trigger',
params: { interval: 60 },
});
});
});
});
+149
View File
@@ -0,0 +1,149 @@
import type { CRDTArray, CRDTDoc, CRDTMap } from './types';
/**
* Recursively convert a plain JavaScript value to deep CRDT structures.
* - Objects become CRDTMap with nested values also converted
* - Arrays become CRDTArray with nested values also converted
* - Primitives (string, number, boolean, null) are stored as-is
*
* This enables fine-grained conflict resolution for concurrent edits
* to different fields within the same object or array.
*
* @param doc - The CRDT document to create nested structures in
* @param value - The plain JavaScript value to convert
* @returns The converted value (CRDTMap, CRDTArray, or primitive)
*
* @example
* ```ts
* const params = { operation: 'update', fields: [{ name: 'field1' }] };
* const crdtParams = seedValueDeep(doc, params);
* // crdtParams is now a CRDTMap with nested CRDTArray for 'fields'
* ```
*/
export function seedValueDeep(doc: CRDTDoc, value: unknown): unknown {
if (Array.isArray(value)) {
const arr: CRDTArray<unknown> = doc.createArray();
for (const item of value) {
arr.push(seedValueDeep(doc, item));
}
return arr;
}
if (value !== null && typeof value === 'object') {
const map: CRDTMap<unknown> = doc.createMap();
for (const [k, v] of Object.entries(value)) {
map.set(k, seedValueDeep(doc, v));
}
return map;
}
// Primitives (string, number, boolean, null, undefined) stored as-is
return value;
}
/**
* Convert any CRDT value (CRDTMap, CRDTArray, or primitive) to plain JSON.
* Handles nested structures recursively.
*
* @param value - The CRDT value to convert
* @returns Plain JavaScript value
*/
export function toJSON(value: unknown): unknown {
if (value === null || value === undefined) {
return value;
}
// Check if it's a CRDTMap or CRDTArray (has toJSON method)
if (typeof value === 'object' && 'toJSON' in value && typeof value.toJSON === 'function') {
return (value as { toJSON: () => unknown }).toJSON();
}
return value;
}
/**
* Get a nested value from a CRDT structure by path.
*
* @param root - The root CRDTMap or plain object
* @param path - Array of keys to traverse (e.g., ['assignments', 'assignments', '0', 'name'])
* @returns The value at the path, or undefined if not found
*/
export function getNestedValue(
root: CRDTMap<unknown> | Record<string, unknown>,
path: string[],
): unknown {
let current: unknown = root;
for (const key of path) {
if (current === null || current === undefined) {
return undefined;
}
if (typeof current === 'object' && 'get' in current && typeof current.get === 'function') {
// CRDTMap - use get method
current = (current as CRDTMap<unknown>).get(key);
} else if (Array.isArray(current)) {
// Regular array - use index
const index = parseInt(key, 10);
current = isNaN(index) ? undefined : current[index];
} else if (typeof current === 'object') {
// Plain object - use property access
current = (current as Record<string, unknown>)[key];
} else {
return undefined;
}
}
return current;
}
/**
* Set a nested value in a CRDT structure by path.
* Creates intermediate CRDTMaps/CRDTArrays as needed.
*
* @param doc - The CRDT document for creating nested structures
* @param root - The root CRDTMap to modify
* @param path - Array of keys to traverse
* @param value - The value to set (will be deep-seeded if object/array)
*/
export function setNestedValue(
doc: CRDTDoc,
root: CRDTMap<unknown>,
path: string[],
value: unknown,
): void {
if (path.length === 0) {
return;
}
let current: CRDTMap<unknown> = root;
// Navigate to parent of target
for (let i = 0; i < path.length - 1; i++) {
const key = path[i];
let next: unknown = current.get(key);
if (next === null || next === undefined) {
// Create intermediate CRDTMap
const newMap = doc.createMap();
current.set(key, newMap);
next = newMap;
}
if (
typeof next === 'object' &&
next !== null &&
'get' in next &&
typeof (next as CRDTMap<unknown>).get === 'function'
) {
current = next as CRDTMap<unknown>;
} else {
// Path doesn't exist or isn't a map, can't continue
return;
}
}
// Set the final value (deep-seed if it's an object/array)
const finalKey = path[path.length - 1];
current.set(finalKey, seedValueDeep(doc, value));
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/__tests__/**"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"compilerOptions": {
"rootDir": ".",
"types": ["node", "vitest/globals"],
"baseUrl": "src",
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
+3
View File
@@ -0,0 +1,3 @@
import { createVitestConfig } from '@n8n/vitest-config/node';
export default createVitestConfig();