Custom Sync Strategies¶
The SyncStrategy interface abstracts how the editor loads, persists, and synchronizes documents. SciFlow ships a Yjs-based implementation (@sciflow/sync-yjs), but you can implement your own for custom backends, offline-first architectures, or non-CRDT collaboration.
The SyncStrategy Interface¶
interface SyncStrategy {
/** Load a document snapshot from storage. */
load(docId: string): Promise<SyncSnapshot>;
/** Apply changes coming from external sources (collaboration, server push). */
applyExternal(ops: Operation[], meta?: Record<string, unknown>): void;
/** Clean up resources (disconnect, unsubscribe). */
dispose(): void;
/** Flush pending changes to storage (optional). */
flush?(): Promise<void>;
/** Send local changes to the sync layer (optional). */
applyLocal?(ops: Operation[], meta?: Record<string, unknown>): void;
}
SyncSnapshot¶
The object returned by load():
interface SyncSnapshot {
doc: SciFlowDocJSON; // ProseMirror document as JSON
version?: number; // Document version for optimistic locking
selection?: SelectionJSON; // Optional saved cursor position
files?: SnapshotFile[]; // Attached files metadata
references?: SnapshotReference[]; // Bibliography entries
}
Operation¶
Operations passed to applyExternal() and applyLocal():
type Operation =
| { type: 'pm-transaction'; steps?: unknown[]; doc?: SciFlowDocJSON;
selection?: SelectionJSON; files?: SnapshotFile[];
references?: SnapshotReference[]; meta?: Record<string, unknown> }
| { type: 'replace_range'; from: number; to: number; text: string }
| { type: 'set_node_attrs'; path: Array<string | number>;
attrs: Record<string, unknown> }
| { type: string; [key: string]: unknown };
The most common operation type is pm-transaction, which carries ProseMirror steps or a full document snapshot.
Implementing a REST Sync Strategy¶
A minimal load/save strategy that talks to a REST API:
import type { SyncStrategy, SyncSnapshot, Operation } from '@sciflow/editor-core';
class RestSyncStrategy implements SyncStrategy {
private docId: string;
private baseUrl: string;
private pending: Operation[] = [];
private flushTimer?: ReturnType<typeof setTimeout>;
constructor(baseUrl: string, docId: string) {
this.baseUrl = baseUrl;
this.docId = docId;
}
async load(): Promise<SyncSnapshot> {
const res = await fetch(`${this.baseUrl}/documents/${this.docId}`);
if (!res.ok) throw new Error(`Failed to load: ${res.status}`);
return res.json();
}
applyExternal(ops: Operation[]): void {
// For a REST-only strategy, external changes are not expected.
// In a polling architecture, you would merge these into the editor.
}
applyLocal(ops: Operation[]): void {
this.pending.push(...ops);
// Debounce saves to avoid excessive requests
clearTimeout(this.flushTimer);
this.flushTimer = setTimeout(() => this.flush(), 1000);
}
async flush(): Promise<void> {
if (this.pending.length === 0) return;
const ops = this.pending.splice(0);
await fetch(`${this.baseUrl}/documents/${this.docId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operations: ops }),
});
}
dispose(): void {
clearTimeout(this.flushTimer);
// Flush remaining changes synchronously if needed
}
}
Offline-First Pattern¶
For applications that must work without a network connection, combine local persistence with a sync queue:
class OfflineSyncStrategy implements SyncStrategy {
async load(docId: string): Promise<SyncSnapshot> {
// Try local storage first
const cached = localStorage.getItem(`doc:${docId}`);
if (cached) return JSON.parse(cached);
// Fall back to server
const res = await fetch(`/api/documents/${docId}`);
const snapshot = await res.json();
localStorage.setItem(`doc:${docId}`, JSON.stringify(snapshot));
return snapshot;
}
applyLocal(ops: Operation[]): void {
// Always persist locally first
const current = JSON.parse(localStorage.getItem(`doc:${this.docId}`) || '{}');
// Apply ops to current snapshot...
localStorage.setItem(`doc:${this.docId}`, JSON.stringify(current));
// Queue for server sync when online
this.enqueueForSync(ops);
}
private enqueueForSync(ops: Operation[]): void {
const queue = JSON.parse(localStorage.getItem('sync-queue') || '[]');
queue.push({ docId: this.docId, ops, timestamp: Date.now() });
localStorage.setItem('sync-queue', JSON.stringify(queue));
if (navigator.onLine) this.drainQueue();
}
// ...
}
Using the Yjs Adapter¶
The built-in Yjs adapter handles real-time collaboration via CRDTs:
import { createYjsSyncStrategy } from '@sciflow/sync-yjs';
const strategy = createYjsSyncStrategy({
doc: yDoc, // Y.Doc instance
provider: myProvider // Object implementing YjsProvider
});
See the Yjs server implementation guide for WebSocket setup.
Known Sync Limitations¶
These limitations apply to the current Yjs adapter and should be considered when designing custom strategies:
| Limitation | Details |
|---|---|
| Files not synced | The files array in the snapshot is not part of the Yjs sync layer. File metadata must be synced separately. |
| References not synced | Bibliography references are not synchronized through the Yjs adapter. Host applications must manage reference sync independently. |
| No selection sync | Cursor positions are not persisted through the sync strategy. The Yjs adapter uses Yjs Awareness for remote cursor display, but saved cursor state is not restored on reload. |
| applySnapshot() is a no-op | In the Yjs adapter, y-prosemirror handles snapshot application automatically. |
Design for these gaps
When building a custom sync strategy, decide upfront whether your implementation will handle file and reference sync. If so, include them in the pm-transaction operations or add dedicated sync channels.