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
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:
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import { nodeConfig } from '@n8n/eslint-config/node';
|
||||
|
||||
export default defineConfig(nodeConfig);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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__/**"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createVitestConfig } from '@n8n/vitest-config/node';
|
||||
|
||||
export default createVitestConfig();
|
||||
Reference in New Issue
Block a user