TandemClient and its remote. The sync engine only runs when the client has a remote.["record", collection, ...idParts]. A scalar ID contributes one part. A compound ID contributes each of its parts in order, so a transaction can scan by ID prefix.commit turns a transaction into a mutation: an ID and a list of operations. The ID is a per-client counter: 1 for a client's first mutation, then one more for each commit.1234567type Mutation = { id: MutationId ops: ( | { type: "set"; collection: string; value: Record } | { type: "remove"; collection: string; id: Id } )[] }
set carries the whole record, not a field-level diff. A remove is recorded even when the client doesn't have the record. Operations carry no previous value, because the client never undoes a mutation. See Rebase.subscribe adds a query, and destroy removes it. Each pull sends the scan window, and the remote returns only records inside it.pullFromRemote() runs a pull with the current scan window.commit applies the mutation to the local database right away.syncInterval, 150 ms by default). A push sends pending mutations with the client ID:1await remote.push({ clientId, mutations })
commit promise rejects, but the mutation stays pending and goes out again with the next push or reconnect. While disconnected, the client skips pushes and sends pending mutations after connect.12345const { cookie, patch, lastMutationId } = await remote.pull({ clientId, cookie, scanWindow, })
patch lists records to set and to remove.cookie is an opaque marker the client sends back on its next pull.lastMutationId is the last mutation from this client that the remote has applied, on every pull. It is 0 until the remote applies one.connect gives the remote a poke function. Calling it makes the client pull. A poke with a payload lets that pull wait while payloads keep arriving: the client pulls once livePullDelay (200 ms by default) passes without a new payload, or livePullMaxDelay (1 s) after the first of them. Any pull that starts sooner confirms them too, such as one a bare poke, pullFromRemote, or a new subscription starts. The remote decides when to poke, for example over a WebSocket, SSE, or a polling timer. Missing a poke delays a pull rather than losing changes. connectTandemEventSource pulls on every stream (re)connection, and fallback pulls check for missed updates every 60 seconds by default.TandemServer sends them after every commit. The client shows them at once, in the same tick, and still pulls once the payloads go quiet. Queries and subscriptions need nothing special: a server that saves a record repeatedly, such as a streaming agent reply, shows each save on every subscribed client. Like a pull, a live record can make an open transaction that read the record fail to commit.lastMutationId, since the remote has applied them. This happens even when the patch is empty.1:{ text: "Buy milk", complete: true }.{ text: "Buy oat milk", complete: false }.complete: true is lost because B's set replaced the whole record. Until A's pull acknowledges its mutation, A keeps replaying its own value on top of each patch.clientStorage, the client loads from storage on startup (client.ready) and writes changes back in batches (clientStorageWriteInterval, 120 ms by default). flushClientStorage() writes immediately.clientStorage therefore elect a leader instead. In a browser document this happens automatically when the client has a remote.BroadcastChannel with direct commands, not a second sync engine.commit applies the edit to the tab's replica at once and sends the mutation to the leader. The leader commits it to its replica and records the tab's mutation ID in the same transaction, skipping a mutation it already holds. While the tab wants the remote, commit resolves when the remote has accepted the write, as in a single tab; while it does not, commit resolves once the leader has saved it. flushClientStorage resolves once the leader has saved the tab's writes and does not wait for the remote.commit rejects and the tab keeps the edit. It sends it again before its next commit, on flushClientStorage, on connect, and to the next leader.syncInterval and clientStorageWriteInterval waits and runs on the next task instead, so a background leader does not slow the tab in use.ready restores persisted data as a single tab does, and transactions still throw before it resolves.connect and disconnect change only this tab's wish, and autoConnect sets it initially. pullFromRemote, flushClientStorage, and clear act on the leader's replica. close() hands leadership on after saving. Pass tabs: false to give one client the storage to itself, or tabs: { environment } to coordinate somewhere other than a browser document, such as a worker or a test with InMemoryTabs from @tanishqkancharla/tandem-core/internal.RemoteApi implementation you can use directly