The guide
The store
Records by type and id, the commit, what a view observes, roots and collection, and the image on disk that renders the next launch.
The store is the UI's state. Every response becomes records keyed by type and id; records are observable objects; a view body that read a field is invalidated when that field of that record changes, and at no other time. This chapter follows a record from the bytes to the body, then to the disk and the next launch.
Records and keys#
A record is Type:id by default. baton.json configures identity for types keyed otherwise, by one field, by several, or for an interface whose implementers share a key; the compiler selects the key fields wherever the type is read, so a response always carries what the ingest needs. An object without a key gets a client id from its path, client:root:characters(page:1), as in Relay.
{
"schema": "schema.graphql",
"identity": {
"types": {
"Asset": ["uuid"],
"Quote": ["base", "quote"]
}
}
}A field is stored under its name, or under its name and its arguments when it has any: notes(first:2) and notes(after:"c2",first:2) are two slots of one record, and an argument whose value is null is left out, so the same page is the same slot whether or not the document mentioned after. The compiler computes every storage key without a variable in it and emits a constant, which the process numbers on first use; a key with variables is rendered once per owner and numbered by the store; a read hashes nothing.
The response
{
"data": {
"character": {
"name": "Agency Director",
"status": "Dead",
"species": "Human",
"image": "https://rickandmortyapi.com/api/character/avatar/9.jpeg",
"origin": {
"name": "Earth (Replacement Dimension)",
"id": "20"
},
"id": "9"
}
}
}The records
| id | "9" |
|---|---|
| image | "https://rickandmortyapi.com/api/character/avatar/9.jpeg" |
| name | "Agency Director" |
| origin | Location:20 |
| species | "Human" |
| status | "Dead" |
| id | "20" |
|---|---|
| name | "Earth (Replacement Dimension)" |
| character(id:"9") | Character:9 |
|---|
The commit#
A response's bytes are decoded straight into record slots, off the main actor, by the plan the compiler emitted, with no model tree in between: that is the ingest. What it produces is a change set, and the commit applies it on the main actor as one batch: a write of each slot that changed, nothing for a slot whose value is equal, and a notification for each observed field that changed, once, when the batch ends. A view never sees half a response.
A view attaches a handle for TestNotesQuery(id: "1") under storeOrNetwork, named notes; the transport answers its fetch with tests/notes-page-1.json.
tests/notes-page-1.json
{
"data": {
"character": {
"id": "1",
"name": "Rick Sanchez",
"notes": {
"totalCount": 5,
"edges": [
{
"cursor": "c1",
"node": {
"__typename": "Note",
"id": "n1",
"text": "Wubba lubba dub dub"
}
},
{
"cursor": "c2",
"node": {
"__typename": "Note",
"id": "n2",
"text": "Portal gun needs charging"
}
}
],
"pageInfo": {
"endCursor": "c2",
"hasNextPage": true
}
}
}
}
}Notified nothing: no observed field changed, so no body runs
The store after the step, tests/notes-page-1.store.json
| __TestNotes_notes_connection | Character:1:__TestNotes_notes_connection |
|---|---|
| id | "1" |
| name | "Rick Sanchez" |
| notes(first:2) | Character:1:notes(first:2) |
| edges | [ Character:1:notes(first:2):edges:0, Character:1:notes(first:2):edges:1 ] |
|---|---|
| pageInfo | Character:1:__TestNotes_notes_connection:pageInfo |
| totalCount | 5 |
| endCursor | "c2" |
|---|---|
| hasNextPage | true |
| edges | [ Character:1:notes(first:2):edges:0, Character:1:notes(first:2):edges:1 ] |
|---|---|
| pageInfo | Character:1:notes(first:2):pageInfo |
| totalCount | 5 |
| cursor | "c1" |
|---|---|
| node | Note:n1 |
| cursor | "c2" |
|---|---|
| node | Note:n2 |
| endCursor | "c2" |
|---|---|
| hasNextPage | true |
| id | "n1" |
|---|---|
| text | "Wubba lubba dub dub" |
| id | "n2" |
|---|---|
| text | "Portal gun needs charging" |
| character(id:"1") | Character:1 |
|---|
The server answers TestNotesQuery(id: "1") with tests/notes-page-1.json, and the environment commits it as a server batch.
tests/notes-page-1.json
{
"data": {
"character": {
"id": "1",
"name": "Rick Sanchez",
"notes": {
"totalCount": 5,
"edges": [
{
"cursor": "c1",
"node": {
"__typename": "Note",
"id": "n1",
"text": "Wubba lubba dub dub"
}
},
{
"cursor": "c2",
"node": {
"__typename": "Note",
"id": "n2",
"text": "Portal gun needs charging"
}
}
],
"pageInfo": {
"endCursor": "c2",
"hasNextPage": true
}
}
}
}
}Notified nothing: no observed field changed, so no body runs
The server answers TestNotesQuery(id: "1") with tests/notes-page-1-renamed.json, and the environment commits it as a server batch.
tests/notes-page-1-renamed.json
{
"data": {
"character": {
"id": "1",
"name": "Rick C-137",
"notes": {
"totalCount": 5,
"edges": [
{
"cursor": "c1",
"node": {
"__typename": "Note",
"id": "n1",
"text": "Wubba lubba dub dub"
}
},
{
"cursor": "c2",
"node": {
"__typename": "Note",
"id": "n2",
"text": "Portal gun needs charging"
}
}
],
"pageInfo": {
"endCursor": "c2",
"hasNextPage": true
}
}
}
}
}Notified Character:1.name
The server answers TestProfileQuery(id: "1") with tests/character-errors.json, and the environment commits it as a server batch.
tests/character-errors.json
{
"data": {
"character": {
"id": "1",
"name": "Rick Sanchez",
"origin": {
"id": "1",
"name": "Earth (C-137)"
},
"status": "Alive",
"image": null,
"location": {
"id": "20",
"name": null,
"dimension": "Replacement Dimension"
},
"gender": null,
"species": "Human",
"type": null
}
},
"errors": [
{
"message": "image service unavailable",
"path": [
"character",
"image"
],
"locations": [
{
"line": 1,
"column": 1
}
]
},
{
"message": "location name redacted",
"path": [
"character",
"location",
"name"
]
},
{
"message": "gender is private",
"path": [
"character",
"gender"
]
}
]
}Notified Character:1.gender Character:1.image Location:20.name
The server answers TestProfileQuery(id: "1") with tests/character-errors-answered.json, and the environment commits it as a server batch.
tests/character-errors-answered.json
{
"data": {
"character": {
"id": "1",
"name": "Rick Sanchez",
"origin": {
"id": "1",
"name": "Earth (C-137)"
},
"status": "Alive",
"image": null,
"location": {
"id": "20",
"name": null,
"dimension": "Replacement Dimension"
},
"gender": null,
"species": "Human",
"type": null
}
}
}Notified Character:1.gender Character:1.image Location:20.name
Three kinds of batch: a server batch, which is written to the image; an optimistic one, which keeps its undo log with its layer and is never written to the image; a local one, the runtime's own writes, a page's loading flag or a lookup's link bound, which never reaches the image.
What a view observes#
Each record carries the UI framework's registrar, Observation's in Swift and a snapshot-state channel per slot in Kotlin, and each lens accessor registers the read of one field of one record. So the framework knows exactly which bodies depend on which slots, and the commit tells it which slots changed. There are no subscriptions of Baton's own, no snapshots to diff, and no watcher that re-executes a query.
The store click a field to change it
| characters(page:1) |
|---|
| results |
|---|
| id | |
|---|---|
| image | |
| name | |
| species | |
| status |
| id | |
|---|---|
| image | |
| name | |
| species | |
| status |
| id | |
|---|---|
| image | |
| name | |
| species | |
| status |
| id | |
|---|---|
| image | |
| name | |
| species | |
| status |
0 of bodies re-ran
Nothing has changed yet.
A commit is ordinary main-actor state mutation, so a data-driven change animates like any other state change in the framework, with nothing of Baton's in the way.
Roots, retention, collection#
A record lives while a root reaches it. A root is an operation's selection and the record it starts from; a view holds a retention on its handle for its life, and when the last retention ends the root waits in a release buffer, ten by default, before its records become collectable, so going back to a screen just left is free. The store collects once per turn of the main thread after a root left or a commit dropped a link, marking from the roots, the optimistic layers and the image's write queue.
A view attaches a handle for TestHeaderQuery(id: "5") under storeOrNetwork, named jerry; the transport answers its fetch with tests/character-header-5.json.
tests/character-header-5.json
{
"data": {
"character": {
"name": "Jerry Smith",
"status": "Alive",
"species": "Human",
"image": "https://rickandmortyapi.com/api/character/avatar/5.jpeg",
"origin": {
"name": "Earth (Replacement Dimension)",
"id": "20"
},
"id": "5"
}
}
}Records held Character:1 Character:11 Character:5 Location:1 Location:20 client:root client:root:mutation client:root:subscription
The retention of einstein ends.
Records held Character:1 Character:11 Character:5 Location:1 Location:20 client:root client:root:mutation client:root:subscription
The retention of jerry ends.
Records held Character:1 Character:5 Location:1 Location:20 client:root client:root:mutation client:root:subscription
The retention of rick ends.
Records held Character:1 Location:1 client:root client:root:mutation client:root:mutation:rename client:root:subscription
The consequence for a scrolling list: memory plateaus, since the store releases what no view holds, and a page scrolled far out of view is a page that can go.
Ages and staleness#
The store stamps each root with the time of its last commit. A query's @cacheExpiration(seconds:), or the store's default, says how old its data may be before it reads as stale; environment.invalidate() marks everything stale and refetches what is retained; environment.revalidate() refetches what is stale or failed, for a return to the foreground or a connection regained. Stale data stays readable, and the age survives a launch, because the image keeps it.
The environment is invalidated: everything is marked stale and the retained operations refetch.
Phase [{"handle":"jerry","phase":"ready","isStale":true,"isRefreshing":true},{"handle":"fresh","phase":"ready","isStale":true,"isRefreshing":true}]
The fetch of jerry is answered with tests/character-header-5.json.
tests/character-header-5.json
{
"data": {
"character": {
"name": "Jerry Smith",
"status": "Alive",
"species": "Human",
"image": "https://rickandmortyapi.com/api/character/avatar/5.jpeg",
"origin": {
"name": "Earth (Replacement Dimension)",
"id": "20"
},
"id": "5"
}
}
}Phase {"handle":"jerry","phase":"ready","isStale":false}
The environment ends and a second store opens over the same image, as the next launch does.
The store's clock advances by 10 seconds.
A view attaches a handle for TestHeaderQuery(id: "5") under storeOrNetwork, named again; the fetch stays in flight.
Phase {"handle":"again","phase":"ready","isStale":false}
Fetch {"handle":"again","fetch":"idle"}
The store's clock advances by 55 seconds.
Phase {"handle":"again","phase":"ready","isStale":true}
The image#
Every server batch is written behind to an image, one SQLite file, off the main actor: a row per record, a row per root field, each operation's fetch time. When a handle is made, the availability check reads from the image whatever memory lacks, so an operation an earlier launch fetched is ready before the first body, and the screen the user left is the screen they get back.
Store(persistence: Persistence(name: "App", version: Types.schemaDigest))Store(persistence = Persistence.named("App", version = Types.schemaDigest, directory = cacheDir.path))The server answers TestQuotesQuery() with tests/quote-list.json, and the environment commits it as a server batch.
tests/quote-list.json
{
"data": {
"quotes": [
{
"rate": 1.5,
"base": "BTC",
"quote": "USD"
},
{
"rate": 0.9,
"base": "EUR",
"quote": "USD"
},
{
"rate": 2,
"base": "A:B",
"quote": "C\\D"
}
]
}
}The store after the step, tests/quote-list.store.json
| base | "A:B" |
|---|---|
| quote | "C\D" |
| rate | 2.0 |
| base | "BTC" |
|---|---|
| quote | "USD" |
| rate | 1.5 |
| base | "EUR" |
|---|---|
| quote | "USD" |
| rate | 0.9 |
| quotes | [ Quote:BTC:USD, Quote:EUR:USD, Quote:A\:B:C\\D ] |
|---|
The availability check runs for TestQuoteQuery(base: "BTC", quote: "USD").
The check answers memory
| A lens reads | and finds |
|---|---|
quote.rate | 1.5 |
The store after the step, scripts/check-bound.store.json
| base | "A:B" |
|---|---|
| quote | "C\D" |
| rate | 2.0 |
| base | "BTC" |
|---|---|
| quote | "USD" |
| rate | 1.5 |
| base | "EUR" |
|---|---|
| quote | "USD" |
| rate | 0.9 |
| quote(base:"BTC",quote:"USD") | Quote:BTC:USD |
|---|---|
| quotes | [ Quote:BTC:USD, Quote:EUR:USD, Quote:A\:B:C\\D ] |
The availability check runs for TestQuoteQuery(base: "BTC", quote: "EUR").
The check answers miss
| A lens reads | and finds |
|---|---|
quote.rate | null |
The environment ends and a second store opens over the same image, as the next launch does.
Records held client:root client:root:mutation client:root:subscription
The availability check runs for TestQuoteQuery(base: "BTC", quote: "USD").
The check answers image
| A lens reads | and finds |
|---|---|
quote.rate | 1.5 |
The availability check runs for TestQuoteQuery(base: "BTC", quote: "EUR").
The check answers miss
The image is a cache and nothing more. One written under another schema digest, another format or another identity configuration starts again rather than migrating; a record that goes a whole launch unread is dropped at the next; an image over its size limit evicts the rows of older launches first; optimistic layers never reach it. Types and root fields that must never touch the disk are named in baton.json under transient, and the lists join the schema's digest.
Client fields#
A field the server does not have, a pin or a draft, is described in a client schema extension named in baton.json and written by a payload committed by hand, environment.commitPayload(operation, payload), for an operation that selects it. It reads through a lens like any field, is left out of the text a server receives, and reaches the image like any record. There are no resolvers; a client field is data, described and committed.
extend type Character {
"""Whether the app pinned the character; the device's alone."""
isPinned: Boolean
}Looking inside#
StoreExport.text(of: environment.store), from BatonInspector, writes the store in the dump format every plate on this site uses: every record by key, sorted, one a line. A dump from a debug menu is a fixture a test can start from, and a diff of two dumps is a reviewable change to identity or layout. In Kotlin the same text is StoreExport.text(environment.store), from baton-inspector. Beside the export, StoreInspector is a live view of a store for a debug pane, a SwiftUI view in BatonInspector and a Compose one in baton-inspector.
Baton