Sync / foldkit-sync
Run it in the browser
Three steps: open storage and a replica, mount the application over it, start the exchange loop.
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 yourviewandupdate. A durable Message applies throughupdateat once, then a Command persists it; if the persist fails, the edit is reverted andonPersistenceFailuresays so in the Model. A local Message is untouched.replica.startis 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 throughstatus.mounted.dispatchsends a Message the way a view does. The container must have anid, ormountthrows.
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.