Skip to content

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.