Change Log and Cookies
The sync loop moves state between the browser and the Worker using two data structures. Neither is exotic; both are worth understanding in detail because every problem that surfaces as a stall / missing row / weird pull ends up traceable to one of them.
The change log — _plasma_changes
Section titled “The change log — _plasma_changes”Every user-table write on the server produces exactly one row in
_plasma_changes:
CREATE TABLE _plasma_changes ( row_version BIGINT PRIMARY KEY AUTOINCREMENT, table_name TEXT NOT NULL, row_id TEXT NOT NULL, op TEXT NOT NULL, -- 'put' or 'del' value TEXT NULL, -- JSON of the full row for 'put', NULL for 'del' created_at BIGINT NOT NULL, -- unix ms client_group_id TEXT NULL, client_id TEXT NULL, mutation_id BIGINT NULL)The client_group_id / client_id / mutation_id columns identify
which client’s mutation produced the change (or NULL if a raw driver
write / cron job did). row_version is the pull cursor.
Tables declared changeLogSuppressed: true skip the triggers
entirely — they’re local-scratch tables the server tracks but never
propagates. See Offline mode.
Growth and compaction
Section titled “Growth and compaction”_plasma_changes grows with every write, not with every read.
An app with 10 writes per user per day and 10k users adds ~100k
rows per day. Left alone, this eventually gets slow.
compactChangeLog(safeUpToVersion) folds intermediate puts on the
same (table_name, row_id) down to just the latest, and removes
put+del pairs that cancel. Run it from a Worker cron.
The pull cookie
Section titled “The pull cookie”Every pull response carries a cookie string the client sends back
on the next pull. It looks like:
c1:eyJhIjoiMTIzIiwiYiI6IjQ1NiJ9The c1: prefix identifies the encoding version. The base64 decodes
to JSON keyed by region ID:
{ "0": "123", "1": "456" }For a single-region deployment (the common case) the object has one
key, "0", whose value is the highest row_version the client has
observed. The pull query becomes WHERE row_version > 123.
Multi-region deployments carry per-region cursors; each region’s
SequencerDO mints monotonic ids in its own space. The
Multi-region guide covers the topology.
Why not just a number?
Section titled “Why not just a number?”Two reasons:
- Forward compatibility. A future protocol version can put more structure into the cookie (region cursors, causal metadata for partial replication) without breaking the encoding contract.
- Server ambiguity. A raw driver write on the server might land
at a
row_versionthe pull loop can’t safely skip past. Wrapping the version in a cookie means the server can encode “this cookie is opaque, don’t try to +1 it yourself” as a contract.
_plasma_client_mutations — the de-dup ledger
Section titled “_plasma_client_mutations — the de-dup ledger”Sibling to the change log:
CREATE TABLE _plasma_client_mutations ( client_group_id TEXT NOT NULL, client_id TEXT NOT NULL, last_mutation_id INTEGER NOT NULL, PRIMARY KEY (client_group_id, client_id))Every push updates this row. A retried push with an already-observed mutation ID is a no-op — the client’s IDB may have wiped the outbox entry, but the server refuses to double-execute.
lastMutationIDs on the pull response is a projection of this table
filtered to the pulling client’s group, so the client knows exactly
which of its outbox entries have been confirmed.
The origin scratch table — _plasma_origin
Section titled “The origin scratch table — _plasma_origin”There’s a third table plasma uses internally: a single-row scratch
that carries the current mutation’s (clientGroupID, clientID, mutationID) down to the AFTER-write triggers. This is how the
client_group_id / client_id / mutation_id columns on
_plasma_changes get populated. It’s an implementation detail, but
shows up in _plasma_* table listings so know it’s there.
Blob-adjacent tables
Section titled “Blob-adjacent tables”Two more tables exist when you use file() columns:
_plasma_blobs— one row per uploaded hash.stateis one ofabsent/uploading/present/orphaned._plasma_blob_refs— reverse index:(hash, table_name, row_id, column_name). Used by the read-auth path (any row referencing this hash lets the caller download the blob if they can read that row) and bygcOrphanedBlobs().
The Files and blobs guide covers both.
What to read next
Section titled “What to read next”- Push, pull, rebase — how each of these structures gets read and written
- Multi-region — how the cookie structure
changes when
SequencerDOis involved