The guide
The twelve words
Schema, document, directive, lens, record, store, plan, operation value, connection, environment, transport, phase: the closed vocabulary, one plate each.
Baton's vocabulary is closed. A feature is a composition of these twelve concepts, or a Relay directive the compiler understands, or it does not ship. That is the rule that keeps a screen one request, a view one fragment, and a change one re-render, and it is why the whole thing fits on a page. Each word below has its contract in the terminology, where the Swift and Kotlin spellings are, and one plate here to make it concrete.
Schema#
The server's menu: every type, every field, whether each can be null. A checked-in SDL file the compiler reads at build time, named in baton.json, with identity configured beside it. The runtime never introspects; what it knows of the schema it knows from the compiler. One schema family per process. In the terminology.
spec/rickandmorty/schema.graphql
type Query {
character(id: ID!): Character
characters(page: Int, filter: FilterCharacter): Characters
charactersByIds(ids: [ID!]!): [Character]
location(id: ID!): Location
locations(page: Int, filter: FilterLocation): Locations
locationsByIds(ids: [ID!]!): [Location]
episode(id: ID!): Episode
episodes(page: Int, filter: FilterEpisode): Episodes
episodesByIds(ids: [ID!]!): [Episode]
}
type Character {
id: ID
name: String
status: String
species: String
type: String
gender: String
origin: Location
location: Location
image: String
episode: [Episode]!
created: String
}episode: [Episode]! is a non-null list of nullable episodes, and the lens types follow that exactly.Document#
A query, a mutation, a subscription or a fragment, carried by a marker in source code or held in a .graphql file. A fragment is a view's needs with a name; an operation is assembled by the compiler from the fragments spread into it, one per screen, and compiles to a variables type, a root lens, a plan, its text and a persisted id. In the terminology.
IssueRow.swift
import Baton
import SwiftUI
struct IssueRow: View {
@Fragment("""
fragment IssueRow_issue on Issue {
number
title
author { login }
}
""")
var issue: IssueRow_issue
var body: some View {
VStack(alignment: .leading) {
Text("#\(issue.number) \(issue.title)")
Text(issue.author?.login ?? "ghost")
.foregroundStyle(.secondary)
}
}
}IssuesScreen.swift
import Baton
import SwiftUI
struct IssuesScreen: View {
@Query("""
query IssuesQuery($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
nameWithOwner
issues(first: 20, states: OPEN) {
nodes { id ...IssueRow_issue }
}
}
}
""")
var issues: IssuesQuery
var body: some View {
switch issues.phase {
case .ready(let data):
if let repo = data.repository {
List {
ForEach(repo.issues.nodes ?? .empty) {
IssueRow(issue: $0.issueRow)
}
}
.navigationTitle(repo.nameWithOwner)
}
case .loading:
ProgressView()
case .failed(let error):
ErrorView(error) { issues.retry() }
}
}
}IssueRow.kt
package app
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import baton.Fragment
@Fragment($$"""
fragment IssueRow_issue on Issue {
number
title
author { login }
}
""")
@Composable
fun IssueRow(issue: IssueRow_issue) {
Column {
Text("#${issue.number} ${issue.title}")
Text(issue.author?.login ?: "ghost")
}
}IssuesScreen.kt
package app
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import baton.Phase
import baton.Query
import baton.rememberQuery
@Query($$"""
query IssuesQuery($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
nameWithOwner
issues(first: 20, states: OPEN) {
nodes { id ...IssueRow_issue }
}
}
}
""")
@Composable
fun IssuesScreen(owner: String, name: String) {
val query = rememberQuery(
IssuesQuery(owner = owner, name = name),
)
when (val phase = query.phase) {
is Phase.Ready -> phase.data.repository?.let { repo ->
LazyColumn {
item { Text(repo.nameWithOwner) }
items(repo.issues.nodes.orEmpty()) {
IssueRow(it.issueRow)
}
}
}
Phase.Loading ->
CircularProgressIndicator()
is Phase.Failed -> ErrorView(phase.error) {
query.retry()
}
}
}Directive#
The only way behaviour attaches to data. The set is Relay's: @connection, @refetchable, @argumentDefinitions, @arguments, @alias, @required, @catch, @throwOnFieldError, @defer, @inline, @include, @skip, and the edge directives @appendEdge, @prependEdge, @appendNode, @prependNode, @deleteEdge and @deleteRecord; the one exception, which Relay has no word for, is @cacheExpiration(seconds:). Nothing is configured by an object at run time. In the terminology.
CommentComposer.swift
import Baton
import SwiftUI
struct CommentComposer: View {
let subjectID: String
let connectionID: String
@State private var text = ""
@Mutation("""
mutation CommentComposerAddComment($input: AddCommentInput!, $connections: [ID!]!) {
addComment(input: $input) {
commentEdge @appendEdge(connections: $connections) {
node { id bodyText author { login } }
}
}
}
""")
var addComment: CommentComposerAddComment.Action
var body: some View {
HStack {
TextField("Leave a comment", text: $text)
Button("Comment") {
let body = text
Task {
try await addComment(
input: AddCommentInput(body: body, subjectId: subjectID),
connections: [connectionID]
)
text = ""
}
}
.disabled(text.isEmpty || addComment.isInFlight)
}
}
}IssueComments.swift
import Baton
import SwiftUI
struct IssueComments: View {
@Fragment("""
fragment IssueComments_issue on Issue
@refetchable(queryName: "IssueCommentsPaginationQuery")
@argumentDefinitions(count: {type: "Int", defaultValue: 30}, cursor: {type: "String"}) {
id
comments(first: $count, after: $cursor) @connection(key: "IssueComments_comments") {
totalCount
edges { node { id bodyText author { login } } }
}
}
""")
var issue: IssueComments_issue
var body: some View {
Text("\(issue.comments.totalCount) comments")
ForEach(issue.comments.nodes) { comment in
Text("\(comment.author?.login ?? "ghost"): \(comment.bodyText)")
}
CommentComposer(subjectID: issue.id, connectionID: issue.comments.connectionID)
}
}CommentComposer.kt
package app
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import baton.Fragment
import baton.Mutation
import baton.rememberMutation
import kotlinx.coroutines.launch
@Mutation($$"""
mutation CommentComposerAddComment($input: AddCommentInput!, $connections: [ID!]!) {
addComment(input: $input) {
commentEdge @appendEdge(connections: $connections) {
node { id bodyText author { login } }
}
}
}
""")
@Composable
fun CommentComposer(subjectID: String, connectionID: String) {
val addComment = rememberMutation(CommentComposerAddComment)
val scope = rememberCoroutineScope()
var text by remember { mutableStateOf("") }
Row {
OutlinedTextField(value = text, onValueChange = { text = it })
Button(
onClick = {
val body = text
scope.launch {
addComment(
input = AddCommentInput(body = body, subjectId = subjectID),
connections = listOf(connectionID),
)
text = ""
}
},
enabled = text.isNotBlank() && !addComment.isInFlight,
) { Text("Comment") }
}
}IssueComments.kt
package app
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import baton.Fragment
import baton.Mutation
import baton.rememberMutation
import kotlinx.coroutines.launch
@Fragment($$"""
fragment IssueComments_issue on Issue
@refetchable(queryName: "IssueCommentsPaginationQuery")
@argumentDefinitions(count: {type: "Int", defaultValue: 30}, cursor: {type: "String"}) {
id
comments(first: $count, after: $cursor) @connection(key: "IssueComments_comments") {
totalCount
edges { node { id bodyText author { login } } }
}
}
""")
@Composable
fun IssueComments(issue: IssueComments_issue) {
val comments = issue.comments
Column {
Text("${comments.totalCount} comments")
for (comment in comments.nodes) {
Text("${comment.author?.login ?: "ghost"}: ${comment.bodyText}")
}
CommentComposer(subjectID = issue.id, connectionID = comments.connectionID)
}
}Lens#
Baton's word for what Relay calls a fragment reference and fragment data, which are one value here: a typed, read-only view over one record, with one accessor per declared field. A parent passes a child its lens through a spread's accessor and cannot read through it. A lens is valid on the main thread, where the store lives; equal by its record, its owner and its origin. In the terminology.
The lens of IssueList_repository, a fragment, reads a record of type Repository:
| accessor | reads | as |
|---|---|---|
issues | issues | a lens of IssueConnection |
edges | edges | a lens of IssueEdge, a list, optional |
node | node | a lens of Issue, optional |
id | id | a scalar |
issueRow | the parent's record | the lens of IssueRow_issue |
cursor | cursor | a scalar |
pageInfo | pageInfo | a lens of PageInfo |
endCursor | endCursor | a scalar, optional |
hasNextPage | hasNextPage | a scalar |
id | id | a scalar |
issues reads a nested lens whose edges are a list, each edge's node a lens that spreads the row's fragment, and cursor and pageInfo, which the compiler added for the connection. Nothing else appears.Record#
An observable object identified by typename plus key, Character:9, holding interned slots and per-field errors. Each slot is an invalidation channel: a body that read it is invalidated when it changes, and at no other time. Objects without a key get a client id from their path, as in Relay. In the terminology.
| 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 |
|---|
Store#
All records, retained roots and lifetime state, owned by the main thread, read synchronously, written by atomic commits. A commit is a batch: the transaction that nets the notifications, an undo log, and a kind, server, optimistic or local. Only a server batch reaches the image on disk. In the terminology.
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
Plan#
How a response becomes records: the normalization artifact the compiler emits per operation, as data the runtime walks. The ingest follows the plan through the bytes, keys each object by the type's key fields, and writes slots by their numbers; it knows no field by name. Both runtimes interpret the same plans. In the terminology.
IssuesScreen.baton.swift
// Generated by batonc. Do not edit.
import Foundation
@_spi(Generated) import Baton
/// Operation value for `query IssuesQuery`.
nonisolated public struct IssuesQuery: Baton.Query {
public var owner: String
public var name: String
@_spi(Generated) public var resolution: Baton.Resolution<Baton.OperationHandle<Self>> = .unresolved
public init(owner: String, name: String) {
self.owner = owner
self.name = name
}
public static let name = "IssuesQuery"
public static let document: Baton.Document = .id("d8e7121ec0e10dfd0dd7c8d126e75dd1")
public var variables: Baton.Variables {
Baton.Variables(["owner": Baton.Variable(self.owner), "name": Baton.Variable(self.name)])
}
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.owner == rhs.owner && lhs.name == rhs.name
}
public func hash(into hasher: inout Hasher) {
hasher.combine(self.owner)
hasher.combine(self.name)
}
@_spi(Generated) public static let plan = Baton.Plan(root: selection0)
private static let selection0: Baton.Selection = Baton.Selection(type: Types.Query, key: [], abstract: false, fields: [
.linked("repository", key: .dynamic(Slots.Query.repository_00c598), plural: false, selection: selection1),
])
private static let selection1: Baton.Selection = Baton.Selection(type: Types.Repository, key: ["id"], abstract: false, fields: [
.scalar("nameWithOwner", key: .fixed(Slots.Repository.nameWithOwner), kind: .string, list: false),
.linked("issues", key: .fixed(Slots.Repository.issues_44b8f4), plural: false, selection: selection2),
.scalar("id", key: .fixed(Slots.Repository.id), kind: .string, list: false),
])
private static let selection2: Baton.Selection = Baton.Selection(type: Types.IssueConnection, key: [], abstract: false, fields: [
.linked("nodes", key: .fixed(Slots.IssueConnection.nodes), plural: true, selection: selection3),
])
private static let selection3: Baton.Selection = Baton.Selection(type: Types.Issue, key: ["id"], abstract: false, fields: [
.scalar("id", key: .fixed(Slots.Issue.id), kind: .string, list: false),
.scalar("number", key: .fixed(Slots.Issue.number), kind: .int, list: false),
.scalar("title", key: .fixed(Slots.Issue.title), kind: .string, list: false),
.linked("author", key: .fixed(Slots.Issue.author), plural: false, selection: selection4),
])
private static let selection4: Baton.Selection = Baton.Selection(type: Types.Actor, key: ["id"], abstract: true, memberships: [.init("__isNode", Types.Node)], variants: [
.init(types: [Types.Bot, Types.EnterpriseUserAccount, Types.Mannequin, Types.Organization, Types.User], key: ["id"], fields: [
.scalar("__typename", key: .fixed(Slots.Actor.__typename), kind: .string, list: false),
.scalar("login", key: .fixed(Slots.Actor.login), kind: .string, list: false),
.scalar("id", key: .fixed(Slots.Actor.id), kind: .string, list: false),
]),
.init(types: nil, condition: Types.Node, fields: [
.scalar("__typename", key: .fixed(Slots.Actor.__typename), kind: .string, list: false),
.scalar("login", key: .fixed(Slots.Actor.login), kind: .string, list: false),
.scalar("id", key: .fixed(Slots.Actor.id), kind: .string, list: false),
]),
.init(types: nil, fields: [
.scalar("__typename", key: .fixed(Slots.Actor.__typename), kind: .string, list: false),
.scalar("login", key: .fixed(Slots.Actor.login), kind: .string, list: false),
]),
])
nonisolated public struct Data: Baton.Lens {
@_spi(Generated) public let anchor: Baton.Anchor
@_spi(Generated) public init(anchor: Baton.Anchor) { self.anchor = anchor }
@MainActor public var repository: Repository? { anchor.linked(anchor.owner.slot(Slots.Query.repository_00c598)).map(Repository.init(anchor:)) }
nonisolated public struct Repository: Baton.Lens {
@_spi(Generated) public let anchor: Baton.Anchor
@_spi(Generated) public init(anchor: Baton.Anchor) { self.anchor = anchor }
@MainActor public var nameWithOwner: String { anchor.requiredString(Slots.Repository.nameWithOwner) }
@MainActor public var issues: Issues { Issues(anchor: anchor.requiredLinked(Slots.Repository.issues_44b8f4, type: Types.IssueConnection)) }
nonisolated public struct Issues: Baton.Lens {
@_spi(Generated) public let anchor: Baton.Anchor
@_spi(Generated) public init(anchor: Baton.Anchor) { self.anchor = anchor }
@MainActor public var nodes: Baton.List<Nodes>? { anchor.list(Slots.IssueConnection.nodes) }
nonisolated public struct Nodes: Baton.Lens {
@_spi(Generated) public let anchor: Baton.Anchor
@_spi(Generated) public init(anchor: Baton.Anchor) { self.anchor = anchor }
@MainActor public var id: String { anchor.requiredString(Slots.Issue.id) }
@MainActor public var issueRow: IssueRow_issue { .init(anchor: anchor.entering()) }
}
}
}
}
}IssuesScreen.baton.kt
// Generated by batonc. Do not edit.
@file:OptIn(baton.Generated::class)
package app
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import baton.Anchor
import baton.Document
import baton.Lens
import baton.OperationKind
import baton.OperationType
import baton.Plan
import baton.PlanField
import baton.QueryOperation
import baton.QueryType
import baton.ScalarKind
import baton.Selection
import baton.StorageKey
import baton.Variable
import baton.Variables
/** Operation value for `query IssuesQuery`. */
@Immutable
class IssuesQuery(val owner: String, val name: String) : QueryOperation<IssuesQuery.Data> {
override val variables: Variables
get() = Variables.of("owner" to Variable.of(owner), "name" to Variable.of(name))
override val type: OperationType<Data> get() = Companion
override fun equals(other: Any?): Boolean = other is IssuesQuery && other.owner == owner && other.name == name
override fun hashCode(): Int = listOf(owner, name).hashCode()
companion object : QueryType<IssuesQuery, IssuesQuery.Data> {
override val name = "IssuesQuery"
override val document: Document = Document.Id("d8e7121ec0e10dfd0dd7c8d126e75dd1")
override val kind = OperationKind.QUERY
override val plan: Plan by lazy { Plan(root = `IssuesQuery-plan`.selection0) }
override fun data(anchor: Anchor): Data = Data(anchor)
}
@Stable
class Data(override val anchor: Anchor) : Lens {
val repository: Repository? get() = anchor.linked(anchor.owner.slot(Slots.Query.repository_00c598))?.let(::Repository)
override fun equals(other: Any?): Boolean = other is IssuesQuery.Data && other.anchor == anchor
override fun hashCode(): Int = anchor.hashCode()
@Stable
class Repository(override val anchor: Anchor) : Lens {
val nameWithOwner: String get() = anchor.requiredString(Slots.Repository.nameWithOwner)
val issues: Issues get() = Issues(anchor.requiredLinked(Slots.Repository.issues_44b8f4, Types.IssueConnection))
override fun equals(other: Any?): Boolean = other is IssuesQuery.Data.Repository && other.anchor == anchor
override fun hashCode(): Int = anchor.hashCode()
@Stable
class Issues(override val anchor: Anchor) : Lens {
val nodes: List<Nodes>? get() = anchor.list(Slots.IssueConnection.nodes, ::Nodes)
override fun equals(other: Any?): Boolean = other is IssuesQuery.Data.Repository.Issues && other.anchor == anchor
override fun hashCode(): Int = anchor.hashCode()
@Stable
class Nodes(override val anchor: Anchor) : Lens {
val id: String get() = anchor.requiredString(Slots.Issue.id)
val issueRow: IssueRow_issue get() = IssueRow_issue(anchor.entering())
override fun equals(other: Any?): Boolean = other is IssuesQuery.Data.Repository.Issues.Nodes && other.anchor == anchor
override fun hashCode(): Int = anchor.hashCode()
}
}
}
}
}
private object `IssuesQuery-plan` {
val selection0: Selection by lazy {
Selection(type = Types.Query, key = listOf(), isAbstract = false, fields = listOf(
PlanField.linked("repository", key = StorageKey.Dynamic(Slots.Query.repository_00c598), plural = false, selection = selection1),
))
}
val selection1: Selection by lazy {
Selection(type = Types.Repository, key = listOf("id"), isAbstract = false, fields = listOf(
PlanField.scalar("nameWithOwner", key = StorageKey.Fixed(Slots.Repository.nameWithOwner), kind = ScalarKind.STRING, list = false),
PlanField.linked("issues", key = StorageKey.Fixed(Slots.Repository.issues_44b8f4), plural = false, selection = selection2),
PlanField.scalar("id", key = StorageKey.Fixed(Slots.Repository.id), kind = ScalarKind.STRING, list = false),
))
}
val selection2: Selection by lazy {
Selection(type = Types.IssueConnection, key = listOf(), isAbstract = false, fields = listOf(
PlanField.linked("nodes", key = StorageKey.Fixed(Slots.IssueConnection.nodes), plural = true, selection = selection3),
))
}
val selection3: Selection by lazy {
Selection(type = Types.Issue, key = listOf("id"), isAbstract = false, fields = listOf(
PlanField.scalar("id", key = StorageKey.Fixed(Slots.Issue.id), kind = ScalarKind.STRING, list = false),
PlanField.scalar("number", key = StorageKey.Fixed(Slots.Issue.number), kind = ScalarKind.INT, list = false),
PlanField.scalar("title", key = StorageKey.Fixed(Slots.Issue.title), kind = ScalarKind.STRING, list = false),
PlanField.linked("author", key = StorageKey.Fixed(Slots.Issue.author), plural = false, selection = selection4),
))
}
val selection4: Selection by lazy {
Selection(type = Types.Actor, key = listOf("id"), isAbstract = true, memberships = listOf(Selection.MembershipAnswer("__isNode", Types.Node)), variants = listOf(
Selection.Variant(types = listOf(Types.Bot, Types.EnterpriseUserAccount, Types.Mannequin, Types.Organization, Types.User), key = listOf("id"), fields = listOf(
PlanField.scalar("__typename", key = StorageKey.Fixed(Slots.Actor.__typename), kind = ScalarKind.STRING, list = false),
PlanField.scalar("login", key = StorageKey.Fixed(Slots.Actor.login), kind = ScalarKind.STRING, list = false),
PlanField.scalar("id", key = StorageKey.Fixed(Slots.Actor.id), kind = ScalarKind.STRING, list = false),
)),
Selection.Variant(types = null, condition = Types.Node, fields = listOf(
PlanField.scalar("__typename", key = StorageKey.Fixed(Slots.Actor.__typename), kind = ScalarKind.STRING, list = false),
PlanField.scalar("login", key = StorageKey.Fixed(Slots.Actor.login), kind = ScalarKind.STRING, list = false),
PlanField.scalar("id", key = StorageKey.Fixed(Slots.Actor.id), kind = ScalarKind.STRING, list = false),
)),
Selection.Variant(types = null, fields = listOf(
PlanField.scalar("__typename", key = StorageKey.Fixed(Slots.Actor.__typename), kind = ScalarKind.STRING, list = false),
PlanField.scalar("login", key = StorageKey.Fixed(Slots.Actor.login), kind = ScalarKind.STRING, list = false),
)),
))
}
}Operation value#
A value of an operation's variables, compared and hashed by them: the thing a parent constructs, IssuesQuery(owner:name:), and a navigation path carries. Inside a view it resolves to a handle, which exposes the phase, the data, a refetch and a retry; outside any view it reads as loading. In the terminology.
- query
IssuesQuery- variables
$owner: String!$name: String!- spreads
IssueRow_issue- sent as
d8e7121ec0e10dfd0dd7c8d126e75dd1, the text's hash; the text stays on the server- the text
query IssuesQuery($owner:String!,$name:String!){repository(owner:$owner,name:$name){nameWithOwner,issues(first:20,states:OPEN){nodes{id,...IssueRow_issue}},id}}fragment IssueRow_issue on Issue{number,title,author{__typename,login,...on Node{__isNode:__typename,id}}}
Connection#
A list that pages: Relay's connection specification, on a fragment marked @refetchable, under @connection(key:). The store merges each page into one list under the key; the lens reads nodes, hasNext and isLoadingNext, pages with loadNext, and names the list with connectionID for an edge directive. In the terminology.
The server answers TestNotesPaginationQuery(count: 2, cursor: "c2", id: "1") with tests/notes-page-2.json, and the environment commits it as a server batch.
tests/notes-page-2.json
{
"data": {
"node": {
"__typename": "Character",
"id": "1",
"name": "Rick Sanchez",
"notes": {
"totalCount": 5,
"edges": [
{
"cursor": "c3",
"node": {
"__typename": "Note",
"id": "n3",
"text": "Get Schwifty"
}
},
{
"cursor": "c4",
"node": {
"__typename": "Note",
"id": "n4",
"text": "Avoid the Citadel"
}
}
],
"pageInfo": {
"endCursor": "c4",
"hasNextPage": true
}
}
}
}
}| A lens reads | and finds |
|---|---|
notes · character.notes.edges.1.node.text | Portal gun needs charging |
notes · character.notes.edges.2.node.text | Get Schwifty |
notes · character.notes.edges.3.node.text | Avoid the Citadel |
notes · character.notes.pageInfo.endCursor | c4 |
notes · character.notes.pageInfo.hasNextPage | true |
The store after the step, scripts/connections-merged.store.json
| __TestNotes_notes_connection | Character:1:__TestNotes_notes_connection |
|---|---|
| id | "1" |
| name | "Rick Sanchez" |
| notes(after:"c2",first:2) | Character:1:notes(after:"c2",first:2) |
| notes(first:2) | Character:1:notes(first:2) |
| endCursor | "c4" |
|---|---|
| hasNextPage | true |
| edges | [ Character:1:notes(after:"c2",first:2):edges:0, Character:1:notes(after:"c2",first:2):edges:1 ] |
|---|---|
| pageInfo | Character:1:notes(after:"c2",first:2):pageInfo |
| totalCount | 5 |
| cursor | "c3" |
|---|---|
| node | Note:n3 |
| cursor | "c4" |
|---|---|
| node | Note:n4 |
| endCursor | "c4" |
|---|---|
| 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" |
| id | "n3" |
|---|---|
| text | "Get Schwifty" |
| id | "n4" |
|---|---|
| text | "Avoid the Citadel" |
| character(id:"1") | Character:1 |
|---|---|
| node(id:"1") | Character:1 |
Environment#
The session: one store, one transport, and the door everything from outside comes through. It fetches, commits, applies optimistic layers, hydrates from the image, logs, and ends. Signing out is ending it and making another. Injected once at the root of the app. In the terminology.
The environment ends.
Phase [{"handle":"rick","phase":{"failed":"gone"}},{"handle":"einstein","phase":{"failed":"gone"}}]
Fetch [{"handle":"rick","fetch":{"failed":"environment"}},{"handle":"einstein","fetch":{"failed":"environment"}}]
Records held client:root client:root:mutation client:root:subscription
Logged nothing
| A lens reads | and finds |
|---|---|
character.name | null |
Transport#
A protocol with one verb, send, which takes a request and yields a stream of payloads: one for a query, several for a deferred response, many for a subscription. The built-in transports are the platform's HTTP and sockets; everything a production endpoint needs beyond that, a retry, a challenge, a deadline, is a transport that wraps a transport, which the app owns. In the terminology.
A view attaches a handle for TestPinnedCharacter(id: "1") under networkOnly, named pinned; the transport answers its fetch with tests/pinned-character.json.
tests/pinned-character.json
{
"data": {
"character": {
"id": "1",
"name": "Rick Sanchez",
"status": "Alive"
}
}
}Fetch {"handle":"pinned","fetch":"idle"}
Sent TestPinnedCharacter
{"operationName":"TestPinnedCharacter","query":"query TestPinnedCharacter($id:ID!){character(id:$id){id,name,status}}","variables":{"id":"1"}}Phase#
The state of a resolved operation, named after AsyncImagePhase: loading, ready, or failed, always synchronously readable, with isRefreshing and isStale beside it. Previous data stays visible while a refresh runs; a fetch that changed nothing wakes no body. Derived from the root's verdict and the fetch, stored nowhere. In the terminology.
A view attaches a handle for TestHeaderQuery(id: "5") under storeOrNetwork, named jerry; the fetch stays in flight.
Phase {"handle":"jerry","phase":"loading","isRefreshing":false}
Fetch {"handle":"jerry","fetch":"inFlight"}
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","isRefreshing":false}
Fetch {"handle":"jerry","fetch":"idle"}
A view attaches a handle for TestHeaderQuery(id: "2") under storeAndNetwork, named morty; the fetch fails with a transport failure.
Phase {"handle":"morty","phase":{"failed":"transport"}}
Fetch {"handle":"morty","fetch":{"failed":"transport"}}
morty retries after its failure.
Phase {"handle":"morty","phase":"loading","isRefreshing":false}
Fetch {"handle":"morty","fetch":"inFlight"}
The fetch of morty fails with a malformed failure.
Phase {"handle":"morty","phase":{"failed":"malformed"}}
Fetch {"handle":"morty","fetch":{"failed":"malformed"}}
What is not a word#
A view model, a cache policy, a type policy, a merge function, a query file for a screen, a cache updater, a resolver, a runtime GraphQL parser. Each was asked for by some screen somewhere, and each is answered by a composition of the twelve above or refused with a reason in the decisions. What earns a concept is the test a thirteenth word would have to pass.
Baton