Sync / foldkit-sync

Run it in the browser

Three steps: open storage and a replica, mount the application over it, start the exchange loop.

ts
import { Effect, Scope } from 'effect'
import { ReplicaId, Sync } from 'foldkit-sync'

const container = document.getElementById('app')!

// One IndexedDB database per document and tab; its connection lives for the page.
const scope = Effect.runSync(Scope.make())
const storage = Effect.runSync(
  Effect.provideService(Sync.indexedDb('todos/tab-1'), Scope.Scope, scope),
)
const replica = Effect.runSync(TodoSync.openReplica(ReplicaId.make('tab-1'), storage))

const mounted = Sync.mount(App, TodoSync, {
  replica,
  container,
  view: (model, h) => ({
    title: 'Todos',
    body: h.ul([], model.todos.map(todo => h.li([], [todo.title]))),
  }),
  onPersistenceFailure: (model, error) =>
    modifyFields(model, { lastError: () => Option.some(error.message) }),
})

Effect.runFork(
  Effect.provide(replica.start, Sync.transport.socket({ url: 'wss://example.com/sync' })),
)

mounted.dispatch(Message.CreatedTodo({ id: crypto.randomUUID(), title: 'Milk' }))

What each call does, and does not do:

  • Sync.indexedDb(name) opens one database. Name it by document and writer: two tabs on one name race, and the loser fails its write rather than merging two histories.
  • openReplica(id, storage) reads back the committed snapshot, cursor, and outbox saved last time, or starts empty. It does no network I/O.
  • Sync.mount(App, TodoSync, …) runs the ordinary Foldkit runtime with your view and update. A durable Message applies through update at once, then a Command persists it; if the persist fails, the edit is reverted and onPersistenceFailure says so in the Model. A local Message is untouched.
  • replica.start is the exchange loop: once now, then after every submit and every notice from the server. A failed exchange is retried on a backoff from 0.5 s to 30 s, or at once on the next edit. The loop never fails; it reports through status.
  • mounted.dispatch sends a Message the way a view does. The container must have an id, or mount throws.

Reload the page: the todos come back from the outbox and the committed snapshot, the selection does not. That is the shared/local line, visible.

mounted also exposes model(), subscribe, observe, committed, settled(), and dispose(), which waits for in-flight persists and leaves the replica open. settled() resolves once the Model has caught up with the replica: every dispatched Message applied, no persist unanswered, and every replica status applied. It waits for no frame and no I/O, so a test pairs it with Frames.track from foldkit-mixins/testing: await frames.settle(mounted). Close the replica with its own scope.