Skip to content

4. State and changes

Every JMAP data type has a per-account state string that bumps on every mutation. Clients use it for two things:

  1. ifInState on <Type>/set — optimistic concurrency. If the server's state doesn't match, the call fails with stateMismatch and the client re-fetches before retrying.
  2. <Type>/changes — incremental sync. Pass the last state you saw, get back the lists of created/updated/destroyed ids since then.

Look at the state

After every successful mutation, the server bumps the counter for that (account_id, data_type) pair. The state string is just the counter cast to string.

curl -s -X POST http://localhost:8000/jmap -d '{
  "using": ["urn:ietf:params:jmap:core", "urn:example:todos"],
  "methodCalls": [["Todo/get", {"accountId": "ACC"}, "c0"]]
}' | jq '.methodResponses[0][1].state'

Returns something like "3". After you Todo/set to create a new one, it becomes "4".

Sync incrementally

A client that last synced at state "3" asks:

["Todo/changes", {"accountId": "ACC", "sinceState": "3"}, "c0"]

The response tells it what changed:

{
  "accountId": "ACC",
  "oldState": "3",
  "newState": "7",
  "hasMoreChanges": false,
  "created": ["<id>"],
  "updated": [],
  "destroyed": []
}

If hasMoreChanges is true, the client passes newState back to fetch the next page.

Optimistic concurrency

["Todo/set", {
  "accountId": "ACC",
  "ifInState": "3",
  "update": {"<id>": {"done": true}}
}, "c0"]

If the server is at state "3", the update applies. If it's at "4" (someone else mutated meanwhile), you get back:

["error", {"type": "stateMismatch", "description": "..."}, "c0"]

Refetch the object, re-apply your change against the new state, retry.

Behind the scenes

The framework writes one row to a shared data_changes table for each create / update / destroy. <Type>/changes queries data_changes WHERE data_type = '<Type>' to return only your capability's events. You don't write or maintain this table — it's framework-owned.

Next: blobs and inter-capability references →