Skip to content

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.

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.

_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.

Every pull response carries a cookie string the client sends back on the next pull. It looks like:

c1:eyJhIjoiMTIzIiwiYiI6IjQ1NiJ9

The 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.

Two reasons:

  1. 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.
  2. Server ambiguity. A raw driver write on the server might land at a row_version the 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.

Two more tables exist when you use file() columns:

  • _plasma_blobs — one row per uploaded hash. state is one of absent / 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 by gcOrphanedBlobs().

The Files and blobs guide covers both.

  • Push, pull, rebase — how each of these structures gets read and written
  • Multi-region — how the cookie structure changes when SequencerDO is involved