Skip to content

Custom sync strategies

The SyncStrategy interface abstracts how the editor loads, persists, and synchronizes documents. No implementation ships with the editor — you provide one for your backend. The interface is deliberately transport-agnostic, so a strategy can be built over a step authority, a CRDT, a plain autosave endpoint, or an offline-first store.

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;

  /**
   * Optional hook to contribute additional ProseMirror plugins needed for
   * synchronization (a CRDT binding typically supplies its own here). Invoked with
   * the built schema during `Editor.create()`; returned plugins are added
   * FIRST, ahead of host-supplied `pmPlugins` and feature plugins.
   */
  getPlugins?(schema: Schema): Plugin[];

  /**
   * Optional. Called once during `Editor.create()`, before the document is
   * loaded, with `{ docId, partId?, clientID? }`. The only hook that runs on
   * every path, including the one where an `initialDoc` is supplied and
   * `load()` is skipped.
   */
  bind?(context: SyncBindContext): 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>;
      clientID?: string | number; clientIds?: Array<string | number> }
  | { 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. clientID/clientIds are only populated when step-based concurrency is active — see below.

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();
  }

  // ...
}

Step-based concurrency (prosemirror-collab)

By default, external updates flow through updateFromSync()/applyOps() as snapshot replacements — reconciled into the live view as a minimal diff transaction, but still a full document swap from the sync strategy's point of view. For a strategy that speaks a central-authority step protocol (a server that accepts steps at a version and broadcasts confirmed steps back — the prosemirror-collab model), there's a second, opt-in path where remote steps become first-class transactions instead: local edits are rebased, not discarded, and the local undo stack survives every external update.

Activate it by passing a stable clientID to Editor.create():

const editor = await Editor.create({
  docId: 'doc-123',
  sync: myStepAuthorityStrategy,
  clientID: 'stable-per-tab-id', // persist this across reloads of the same client
});

Setting clientID adds a prosemirror-collab plugin to the editor — unless the strategy's getPlugins(schema) already supplied a Yjs binding, which brings its own conflict resolution. That is the one case the editor detects, and it detects it narrowly: a plugin registered under Yjs's y-sync plugin key (ySyncPlugin) among the plugins the editor was assembled from. Any other conflict-resolution plugin you supply through getPlugins is not recognised, and collab() is added alongside it — two conflict-resolution models on one document, which is not a supported combination. Pick one.

Activating collab enables:

  • Editor.receiveSteps(steps, clientIds) — apply steps received from the authority. steps are Step.toJSON()-serialized ProseMirror steps; one clientId per step identifies who authored it. Internally this calls prosemirror-collab's receiveTransaction() and dispatches through the normal transaction path — never a snapshot rebuild — so plugin state (undo history, decorations, this client's own unconfirmed edits) is preserved and correctly rebased rather than wiped. Requires a mounted editor — see When you may start delivering steps.
  • Editor.applyExternalOps(ops) — routes ops shaped { type: 'pm-transaction', steps, clientIds } to receiveSteps(). A strategy's applyExternal(ops, meta) typically needs a reference to its Editor instance (for example, via a strategy-specific attachEditor() hook, since the strategy is constructed before the editor exists) to call this.
  • Editor.getClientID() — the clientID this editor was configured with, or undefined if none was passed. It reports configuration, not whether collab is active: in the Yjs case above a clientID was configured but collab() was never added, so getClientID() returns the id while receiveSteps() throws.
  • Outbound accounting — once collab is active, Editor.getVersion() reflects the authority's confirmed version (it only advances when receiveSteps() applies a confirmation), not an optimistic local counter. Local edits flowing to sync.applyLocal() carry the full unconfirmed buffer (sendableSteps()) plus a clientID, so a strategy can resend after a rebase without hand-tracking which steps are still outstanding.
  • Editor.reload() — rebuilds the mounted state from the current snapshot/version via EditorState.create, re-initializing every plugin (including collab's version baseline). This is the explicit escape hatch for a strategy that detects a gap in its inbound step stream (a dropped/reordered delivery): refetch a fresh snapshot via updateFromSync(), then call reload() to re-baseline. It discards any unconfirmed local edits — a caller that must not lose pending work should capture sendableSteps(view.state) before calling it and decide how to rebase/reapply/report loss afterward.

Strategies that never set clientID are completely unaffected — this is purely additive. updateFromSync()/applyOps() remain the right calls for any snapshot-shaped external update; use receiveSteps()/applyExternalOps() only for content arriving through the step-authority protocol itself.

When you may start delivering steps

The order is Editor.create()editor.mount(element) → deliver steps. There is no view to rebase into before mount(), so receiveSteps() (and applyExternalOps(), which routes to it) throws when the editor is not mounted yet. Delivering early is a bug in the transport, not something the editor can absorb: the steps would be lost and the client would sit behind the authority with no signal that anything was missed.

A transport that connects before the host mounts — an SSE tail opened in load(), say — must therefore buffer its inbound steps and flush them once the editor is mounted. Give the strategy an explicit attach point the host calls after mount():

const editor = await Editor.create({ docId, sync: strategy, clientID });
editor.mount(element);
strategy.attachEditor(editor); // only now may buffered steps be delivered

A read-only editor is not an exception: remote steps keep applying to a read-only collaborator, because receiveSteps() marks its transaction as external content rather than a local edit. See Read-Only Mode.

What a step authority must do

@sciflow/editor-core implements the client side of this protocol; the server side is yours to build. These four requirements are not implementation choices — get any one of them wrong and the failure is silent, not a thrown error at build time.

  • One clientId per step, on every path that returns steps — the broadcast tail and the 409 conflict body. receiveTransaction uses them to tell a confirmation of this client's own steps apart from a foreign edit. This is a MUST, not an optional field: sending the wrong ids makes the client rebase its unconfirmed buffer over its own confirmed steps — a silent double-apply that corrupts the document with no error; omitting the array entirely makes the client throw on clientIDs.length.
  • Compare-and-append on a version, never a merge. Accept a submission iff its base version equals the current version; otherwise reject it with the missing steps and their client ids so the client can rebase and resend. The server never transforms steps itself.
  • A monotonic cursor over the event stream, so a client that missed frames can catch up by reading forward rather than refetching the whole document.
  • A dedup key the client mints — one per submission, unique within its document — enforced server-side, so an at-least-once retry replays the already-recorded outcome instead of applying the same steps twice.

Known sync gaps to design for

These are not limitations of any one implementation — they are declared divergence classes: the behavior a channel gives you when two writers change it concurrently, which the SyncStrategy interface does not upgrade for you. Every strategy has to design for the class its channel actually has, not the class it wishes it had.

Channel Class Behavior you get, and must design for
files 3 — not merge-shaped Id-keyed upsert; concurrent edits silently overwrite. Last-write-wins, declared.
references 3 on this channel Same divergence behavior. (A server's own metadata channel may implement class 2 — precondition-gated patches — internally, but the snapshot the editor receives carries no conflict detection.)
selection / cursors transient, never durable Remote cursors are a presence concern on a separate channel; nothing is restored on reload.

An undeclared class defaults to class 1

A channel that never states its class gets built as class 1 — automatic merge — because that is what the content channel demonstrates. Class-1 machinery layered onto a class-3 channel (files, references) destroys data quietly: the merge logic runs, produces a result, and nobody sees that the result silently dropped one side's change.

Design for the class you have

When building a custom sync strategy, decide upfront whether your implementation raises files and references to a higher class (for example, precondition-gated patches) or leaves them class 3. If you raise them, do it explicitly — include them in the pm-transaction operations or add dedicated sync channels with their own conflict handling.

A Yjs adapter used to ship here

@sciflow/sync-yjs was removed on 2026-08-07. It was never published to npm and nothing imported it. The reasoning: a CRDT cannot reject a change before it merges, and that is a hard requirement where an auditable, attributable history is the point. clientID above — the prosemirror-collab step-authority path — is the model @sciflow/editor-core supports for collaborative editing, not SciFlow's answer for every editing surface.

No authority client is published. @sciflow/editor-core gives you the editor half of the protocol; the server, the transport, and the reconnect behavior are yours to build. The What a step authority must do section above states the contract it must meet.

The same setup is reachable without calling Editor.create() directly — set sync, docId, partId, and clientID on <sciflow-editor>; see Binding the Element to Your Own Sync Strategy.