Skip to content

Add inbound sync API#138

Draft
marcus-pousette wants to merge 20 commits into
cybersemics:sync/app-facing-controllerfrom
marcus-pousette:sync/scoped-live-controller
Draft

Add inbound sync API#138
marcus-pousette wants to merge 20 commits into
cybersemics:sync/app-facing-controllerfrom
marcus-pousette:sync/scoped-live-controller

Conversation

@marcus-pousette

@marcus-pousette marcus-pousette commented May 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the inbound companion to #137: createInboundSync({ localPeer }) manages one-shot reconciliation and live subscriptions across current and future peer transports.

Scope of this PR: replace app-owned inbound sync fanout and live subscription bookkeeping. It does not replace transport discovery, websocket status UI, auth setup, or outbound local-op upload. Outbound upload is handled by #137.

EM Integration Before

Source reference: the current Antonov548/em fork has its TreeCRDT websocket sync entrypoint in treecrdtWebSocketSync.ts, with remote materialization wired back into Redux from initialize.ts.

The current fork uses one high-level websocket handle. If that integration needs scoped or multi-peer inbound sync, the sync module would otherwise have to grow peer transport maps, subscription maps, in-flight guards, and manual sync fanout like this:

const peers = new Map<string, DuplexTransport<SyncMessage<Operation>>>();
const subscriptions = new Map<string, Map<string, SyncSubscription>>();
const startingSubscriptions = new Set<string>();

function addPeer(peerId: string, transport: DuplexTransport<SyncMessage<Operation>>) {
  peers.set(peerId, transport);
  for (const filter of currentSubscriptionFilters()) startSubscription(peerId, filter);
}

function startSubscription(peerId: string, filter: Filter) {
  const peer = treecrdtPeer;
  const transport = peers.get(peerId);
  if (!peer || !transport) return;

  const key = `${peerId}:${filterKey(filter)}`;
  if (startingSubscriptions.has(key)) return;
  startingSubscriptions.add(key);

  const sub = peer.subscribe(transport, filter, {
    immediate: true,
    intervalMs: 0,
    maxOpsPerBatch: TREECRDT_SYNC_MAX_OPS_PER_BATCH,
  });

  const byFilter = subscriptions.get(peerId) ?? new Map();
  byFilter.set(filterKey(filter), sub);
  subscriptions.set(peerId, byFilter);

  void sub.ready.finally(() => startingSubscriptions.delete(key));
  void sub.done.catch((err) => {
    byFilter.delete(filterKey(filter));
    reportTreecrdtSyncError(err);
  });
}

export async function syncTreecrdt(filters: readonly Filter[]): Promise<void> {
  for (const [peerId, transport] of peers) {
    for (const filter of filters) {
      await treecrdtPeer.syncOnce(transport, filter, {
        maxOpsPerBatch: TREECRDT_SYNC_MAX_OPS_PER_BATCH,
      });
    }
  }
}

That makes the app sync module responsible for subscription lifecycle, filter diffing, ready/done errors, peer cleanup, and per-peer syncOnce loops.

EM Integration After

With #138, an em-style sync module can keep the same high-level decisions in app code while moving the inbound mechanics into createInboundSync.

let inbound: InboundSync<Operation> | null = null;

function startTreecrdtSync(peer: SyncPeer<Operation>) {
  inbound = createInboundSync({
    localPeer: peer,
    syncOptions: () => ({ maxOpsPerBatch: TREECRDT_SYNC_MAX_OPS_PER_BATCH }),
    subscribeOptions: () => ({
      immediate: true,
      intervalMs: 0,
      maxOpsPerBatch: TREECRDT_SYNC_MAX_OPS_PER_BATCH,
    }),
    onError: ({ error }) => reportTreecrdtSyncError(error),
  });
}

function addPeer(peerId: string, transport: DuplexTransport<SyncMessage<Operation>>) {
  inbound?.addPeer(peerId, transport);
  inbound?.subscribe(currentSubscriptionFilters());
}

export async function syncTreecrdt(filters: Filter | readonly Filter[]): Promise<void> {
  await inbound?.syncOnce(filters, {
    syncTimeoutMs: TREECRDT_REMOTE_SYNC_TIMEOUT_MS,
  });
}

export function subscribeTreecrdt(filters: Filter | readonly Filter[]): void {
  inbound?.subscribe(filters);
}

function removePeer(peerId: string): void {
  inbound?.removePeer(peerId);
}

The app still chooses peers and filters. The helper owns the repeated transport fanout, subscription maps, in-flight guards, cleanup, and error reporting surface.

Stacked on #137.

@marcus-pousette marcus-pousette force-pushed the sync/scoped-live-controller branch from 664668c to 7e98f4b Compare May 5, 2026 08:48
@marcus-pousette marcus-pousette changed the title Add scoped live sync controller Add inbound sync API May 5, 2026
@marcus-pousette marcus-pousette force-pushed the sync/scoped-live-controller branch from 7e98f4b to 7249913 Compare May 5, 2026 09:00
@marcus-pousette marcus-pousette force-pushed the sync/app-facing-controller branch from d7c5bb6 to edcaec2 Compare May 5, 2026 12:07
@marcus-pousette marcus-pousette force-pushed the sync/scoped-live-controller branch 2 times, most recently from 7709dfc to 2f4ba43 Compare May 5, 2026 12:18
@marcus-pousette marcus-pousette force-pushed the sync/scoped-live-controller branch from 2f4ba43 to 3470733 Compare June 1, 2026 20:33
… sync/scoped-live-controller

# Conflicts:
#	examples/playground/src/playground/hooks/usePlaygroundSync.ts
… sync/scoped-live-controller

# Conflicts:
#	examples/playground/src/playground/hooks/usePlaygroundSync.ts
… sync/scoped-live-controller

# Conflicts:
#	examples/playground/src/playground/hooks/usePlaygroundSync.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant