Skip to content

Push, Pull, Rebase

plasma.start() opens a sync loop that runs three phases on a repeating schedule. Each phase is an HTTP call to your Worker plus some IDB work. This page walks through each one so a stack trace or onError payload isn’t magic.

Trigger: a mutation just landed in the outbox, or the online handler fired.

The browser gathers every outbox entry whose blob deps (if any) are uploaded, packages them into a PushRequest, and POSTs to /sync/push.

POST /sync/push
{
"protocolVersion": 1,
"schemaVersion": "todos-v1",
"clientGroupID": "user-42",
"clientID": "aaaa-bbbb",
"mutations": [
{ "id": 12, "name": "markDone", "args": { "id": "t1" }, "timestamp": 1730100000000 }
]
}

The Worker:

  1. Runs auth() and rejects with 401 if it returns { ok: false }.
  2. For each mutation, dedup-checks (clientGroupID, clientID, id) against _plasma_client_mutations. Already-processed entries are skipped.
  3. Runs the mutator canonically against a D1 transaction. Each db.insert(...) / db.update(...) / db.delete(...) writes to the user table.
  4. AFTER-write triggers plasma installed on migration copy the write into _plasma_changes with a monotonic row_version.
  5. On mutator throw, the transaction rolls back but _plasma_client_mutations.last_mutation_id still advances so the client’s outbox drops the poison mutation.

The push response itself is minimal:

{ "ok": true }

Success or failure of individual mutations isn’t reported inline — plasma treats push as fire-and-forget. The server:

  • On mutator success: writes land in the change log, and the next pull carries them back plus a bumped lastMutationIDs entry for this client.
  • On mutator throw: the transaction rolls back, but _plasma_client_mutations.last_mutation_id still advances (so the poison mutation isn’t retried forever). No change log rows are produced. The client learns about the drop on the next pull, when lastMutationIDs says “we’ve seen up to N” but no matching row appeared in the patch — dropConfirmed removes the outbox entry, rebuildOptimistic reruns the surviving outbox, and the failed mutation’s optimistic effect vanishes.

That’s why there’s no mutation-error kind in SyncClientError — the client only sees push HTTP failures directly (push-http / network). Mutator throws surface as rows silently reverting on the next rebase. Design the UX for that reversion; a pre-check in the caller usually beats a post-hoc toast.

Trigger: a poll timer (default 5s), a WebSocket poke, an online event, or a manual client.pullOnce().

The browser sends its current pull cookie (c1: prefix + base64 JSON of per-region cursors) as a ?cookie=… query param on /sync/pull.

GET /sync/pull?protocolVersion=1&schemaVersion=todos-v1
&clientGroupID=user-42&clientID=aaaa-bbbb
&cookie=c1:eyJ...

The Worker:

  1. Parses the cookie, computes the “since” watermark.
  2. SELECT from _plasma_changes where row_version > watermark, filtered by each row’s auth.read(ctx, row).
  3. Returns the change list + the new cookie + lastMutationIDs (per-clientID watermarks so the browser knows which of its outbox entries the server has already processed).
{
"cookie": "c1:eyJhIjoiMTIzIn0=",
"patch": [
{ "kind": "put", "table": "todos", "key": "t1", "value": { "id": "t1", "done": 1 } }
],
"lastMutationIDs": { "aaaa-bbbb": 12 },
"hasMore": false
}

If hasMore: true, the browser follows up with another pull carrying the new cookie until it drains.

After a pull, the browser holds:

  • The base store — needs to update with the new patch
  • The outbox — needs its confirmed entries dropped
  • The user-visible store — needs to reflect (base + surviving outbox), in the right order

The rebase runs rebuildOptimistic:

  1. applyPatchToBase(patch) — write every put / del into the <table>_base object store.
  2. dropConfirmed(clientID, lastMutationIDs[clientID]) — remove outbox entries the server confirmed.
  3. Pause the reactive hub. Suspend live-query notifications so subscribers don’t see intermediate states.
  4. For each user table:
    • Clear the user-visible store.
    • Copy every row from the base store into it.
  5. Replay each surviving outbox entry — same mutator, same args, same optimistic engine as the original mutate() call.
  6. Resume the reactive hub. Every table touched by the rebase fires exactly one notification. Live queries recompute their window.

Every phase’s error goes through the same SyncClientError union that flows to your onError handler. The complete kind list:

Kind Meaning Recovery
push-http Push returned non-2xx after all retries plasma keeps retrying; you get one event per exhaust
pull-http Pull returned non-2xx after all retries Same
network fetch threw (offline, DNS, TLS) plasma keeps retrying with backoff
rebase-replay Optimistic replay of a queued mutator threw during rebuildOptimistic Swallowed; the next push carries the mutation and the server-side failure produces a silent revert
schema-mismatch Server sent 409 with expected schemaVersion onSchemaMismatch({ phase }) → "reset" | "stay"
blob-upload-failed R2 PUT retries exhausted client.retryBlobUpload(mutationID) or discardMutation(mutationID)

Server-side mutator throws (auth denied, validation) do not fire a SyncClientError — see the previous section. Watch for the row reverting on the next rebase.