diff --git a/CHANGELOG.md b/CHANGELOG.md index 52874ea1..b3bf0b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ are expected and listed without apology. ## Unreleased +- The site, the articles, the principles, the vision, and the recipes are + written in plain sentences. The claims, the numbers, the code samples, + and the decision records are unchanged. - `docs/recipes/accounts.md`: a sign-out, a switch of accounts, and what the disk keeps. Where the account lives, in the image's path or its `version`; the order of a sign-out, end the environment, remove the diff --git a/docs/README.md b/docs/README.md index 3b2a2c8b..86f5cbbc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,49 +3,53 @@ What to read when. - **What is this, and why is it shaped this way?** - [vision.md](vision.md) — the argument and the five rules. + [vision.md](vision.md) is the argument and the five rules. - **Why GraphQL at all, and where do my view models go?** - [why-graphql.md](why-graphql.md) — GraphQL for iOS and Android engineers - who have built on REST: what it is, what it changes, what it costs. + [why-graphql.md](why-graphql.md) is for iOS and Android engineers who + have built on REST: what it is, what it changes, and what it costs. - **Why is this decision the way it is?** - [principles/](principles/) — one file per constraint: the failure mode it - avoids, the idea, the consequences, the rejected alternatives, and how it - is spelled in code today. + [principles/](principles/) has one file per constraint: the failure it + avoids, the idea, the consequences, the rejected alternatives, and how + it is spelled in code today. - **Why this and not the alternative?** - [decisions/](decisions/) — one file per choice: context, decision, - evidence, what was not chosen, and what would reopen it. + [decisions/](decisions/) has one file per choice: the context, the + decision, the evidence, what was not chosen, and what would reopen it. - **How does this compare with the other native clients?** - [comparison.md](comparison.md) — approaches, numbers, pros and cons. + [comparison.md](comparison.md) sets out the approaches, the numbers, and + the pros and cons. - **Why this shape, argued at length?** - [articles/](articles/) — one essay per choice that shapes the library: - Relay without the read side, what Meta built, why it is fast, the first - frame, two runtimes, the compiler, how a fast store stays honest, the - twelve words, Baton beside fate, the small lies a client tells, and how - it is built. + [articles/](articles/) holds one essay per choice that shapes the + library: Relay without the read side, what Meta built, why it is fast, + the first frame, two runtimes and the compiler. Further essays cover how + a fast store stays honest, the twelve words, Baton beside fate, the small + lies a client tells, and how it is built. - **What does this word mean?** - [terminology.md](terminology.md) — the vocabulary contract, updated in the - same change as the code. Concepts marked *(planned)* do not exist yet. + [terminology.md](terminology.md) is the vocabulary contract, updated in + the same change as the code. Concepts marked *(planned)* do not exist + yet. - **Where is it, in each runtime?** - [runtimes.md](runtimes.md) — the map of both trees, a file per concept, + [runtimes.md](runtimes.md) is the map of both trees, a file per concept, checked by the boundary script. -- **How do I…** — [recipes/](recipes/), one page per composition of what - ships: [integrating Baton with a coding - agent](recipes/agents.md), the page an agent reads first, [the exchange](recipes/exchange.md), a challenge, a retry and a - deadline over the transport's one verb, [`batonc`](recipes/batonc.md), - the compiler's command line for a build outside SwiftPM, - [Bazel](recipes/bazel.md), the compiler as a toolchain and one rule over - its command, [Gradle](recipes/gradle.md), the Kotlin runtime from Maven - Central and the compiler as a task, and - [previews and tests](recipes/testing.md), a store without a server, - [UIKit and AppKit](recipes/uikit.md), a handle held by a controller, - [views and view models](recipes/views.md), a Kotlin handle without a - composition, [outside views](recipes/outside-views.md), a fetch, a read - and a write from code that is not a view, [accounts](recipes/accounts.md), - a sign-out, a switch and what the disk keeps, and - [porting from Relay](recipes/porting-from-relay.md), Relay's words beside - Baton's, and [derived state outside views](recipes/derived-state.md), a - model over `Observations` or `snapshotFlow`, and [discover once, refresh through - `nodes(ids:)`](recipes/discover-once.md), the pattern for external keys. - The rest arrive with the releases that make them true. -- **What did a decision open up?** — [openings/](openings/), written as the - project ships. None yet. +- **How do I...** [recipes/](recipes/) has one page per composition of what + ships. [integrating Baton with a coding agent](recipes/agents.md) is the + page an agent reads first. [the exchange](recipes/exchange.md) is a + challenge, a retry and a deadline over the transport's one verb. + [`batonc`](recipes/batonc.md) is the compiler's command line for a build + outside SwiftPM. [Bazel](recipes/bazel.md) is the compiler as a toolchain + and one rule over its command. [Gradle](recipes/gradle.md) is the Kotlin + runtime from Maven Central and the compiler as a task. + [previews and tests](recipes/testing.md) is a store without a server. + [UIKit and AppKit](recipes/uikit.md) is a handle held by a controller. + [views and view models](recipes/views.md) is a Kotlin handle without a + composition. [outside views](recipes/outside-views.md) is a fetch, a read + and a write from code that is not a view. + [accounts](recipes/accounts.md) is a sign-out, a switch and what the disk + keeps. [porting from Relay](recipes/porting-from-relay.md) sets Relay's + words beside Baton's. + [derived state outside views](recipes/derived-state.md) is a model over + `Observations` or `snapshotFlow`. + [discover once, refresh through `nodes(ids:)`](recipes/discover-once.md) + is the pattern for external keys. The rest arrive with the releases that + make them true. +- **What did a decision open up?** [openings/](openings/) is written as the + project ships. There are none yet. diff --git a/docs/articles/README.md b/docs/articles/README.md index 5eb3e7f2..170ef1e1 100644 --- a/docs/articles/README.md +++ b/docs/articles/README.md @@ -1,29 +1,31 @@ # Articles -The design argued at length. Each page takes one choice that shapes the -library, says what the alternatives were and what they cost, and puts the -evidence beside it: the numbers from [BENCHMARKS.md](../../BENCHMARKS.md), the -decision records under [decisions/](../decisions/), and the public record of -the clients Baton is compared with. The short forms are the -[vision](../vision.md) and the [principles](../principles/); these are the +These pages argue the design at length. Each page takes one choice that +shapes the library and says what the alternatives were, what they cost, and +where the evidence sits. + +The numbers are in [BENCHMARKS.md](../../BENCHMARKS.md), the decision +records are under [decisions/](../decisions/), and the public record of the +clients Baton is compared with is here too. The short forms are the +[vision](../vision.md) and the [principles](../principles/). These are the long ones, in reading order. - [Relay without the read side](relay-without-the-read-side.md): why Baton - keeps Relay's compiler and the write side of its store and deletes the read - side, because Observation and snapshot state already answer the question + keeps Relay's compiler and the write side of its store, and deletes the + read side. Observation and snapshot state already answer the question Relay's runtime was built to answer. -- [What Meta built, and what you can download](what-meta-built.md): the public - record of Meta's in-house native client, where Baton makes the same choices, - and where it goes further. +- [What Meta built, and what you can download](what-meta-built.md): the + public record of Meta's in-house native client, where Baton makes the same + choices, and where it goes further. - [Materialization is the bill](materialization-is-the-bill.md): why Baton is - faster than Apollo iOS and Apollo Kotlin on the same data, itemized on a Mac - and on a phone, with the caveats. -- [The first frame has the data](the-first-frame.md): synchronous reads on the - main actor, the image on disk, what a phone measured, and the fix the trace - asked for. + faster than Apollo iOS and Apollo Kotlin on the same data, with the bill + itemized on a Mac and on a phone, and the caveats. +- [The first frame has the data](the-first-frame.md): synchronous reads on + the main actor, the image on disk, what a phone measured, and the fix the + trace asked for. - [Two runtimes, no bridge](two-runtimes-no-bridge.md): why the runtime is - written twice instead of shared through a Rust core, and what holds the two - to one behaviour. + written twice, instead of shared through a Rust core, and what holds the + two to one behaviour. - [A compiler, not a configuration](a-compiler-not-a-configuration.md): what is settled at build time, why the runtime has no parser and no policy object, and why that matters more now that agents write screens. @@ -31,14 +33,14 @@ long ones, in reading order. oracle, the fixtures, Relay's own tests measured against, and the benches that keep every claim checkable. - [Twelve words, and why the list is closed](twelve-words.md): the closed - vocabulary, what it refused and why, and how it looks beside a client - that dropped GraphQL and the clients that chose configuration. + vocabulary, what it refused and why, and how it looks beside a client that + dropped GraphQL and beside the clients that chose configuration. - [Baton beside fate](baton-beside-fate.md): a client that took Relay's - insights and removed GraphQL, beside one that kept the compiler; where - they agree, where they part, three mechanisms watched, and a proof against + insights and removed GraphQL, beside one that kept the compiler: where + they agree, where they part, three mechanisms to watch, and a proof against fate's own server. - [The small lies a client tells](the-small-lies.md): six ways GraphQL - clients lie about data, and the types, directives and events that keep + clients lie about data, and the types, directives, and events that keep Baton from each. - [Settled stays settled](settled-stays-settled.md): how Baton is built, habit by habit, each with the mechanism that fails when the habit is diff --git a/docs/articles/a-compiler-not-a-configuration.md b/docs/articles/a-compiler-not-a-configuration.md index 924343d3..d5da21b4 100644 --- a/docs/articles/a-compiler-not-a-configuration.md +++ b/docs/articles/a-compiler-not-a-configuration.md @@ -1,172 +1,202 @@ # A compiler, not a configuration A GraphQL client that reasons about GraphQL at run time pays for it on every -request and in every binary: a parser, a validator, an executor over selection -metadata, string-keyed records, type policies looked up by name. It also -cannot tell a developer at build time that a field does not exist, that a -parent never spread the fragment a child reads, or that two operations will -collide in the cache. Baton's runtime parses no GraphQL. Every operation is -static, so every question a cache has to answer is answered before the app -runs, and the runtime is a tokenizer that follows a plan, a store that merges -slots, accessors that read them, and a transport that sends the text, or the -id the build registered. +request, and again in the binary. The binary carries a parser, a validator, +and an executor over selection metadata. Records are keyed by strings, and +type policies are looked up by name. + +That client also cannot tell you, at build time, that a field does not +exist, that a parent never spread the fragment a child reads, or that two +operations will collide in the cache. + +Baton's runtime parses no GraphQL. Every operation is static, so every +question a cache has to answer is answered before the app runs. The runtime +is a tokenizer that follows a plan, a store that merges slots, accessors +that read them, and a transport that sends the text, or the id the build +registered. ## What the compiler settles -`batonc` reads the schema, `baton.json` and every document in the module, -whether in a Swift or Kotlin file or in a `.graphql` file, and settles: +`batonc` reads the schema, `baton.json`, and every document in the module. A +document can sit in a Swift file, a Kotlin file, or a `.graphql` file. The +compiler then settles the following. -- **Validity.** Every field against the schema, with the diagnostic pointing - into the GraphQL text inside the host file, at the character, as - `path:line:column: error: message`, so the editor shows it on the line and a +- **Validity.** Every field is checked against the schema. The diagnostic + points into the GraphQL text inside the host file, at the character, as + `path:line:column: error: message`. The editor shows it on the line. A misspelled field has the schema's suggestion beside it. - **Aggregation.** The screen's operation is assembled from the fragments - spread into it, fragment arguments applied by cloning per unique argument - set, every fragment inlined into one normalization plan per operation. - Nobody writes the screen's query. -- **Identity.** The key fields identity needs, `id` unless `baton.json` names - other fields for a type, are inserted into the selection wherever the type - is read, so a response always carries what the ingest needs to make a - record. -- **Storage keys.** Every key without a variable is computed and emitted as a - constant; a key with variables is rendered once per owner. No path a view or - a commit takes hashes a field name. -- **Masking.** A fragment compiles to a lens with an accessor per declared - field and no other; a spread compiles to an accessor returning the child's - lens. The parent cannot read through it. -- **The wire.** The operation's text is printed compact and hashed to an id; - under Relay's `persistConfig` the registration file is written by the build - and the app sends the id alone. -- **The report.** What a dependent target's compilation would read: every - operation, fragment and lens, the schema's digest, the reachable and the - unused fragments, for tools and for the inspector. - -The runtime has no opinion. The same plans are interpreted by the Swift and -the Kotlin runtime, so they can only disagree in execution, which the fixtures -catch. + spread into it. Fragment arguments are applied by cloning, once per unique + argument set. Every fragment is inlined into one normalization plan per + operation. Nobody writes the screen's query. +- **Identity.** The key fields identity needs are inserted into the selection + wherever the type is read. That is `id`, unless `baton.json` names other + fields for a type. A response always carries what the ingest needs to make + a record. +- **Storage keys.** Every key without a variable is computed and emitted as + a constant. A key with variables is rendered once per owner. No path a + view or a commit takes hashes a field name. +- **Masking.** A fragment compiles to a lens, a typed, read-only view over + one record: one accessor per field you declared, and no accessor for + anything else. A spread compiles to an accessor that returns the child's + lens, which the parent cannot read through. +- **The wire.** The operation's text is printed compact and hashed to an id. + Under Relay's `persistConfig`, the build writes the registration file, and + the app sends the id alone. +- **The report.** What a dependent target's compilation would read is every + operation, fragment, and lens, the schema's digest, the reachable + fragments, and the unused ones. The report is for tools and for the + inspector. + +The runtime has no opinion. The Swift runtime and the Kotlin runtime +interpret the same plans. They can disagree only in execution, and the +fixtures catch that. ## Relay's front end, pinned, behind Baton's driver -The front end is Relay's own compiler: its parser, schema, typed IR, spec -validation and transforms, consumed as Rust crates pinned to a tag, behind a -driver that is Baton's. Everything in front of them, finding GraphQL in Swift -and Kotlin sources, and everything behind them, the plan IR, the emitters and -the artifact format, is Baton's. There is no fork. - -The reason is a graveyard. Relay.swift put the fragment in the Swift file and -generated code through a plugin for the JavaScript Relay compiler; Relay 13 -replaced that compiler in January 2022 and removed the plugin API, and -Relay.swift stopped. rescript-relay forks the Rust compiler and was 675 -commits behind upstream in October 2026. Apollo iOS bundles GraphQL.js, so -syntax the JavaScript reference implementation does not parse yet is syntax -Apollo iOS cannot accept. Baton owns the binary, the scanner and the emitter, -and the pinned revision cannot fall out of step with anything because the -runtime is Baton's; an upgrade is elective ([the +The front end is Relay's own compiler: its parser, its schema, its typed IR, +its spec validation, and its transforms. Baton consumes them as Rust crates, +pinned to a tag, behind a driver that is Baton's. + +Everything in front of that front end is Baton's: finding GraphQL in Swift +and Kotlin sources. Everything behind it is Baton's too: the plan IR, the +emitters, and the artifact format. There is no fork. + +The reason is a graveyard. Relay.swift put the fragment in the Swift file +and generated code through a plugin for the JavaScript Relay compiler. Relay +13 replaced that compiler in January 2022 and removed the plugin API. +Relay.swift stopped. + +rescript-relay forks the Rust compiler and, in October 2026, was 675 commits +behind upstream. Apollo iOS bundles GraphQL.js, so syntax the JavaScript +reference implementation does not parse yet is syntax Apollo iOS cannot +accept. + +Baton owns the binary, the scanner, and the emitter. The pinned revision +cannot fall out of step with anything, because the runtime is Baton's. An +upgrade is elective ([the decision](../decisions/relay-front-end.md)). -The spike that settled it measured a stripped binary of 4.3 MB and 500 -fragments compiled in 8 ms warm; GitHub's schema, about 1,800 definitions, -passes the front end in 33 ms. The compiler is one prebuilt binary for macOS -and Linux, run by the SwiftPM and Xcode plugin, the Gradle plugin, the Bazel -rule or the command line, and none of those integrations holds logic of its -own ([the decision](../decisions/a-build-integration-holds-no-logic.md)). +The spike that settled it measured a stripped binary of 4.3 MB, and 500 +fragments compiled in 8 ms warm. GitHub's schema, about 1,800 definitions, +passes the front end in 33 ms. The compiler is one prebuilt binary, for +macOS and Linux. The SwiftPM plugin, the Xcode plugin, the Gradle plugin, +the Bazel rule, or the command line runs it. None of those integrations +holds logic of its own ([the +decision](../decisions/a-build-integration-holds-no-logic.md)). ## Slots are numbered by the process -One detail shows how far the principle is taken, and where it stops. A record -stores a field's value at a slot, a small integer per storage key and type. -The obvious reading of "the compiler decides" is that the compiler writes the -integers into the generated code. It does not, and the reason is that modules -compile apart and share one store: two modules must agree on the slot of -`Character.name` without having seen each other, and a build-tool plugin sees -one target at a time. So the compiler emits one constant per key and type, -`Slots.Character_name`, and the process numbers each constant the first time -it is touched, in a table every module shares. A name is hashed where a key is -first met and nowhere a view or a commit passes again. The image stores names, -not numbers, because slot numbers belong to a process, and that is why it -survives a build with other documents ([the +One detail shows how far the principle goes, and where it stops. A record +stores a field's value at a slot, a small integer, one per storage key and +type. + +The obvious reading of "the compiler decides" is that the compiler writes +those integers into the generated code. It does not. Modules compile apart +and share one store. Two modules must agree on the slot of `Character.name` +without having seen each other. A build-tool plugin sees one target at a +time. + +So the compiler emits one constant per key and type, +`Slots.Character_name`. The process numbers each constant the first time it +is touched, in a table every module shares. A name is hashed where a key is +first met, and nowhere a view or a commit passes again. The image stores +names, not numbers, because slot numbers belong to a process. That is why +the image survives a build with other documents ([the decision](../decisions/slots-are-numbered-by-the-process.md)). ## No policy objects -The opposite of a compiler is a configuration: a cache that is correct once -the right objects are registered at run time. Apollo configures identity with -`@typePolicy` and `@fieldPolicy` or a key-generator function; Medium's Android -team, migrating between Apollo Kotlin majors in 2025, found cache misses -everywhere because some ids were missing from their `CacheKeyGenerator` and -some of its logic was wrong ([their -write-up](https://medium.engineering/medium-android-app-migrating-from-apollo-kotlin-3-to-4-lessons-learned-ff8d0d861cdb)), -and Vrbo's 2019 account of the same cache says normalization is -all-or-nothing, every type needs a stable key, and two queries with the same -response type do not share data unless their roots are remapped by hand -([their +The opposite of a compiler is a configuration. A cache is correct once the +right objects are registered at run time. Apollo configures identity with +`@typePolicy` and `@fieldPolicy`, or with a key-generator function. + +Medium's Android team migrated between Apollo Kotlin majors in 2025 and +found cache misses everywhere. Some ids were missing from their +`CacheKeyGenerator`, and some of its logic was wrong ([their +write-up](https://medium.engineering/medium-android-app-migrating-from-apollo-kotlin-3-to-4-lessons-learned-ff8d0d861cdb)). +Vrbo's 2019 account of the same cache says normalization is all-or-nothing. +Every type needs a stable key. Two queries with the same response type do +not share data unless their roots are remapped by hand ([their write-up](https://medium.com/expedia-group-tech/how-apollo-android-client-cache-works-at-vrbo-73334d98af43)). + Those are not bugs in Apollo. They are what a run-time policy is: a contract the compiler never sees, broken in production. -In Baton identity is configuration the compiler reads and checks. A type keyed -by something other than `id`, by several fields or through an interface, is -named once in `baton.json`, the compiler validates the fields against the -schema and selects them wherever the type is read, and the plan carries the -key per selection ([the decision](../decisions/identity-is-configured.md)). -Fetch behaviour is a directive or a value on a handle: `@connection`, +In Baton, identity is configuration the compiler reads and checks. When a +type is keyed by something other than `id`, you name it once in +`baton.json`. The key can be several fields, or it can go through an +interface. The compiler validates the fields against the schema and selects +them wherever the type is read. The plan carries the key per selection ([the +decision](../decisions/identity-is-configured.md)). + +Fetch behaviour is a directive, or a value on a handle: `@connection`, `@refetchable`, `@cacheExpiration`, a fetch policy. There is no type-policy -registry, no merge function, no interceptor chain and no per-call option bag, -and the principle lists those under what it is not, as refusals rather than +registry, no merge function, no interceptor chain, and no per-call option +bag. The principle lists those under what it is not. They are refusals, not omissions. ## Byte-stable output, and names that try to break it Generated output is deterministic and byte-stable, and golden tests cover -every emitter in both languages. What generated code names in the runtime is a -format with a number; a change to it raises the number on both sides in one -commit, and generated code of an older format fails to compile at its marker -with a message that says to rebuild. The compiler's tests fence the bytes of -generated code per accessor line at 120 over the goldens, so a lens cannot -quietly grow. +every emitter in both languages. What generated code names in the runtime is +a format with a number, and a change to it raises the number on both sides +in one commit. Generated code of an older format fails to compile at its +marker, with a message that says to rebuild. + +The compiler's tests fence the bytes of generated code per accessor line at +120, over the goldens, so a lens cannot quietly grow. Then there are the names. A schema can call a field `init`, `Type`, -`description`, `self` or `suspend`, a fragment `Variables` or `Companion`, an -enum value `UNKNOWN` beside `Unknown`. A corpus of hostile names in every -position a name can take is compiled into a host file for each language, and a -sweep type-checks the generated code one document at a time, in CI, so that a -field spelled like a keyword is escaped, a name that would clash with a -generated member is refused with a diagnostic at the name, and the generated -case for an enum value the build did not know cannot collide with a schema -value spelled `UNKNOWN`, which on a case-insensitive disk would be the same -class file. That is work a run-time client never has to do and an adopter of a -compiler should never notice. +`description`, `self`, or `suspend`, a fragment `Variables` or `Companion`, +and an enum value `UNKNOWN` beside `Unknown`. + +A corpus of hostile names, in every position a name can take, is compiled +into a host file for each language. A sweep type-checks the generated code +one document at a time, in CI. A field spelled like a keyword is escaped, +and a name that would clash with a generated member is refused, with a +diagnostic at the name. The generated case for an enum value the build did +not know cannot collide with a schema value spelled `UNKNOWN`. On a +case-insensitive disk, that would be the same class file. + +That is work a run-time client never has to do. If you adopt a compiler, you +should never notice it. ## Why this matters more now When a coding agent writes the screens, as one increasingly does in 2026, a -compiler-checked data layer is what makes its screens safe to accept. A -diagnostic at the character in the file is exactly the loop an agent iterates -on best; a fragment beside the view is local reasoning an agent can hold in -one window; structural masking means a view cannot grow a dependency on a -field it did not declare, however the code was produced. -[fate](https://github.com/nkzw-tech/fate), a data client for the web released -in 2026 that takes Relay's ideas without requiring GraphQL, answered a request -for a compiler with "we have agents" ([its issue -20](https://github.com/nkzw-tech/fate/issues/20)) and checks nothing at build -time. The argument runs the other way: a planner at run time is weaker for -agents, not stronger, because the only thing that tells an agent its fragment -is wrong is a user. Baton ships [a page for agents](../recipes/agents.md) that +compiler-checked data layer is what makes those screens safe to accept. A +diagnostic at the character, in the file, is the loop an agent iterates on +best. A fragment beside the view is local reasoning an agent can hold in one +window. Structural masking means a view cannot grow a dependency on a field +it did not declare, however the code was produced. + +[fate](https://github.com/nkzw-tech/fate) is a data client for the web, +released in 2026, that takes Relay's ideas without requiring GraphQL. Asked +for a compiler, the author answered "we have agents" ([its issue +20](https://github.com/nkzw-tech/fate/issues/20)). It checks nothing at +build time. + +The argument runs the other way. A planner at run time is weaker for agents, +not stronger, because the only thing that tells an agent its fragment is +wrong is a user. Baton ships [a page for agents](../recipes/agents.md) that says what to read, the shape of a screen, what never to write, and what to -run, and the compiler is the reviewer that never gets tired. +run. The compiler is the reviewer that never gets tired. ## What it costs -A Rust toolchain for Baton's own development; a prebuilt binary and a build -step for adopters, with a plugin trust prompt in Xcode the first time; a -pinned revision of the front end that Baton advances on its own schedule; and -a schema checked into the build, so that a change to it is a change the build -sees. Nothing is configurable at run time that could be decided at build time, -which an app with a genuinely dynamic schema would find too strict, and such -an app is not who this is for. - -In exchange, the runtime is small enough to read in an afternoon and to -vendor, a wrong field is a build error with the fix suggested, the cache -cannot be misconfigured because there is nothing to configure, and the same -compiler drives both platforms. The compiler decides; the runtime executes +The cost is a Rust toolchain, for Baton's own development, and a prebuilt +binary and a build step, for adopters, with a plugin trust prompt in Xcode +the first time. It is also a pinned revision of the front end, which Baton +advances on its own schedule, and a schema checked into the build, so a +change to it is a change the build sees. + +Nothing is configurable at run time that could be decided at build time. An +app with a genuinely dynamic schema would find that too strict, and such an +app is not who this is for. + +In exchange, the runtime is small enough to read in an afternoon, and to +vendor. A wrong field is a build error, with the fix suggested. The cache +cannot be misconfigured, because there is nothing to configure. The same +compiler drives both platforms. The compiler decides. The runtime executes ([the principle](../principles/compiler-decides.md)). diff --git a/docs/articles/baton-beside-fate.md b/docs/articles/baton-beside-fate.md index 4b81920c..afd72c6c 100644 --- a/docs/articles/baton-beside-fate.md +++ b/docs/articles/baton-beside-fate.md @@ -1,42 +1,52 @@ # Baton beside fate -The most instructive comparison for Baton is not with the clients it replaces -but with one that started from the same place and turned the other way. -[fate](https://github.com/nkzw-tech/fate) is Christoph Nakazawa's data client -for the web: 0.1.0 in December 2025, 1.0 in May 2026, read here from its -repository at 1.7.7 in October 2026, written in practice by one author. It -takes Relay's three insights, a normalized cache, declarative data -dependencies and views colocated with their data, and removes GraphQL as the -adoption barrier. Baton takes the same three insights and keeps the compiler. -Where the two agree, they agree on mechanisms Baton had already settled, which -is evidence. Where they differ, they differ on exactly the points Baton's -principles decide, which is the clearest statement of what those principles -are for. And three of fate's mechanisms are worth watching, each with the -trigger that would bring a version of it into Baton. +The useful comparison is not with the clients Baton replaces, but with one +that started in the same place and turned the other way. + +[fate](https://github.com/nkzw-tech/fate) is Christoph Nakazawa's data +client for the web. 0.1.0 shipped in December 2025, and 1.0 shipped in May +2026. This page reads the repository at 1.7.7, in October 2026. In practice, +one author writes it. + +fate takes Relay's three insights and removes GraphQL, so GraphQL is not the +adoption barrier. The insights are a normalized cache, declarative data +dependencies, and views colocated with their data. Baton takes the same +three and keeps the compiler. + +Where the two agree, they agree on mechanisms Baton had already settled, and +that is evidence. Where they differ, they differ on exactly the points +Baton's principles decide, and that is the clearest statement of what those +principles are for. Three of fate's mechanisms are worth watching. Each has +a trigger that would bring a version of it into Baton. ## What fate is -A view in fate is a plain object literal, `view()({ title: true, author: -UserView })`, and a spread is an object spread. A planner at run time flattens -the views of a screen into dotted field paths and sends them over fate's own -wire protocol, a `POST` with a `byId` and a `list` root per type; tRPC and -GraphQL transports sit beside it. There is no compile-time check; asked for a -compiler, the author answered that agents fill that role ([its issue -20](https://github.com/nkzw-tech/fate/issues/20)). Mutations are React Actions -with a partial optimistic record, and an `insert` places a created entity into -the lists it matches by a heuristic. `useLiveView` is field-scoped server push -over one server-sent-events stream. Persistence is IndexedDB with an age per -request, and a durable offline mutation journal with idempotency receipts on -the server. Every template ships an `AGENTS.md`, and the docs are copied into -the npm package so an agent reads them from `node_modules`. - -It is a serious piece of work, and it is for the web. Nothing below is a +A view in fate is a plain object literal, `view()({ title: true, +author: UserView })`, and a spread is an object spread. A planner at run time +flattens the views of a screen into dotted field paths and sends them over +fate's own wire protocol: a `POST` with a `byId` and a `list` root per type. +tRPC and GraphQL transports sit beside it. + +There is no compile-time check. Asked for a compiler, the author answered +that agents fill that role ([its issue +20](https://github.com/nkzw-tech/fate/issues/20)). + +Mutations are React Actions, with a partial optimistic record. An `insert` +places a created entity into the lists it matches, by a heuristic. +`useLiveView` is field-scoped server push over one server-sent-events +stream. Persistence is IndexedDB, with an age per request, and a durable +offline mutation journal with idempotency receipts on the server. + +Every template ships an `AGENTS.md`, and the docs are copied into the npm +package, so an agent reads them from `node_modules`. + +fate is a serious piece of work, and it is for the web. Nothing below is a verdict on it there. The comparison is about design. ## Where the two agree -Each row is a mechanism fate chose from Relay's side of the line and Baton had -chosen already, with the record that says so. +Each row is a mechanism fate chose from Relay's side of the line. Baton had +already chosen it. The cell names the record that says so. | fate | Baton | |---|---| @@ -49,119 +59,129 @@ chosen already, with the record that says so. | Masking enforced: a reference carries the tags of the views spread into it, and a read without the tag throws | Masking as the shape of the type: the lens has no accessor for what it did not declare ([the principle](../principles/fragment-is-a-lens.md)) | | Mutations as actions | An action, in the terminology | -Two clients that started from Relay and never spoke arrived at the same buffer -size, the same optimistic model, the same answer to a refetch that does not -fill a hole, and the same refusal to hydrate from a foreign snapshot. That is -the part of Relay that is about data rather than React, and it is the part -Baton kept. +Two clients started from Relay and never spoke. They arrived at the same +buffer size, the same optimistic model, the same answer to a refetch that +does not fill a hole, and the same refusal to hydrate from a foreign +snapshot. That is the part of Relay that is about data rather than React, +and the part Baton kept. ## Where Baton does not follow -Four of fate's choices are the ones Baton's principles rule out, and each -would have been a new concept in a closed inventory ([the +Four of fate's choices are the ones Baton's principles rule out. Each would +have been a new concept in a closed inventory ([the principle](../principles/what-earns-a-concept.md)). A principle has no -reopening condition, which is the difference between it and a decision. - -- **A planner at run time, with agents in place of a compiler.** Baton's rule - is that the compiler decides and the runtime executes. A diagnostic at the - character in the file is the loop an agent iterates on best, so the "we have - agents" answer runs the other way: a run-time planner is weaker for agents, - not stronger, because nothing tells the agent its view is wrong until a user - does. +reopening condition. That is the difference between a principle and a +decision. + +- **A planner at run time, with agents in place of a compiler.** Baton's + rule is that the compiler decides and the runtime executes. A diagnostic + at the character, in the file, is the loop an agent iterates on best. So + the "we have agents" answer runs the other way. A run-time planner is + weaker for agents, not stronger. Nothing tells the agent its view is wrong + until a user does. - **Automatic list insertion by heuristic.** A guess about which lists a new - record belongs in is a policy in the store. In Baton an edge directive names - its connection ([the + record belongs in is a policy in the store. In Baton, an edge directive + names its connection ([the decision](../decisions/connections-own-their-edges.md)). -- **An offline mutation journal.** Baton is not an offline sync engine and - says so on its first page. Offline sync sits on the watch list that +- **An offline mutation journal.** Baton is not an offline sync engine, and + it says so on its first page. Offline sync sits on the watch list that principle keeps outside the plan, behind its trigger. - **A wire protocol of its own.** Baton asks nothing of a server beyond the - GraphQL specification, and a Swift "views as objects" surface in fate's - image would be a second way to write a document beside the GraphQL in the - markers, which the records refuse as two constructs for one thing ([the - decision](../decisions/marker-macros.md)). + GraphQL specification. A Swift "views as objects" surface, in fate's + image, would be a second way to write a document, beside the GraphQL in + the markers. The records refuse that, as two constructs for one thing + ([the decision](../decisions/marker-macros.md)). ## Three mechanisms to watch The honest part of a comparison is what the other side does better, and -whether it is wanted. Each of these has a trigger, written here, that would +whether you want it. Each of these has a trigger, written here, that would bring a version of it into Baton. -**Entity-level fill.** When a record lacks a field a view needs, fate fetches -only the missing paths of that one record, by id. Baton's heal refetches the -owning operation whole. The composition, if a screen ever needs it, uses what -exists: heal through the fragment's `@refetchable` query, or a lookup, when -one exists, and the owning operation otherwise. No new concept, a narrower -choice of which operation the heal runs. The trigger is a measured heal that -refetches a screen's operation for one row's field. +**Entity-level fill.** When a record lacks a field a view needs, fate +fetches only the missing paths of that one record, by id. Baton's heal +refetches the owning operation whole. If a screen ever needs the narrower +fetch, the composition uses what already exists: heal through the fragment's +`@refetchable` query, or a lookup, when one exists, and the owning operation +otherwise. That is no new concept: a narrower choice of which operation +the heal runs. The trigger is a measured heal that refetches a screen's +operation for one row's field. **Write ordering by request start.** fate keeps a generation per record and per list, stamped when the request started, and drops an older response's -write over a newer one's. Baton commits in completion order, and the runtime +write over a newer one's. Baton commits in completion order. The runtime contract says nothing about ordering between operations, so a slow `storeAndNetwork` fetch that started first can land after a quicker refetch -that started later. The cost of fate's answer is a sequence per written slot, -which sits beside a refusal Baton has already made, an age per record or per -field; the argument would have to show it is an ordering, not an age. The -trigger is a fixture reproducing the overwrite on a real screen. - -**Fragment-declared liveness.** `useLiveView` declares on the view that its -fields are pushed. `@live` is a proposal in GraphQL's working group rather -than part of the specification, and Relay's `@live` marks a client resolver, -not a server push ([Relay's live -fields](https://relay.dev/docs/guides/relay-resolvers/live-fields/)), so the -composition today is a subscription beside the query, which the transport and -the handle already carry. The trigger is an adopter's server that pushes -entity updates and a screen where one subscription per fragment proves +that started later. The cost of fate's answer is a sequence per written +slot, which sits beside a refusal Baton has already made, an age per record +or per field. The argument would have to show it is an ordering, not an +age. The trigger is a fixture reproducing the overwrite on a real screen. + +**Fragment-declared liveness.** `useLiveView` declares, on the view, that +its fields are pushed. `@live` is a proposal in GraphQL's working group, not +part of the specification. Relay's `@live` marks a client resolver, not a +server push ([Relay's live +fields](https://relay.dev/docs/guides/relay-resolvers/live-fields/)). So the +composition today is a subscription beside the query, which the transport +and the handle already carry. The trigger is an adopter's server that pushes +entity updates, and a screen where one subscription per fragment proves awkward. ## Two things taken -Two things came out of the reading, neither a change to the runtime. +Two things came out of the reading. Neither is a change to the runtime. -The first is a page. fate's `AGENTS.md` per template is the right idea, and +The first is a page. fate's `AGENTS.md` per template is the right idea. Baton's version is [the agents recipe](../recipes/agents.md): what to read, the shape of a screen, what never to write, what to run, and a review -checklist. Baton's page points the agent at the compiler's diagnostics, which -is the loop fate lacks. +checklist. Baton's page points the agent at the compiler's diagnostics, +which is the loop fate lacks. The second is a proof. fate's GraphQL template is a Pothos and Yoga server over Prisma and Postgres, with `nodes(ids:)`, cursor connections, and live -nodes and connections over graphql-sse: standard GraphQL. On 9 October 2026 -the template was run as it ships, with no change, and Baton's documents were -sent to it; the responses it recorded are fixtures under +nodes and connections over graphql-sse. That is standard GraphQL. On 9 +October 2026 the template was run as it ships, with no change, and Baton's +documents were sent to it. The responses it recorded are fixtures under [`spec/fate/`](../../spec/fate/README.md), proven by a Swift test target and -the Kotlin harness. Two pages of a posts connection, one post by `node(id:)`, -a mutation signed in as the seeded user, and the event stream of a live post. -Baton ran against fate's server unchanged, because it asks nothing of a server -that the specification does not. The recording also found the template's limit -for a normalized client: its live node answers a JSON scalar keyed by the -database id rather than typed fields under the post's global id, so its events -land at the subscription root and leave the post's record as it was. That is a -fact about the shape of the schema, and the fixture says it. +the Kotlin harness: two pages of a posts connection, one post by `node(id:)`, +a mutation signed in as the seeded user, and the event stream of a live +post. + +Baton ran against fate's server unchanged, because it asks nothing of a +server that the specification does not. The recording also found the +template's limit for a normalized client: its live node answers a JSON +scalar keyed by the database id, rather than typed fields under the post's +global id. So its events land at the subscription root and leave the post's +record as it was. That is a fact about the shape of the schema, and the +fixture says it. ## What the comparison says fate shows what Relay's insights look like with the language removed: a smaller barrier to entry, a planner on the client, heuristics where the -compiler would have had facts, and a protocol of its own. Baton shows what -they look like with the compiler kept and the runtime deleted: one request per -screen assembled at build time, a wrong field as a build error, a lens that -cannot read what it did not declare, and any server that follows the -specification. The two agree wherever the question is about data and disagree -wherever it is about who decides. Baton's answer is the compiler, and the -records and principles linked above are the price of that answer, paid once. +compiler would have had facts, and a protocol of its own. + +Baton shows what they look like with the compiler kept and the runtime +deleted: one request per screen, assembled at build time, a wrong field as a +build error, a lens that cannot read what it did not declare, and any server +that follows the specification. + +The two agree wherever the question is about data. They disagree wherever it +is about who decides. Baton's answer is the compiler. The records and +principles linked above are the price of that answer, paid once. ## Sources fate was read from [its repository](https://github.com/nkzw-tech/fate) at -commit `15af87c`, version 1.7.7, on 9 October 2026: the README for what a -view, a spread and a live view are and for the transports; the client at +commit `15af87c`, version 1.7.7, on 9 October 2026. The README is the source +for what a view, a spread, and a live view are, and for the transports. The +client at [`packages/fate/src/client.ts`](https://github.com/nkzw-tech/fate/blob/15af87c/packages/fate/src/client.ts) -for the release buffer, the optimistic layer, the stalled request, the -hydration scope, the list insertion and the write generations; the templates -for their `AGENTS.md` and for the GraphQL server -[`spec/fate/`](../../spec/fate/README.md) was recorded from; the repository's -releases for the dates and the commit log for the authorship; and [issue -20](https://github.com/nkzw-tech/fate/issues/20) for the compiler question. -Baton's side is the records linked inline. +is the source for the release buffer, the optimistic layer, the stalled +request, the hydration scope, the list insertion, and the write generations. +The templates are the source for their `AGENTS.md`, and for the GraphQL +server that [`spec/fate/`](../../spec/fate/README.md) was recorded from. The +repository's releases are the source for the dates. The commit log is the +source for the authorship. [Issue +20](https://github.com/nkzw-tech/fate/issues/20) is the source for the +compiler question. Baton's side is the records linked inline. diff --git a/docs/articles/how-a-fast-store-stays-honest.md b/docs/articles/how-a-fast-store-stays-honest.md index 639fbdcc..2c0b1fe6 100644 --- a/docs/articles/how-a-fast-store-stays-honest.md +++ b/docs/articles/how-a-fast-store-stays-honest.md @@ -1,159 +1,180 @@ # How a fast store stays honest -A normalized store is a long chain of clever steps: a one-pass tokenizer -driven by a plan, storage keys interned to slots, records shared by many -operations, optimistic overlays rebased on every commit, a persisted image -read back on the next launch, strings kept as byte ranges until something -needs them. Each step is a place to lose a value, merge two records that are -not the same thing, or show one operation's data through another's selection. -The clients that broke in production broke here: identity without stable keys, -cache misses after a key-generator change, stale data served as fresh. Speed -work is where a store earns distrust, and Baton's answer is not to go slower. -It is to make every fast path prove it gives the same answer as the slow one, -and to publish the proof. +A normalized store is a long chain of clever steps: a one-pass tokenizer, +driven by a plan; storage keys interned to slots; records shared by many +operations; optimistic overlays, rebased on every commit; a persisted image, +read back on the next launch; and strings kept as byte ranges until +something needs them. + +Each step is a place to lose a value, to merge two records that are not the +same thing, or to show one operation's data through another's selection. The +clients that broke in production broke here: identity without stable keys, +cache misses after a key-generator change, and stale data served as fresh. + +Speed work is where a store earns distrust. Baton's answer is not to go +slower, but to make every fast path prove it gives the same answer as the +slow one, and to publish the proof. ## The response is the oracle -For data this client fetched, the truth is the response as it arrived. That is -the whole rule. Whatever path a value takes, through the ingest, the interned -slots, an optimistic overlay or the image, reading it through a lens must -equal reading the raw response at the same path. Anything faster than the -naive reading has to reproduce it exactly, and a fixture proves that it does -([the principle](../principles/response-is-the-oracle.md)). +For data this client fetched, the truth is the response as it arrived. That +is the whole rule. A value may pass through the ingest, the interned slots, +an optimistic overlay, or the image. Reading it through a lens must equal +reading the raw response at the same path. Anything faster than the naive +reading has to reproduce it exactly. A fixture proves that it does ([the +principle](../principles/response-is-the-oracle.md)). A naive reference reader exists only to be the oracle. It walks a response's -JSON, as `JSONSerialization` reads it, by the same resolved plan a lens would -follow, and it walks the store's records by the same plan, and the two walks -must yield the same leaves for every response in the specification that has -data and a document that reads it. They are compared after the commit; from -the image, in a second store over the same file; under an optimistic layer -that overrides one leaf, and again after that layer is reverted; and for a -deferred or streamed response, as its first part with the later parts merged -in at their paths. +JSON, as `JSONSerialization` reads it, by the same resolved plan a lens +would follow, and walks the store's records by the same plan. The two walks +must yield the same leaves, for every response in the specification that has +data and a document that reads it. + +They are compared after the commit, from the image in a second store over +the same file, and under an optimistic layer that overrides one leaf, and +again after that layer is reverted. For a deferred or streamed response, +they are compared as its first part, with the later parts merged in at their +paths. The opposite failure is to trust the chain because each step was tested in -isolation. The bugs are between the steps, and the oracle is the only test -that spans them. +isolation. The bugs are between the steps. The oracle is the only test that +spans them. ## The fixtures are server responses, not tests -The specification under `spec/` holds, in files no language owns, the schemas, -the authors' documents with their client directives, the recorded responses, -the records a store must hold after each one, and the reads a generated lens -must answer ([the specification](../../spec/README.md)). Beside each response -the oracle reads, a store dump freezes what the commit must produce: every -record by key, links written as Relay writes them, sorted, one record a line. +The specification under `spec/` holds the files no language owns: the +schemas, the authors' documents with their client directives, the recorded +responses, the records a store must hold after each one, and the reads a +generated lens must answer ([the specification](../../spec/README.md)). + +Beside each response the oracle reads, a store dump freezes what the commit +must produce: every record by key, links written as Relay writes them, +sorted, one record a line. + Scripts freeze behaviour over time: phases and fetches, which commits notify which slots, lifetime through a release buffer, an optimistic layer applied, -resolved and reverted, connection pages merged, the availability check across -a relaunch, the heal, a stream growing in its slot. A list of malformed -responses says what outcome each must have. +resolved, and reverted, connection pages merged, the availability check +across a relaunch, the heal, and a stream growing in its slot. A list of +malformed responses says what outcome each must have. The rule of the project is that these are server responses, not tests. A -change to the store, the tokenizer or the record layout is frozen under them, -and none is edited to fit code; a reviewed change to identity or layout -rewrites the dumps through one command, in a commit that says so. That is why -the store's internals could change release after release in the project's -first two weeks, slots numbered by the process, the change set reshaped, -strings kept as bytes, without the answer to any read changing. +change to the store, the tokenizer, or the record layout is frozen under +them, and none is edited to fit code. A reviewed change to identity or layout +rewrites the dumps through one command, in a commit that says so. + +That is why the store's internals could change release after release in the +project's first two weeks: slots numbered by the process, the change set +reshaped, and strings kept as bytes. The answer to any read did not change. Both runtimes pass the same files. The Kotlin runtime's first milestone was -every case's dump matching byte for byte, before it had a lens, a handle or a -transport, and the scripts run on it through the same scripted transport the +every case's dump matching byte for byte, before it had a lens, a handle, or +a transport. The scripts run on it through the same scripted transport the Swift tests use. ## Relay's own tests, measured against -Relay's runtime tests state, for a document, variables and a payload, the +Relay's runtime tests state, for a document, variables, and a payload, the records Relay's store holds afterwards. A harvest records those calls under -Relay's own test suite and translates the records into Baton's keys, so that -Baton's store can be compared with Relay's on inputs nobody here chose. The -first harvest of the normalizer's tests gave 60 cases: 13 where Baton agreed, -10 where it did not, and the rest using features Baton does not have or a -store the test seeded by hand. +Relay's own test suite and translates the records into Baton's keys. Baton's +store can then be compared with Relay's, on inputs nobody here chose. + +The first harvest of the normalizer's tests gave 60 cases: 13 where Baton +agreed, 10 where it did not, and the rest using features Baton does not +have, or a store the test seeded by hand. The decision was to measure, not obey ([the -decision](../decisions/relays-tests-are-measured-not-obeyed.md)). A case Baton -disagrees with carries a status, a note saying how the two differ, and once -decided, what is to be done; it runs as a known issue, and it fails when it -starts passing, so that a status is revisited rather than parked. Where -GraphQL does not specify the behaviour, Baton documents it and copies Relay -unless that costs performance. A test that seeded its store by hand is -harvested as the response the seeded store stands for, and kept only when -Relay, normalizing that response into an empty store, answers as it did on the -seeded one: Baton is compared with Relay on a response, never on a store built -by hand. The `@stream` cases of that harvest compile and pass on both runtimes -since 0.16.0. +decision](../decisions/relays-tests-are-measured-not-obeyed.md)). A case +Baton disagrees with carries a status, a note saying how the two differ, +and, once decided, what is to be done. It runs as a known issue and fails +when it starts passing, so a status is revisited rather than parked. + +Where GraphQL does not specify the behaviour, Baton documents it and copies +Relay, unless that costs performance. A test that seeded its store by hand +is harvested as the response the seeded store stands for, and kept only when +Relay, normalizing that response into an empty store, answers as it did on +the seeded one. Baton is compared with Relay on a response, never on a store +built by hand. The `@stream` cases of that harvest compile and pass on both +runtimes since 0.16.0. ## Honest data is part of the same rule Nullability is what the schema says. A non-null field is a non-optional -property, a nullable one is optional, and the lens types follow the schema -exactly, so product code stops handling permutations that never happen and -stops pretending a value is there when it is not. `@required` moves a null to -where the product wants it; `@catch` turns a field's error into a `Result`; -`@throwOnFieldError` fails the operation or the spread. Field errors are -stored beside the field, in memory and in the image, so a cached read sees the -same error the network read saw, and the fixtures hold responses with errors -on them. +property. A nullable one is optional. The lens types follow the schema +exactly, so product code stops handling permutations that never happen, and +stops pretending a value is there when it is not. + +`@required` moves a null to where the product wants it, `@catch` turns a +field's error into a `Result`, and `@throwOnFieldError` fails the operation +or the spread. Field errors are stored beside the field, in memory and in the +image, so a cached read sees the same error the network read saw. The +fixtures hold responses with errors on them. Missing data is the one case the types cannot express, and it is never invented. It arises only when another operation retargets a link to a record -fetched with fewer fields. Then a lens returns the type's zero value for that -read, the environment logs a `missing` event naming the record and the field, -the owning operation is marked stale and refetched once, and a debug build is -loud about it. A server response that omits a field the operation selected -fails the fetch as malformed rather than leaving a hole. The principle has the -list of small lies clients tell and the mechanism that refuses each ([Honest +fetched with fewer fields. Then a lens returns the type's zero value for +that read, the environment logs a `missing` event naming the record and the +field, and the owning operation is marked stale and refetched once. A debug +build is loud about it. + +A server response that omits a field the operation selected fails the fetch +as malformed, rather than leaving a hole. The principle has the list of +small lies clients tell, and the mechanism that refuses each ([Honest data](../principles/honest-data.md)). ## Claims are measured -Every advertised number has a bench behind it, and +Every advertised number has a bench behind it. [BENCHMARKS.md](../../BENCHMARKS.md) records it with the revision, the -machine, the OS and the date, appended to and never edited. A claim without a -bench does not ship: when a plan proposed a gate of 50 µs for a derived -verdict and the bench measured 567 µs untracked, the step parked at its first -half and the file says so; when a closure stored on a root cost the collection -walk 200 µs more, it was refused and recorded. CI gates structure rather than -wall-clock time, because wall-clock time depends on the machine: the bytes of -generated code per accessor line, a file of deterministic counts such as the -notifications a commit sends, and bounds on the samples' type-checking time. -It also reports public API breakage against a tagged release, in the log and -not yet enforced, since before 1.0 a breaking change is expected and listed in -the changelog. +machine, the OS, and the date. The file is appended to, and never edited. A +claim without a bench does not ship. + +When a plan proposed a gate of 50 µs for a derived verdict, and the bench +measured 567 µs untracked, the step parked at its first half, and the file +says so. When a closure stored on a root cost the collection walk 200 µs +more, it was refused and recorded. + +CI gates structure rather than wall-clock time, because wall-clock time +depends on the machine. It gates the bytes of generated code per accessor +line, a file of deterministic counts such as the notifications a commit +sends, and bounds on the samples' type-checking time. It also reports public +API breakage against a tagged release, in the log, and not yet enforced. +Before 1.0 a breaking change is expected, and listed in the changelog. The benches catch regressions the tests cannot. When the heal was built, the -untracked lens read rose from 25 ns to 33 ns, because the report of a missing -slot and the heal's call had been inlined into every accessor; the bench -noticed, the call went out of line, and the read is 25.1 ns again. A -regression in the collection walk was bisected to its commit and recorded +untracked lens read rose from 25 ns to 33 ns. The report of a missing slot, +and the heal's call, had been inlined into every accessor. The bench +noticed. The call went out of line, and the read is 25.1 ns again. + +A regression in the collection walk was bisected to its commit and recorded under its own heading. A collection pass's median, 3.8 ms over about 9,000 -records on an M1 Pro, stands in the file as the trigger that would reopen the -decision to run it on the main actor. +records on an M1 Pro, stands in the file as the trigger that would reopen +the decision to run it on the main actor. ## The build that finds what the tests miss -Two more habits belong here because they found real defects. Tests run in -release builds as well as debug, because the optimizer merges identical -getters and the key paths that serve as invalidation channels then collide; -the spike that measured the design found exactly that, and the rule is written -into the project's instructions. And generated code is compiled against a -corpus of hostile schema names, fields spelled like keywords and types named -like generated members, by a sweep that runs in CI, so that a schema that -calls a field `init` does not find the bug in production. +Two more habits belong here, because they found real defects. Tests run in +release builds as well as debug. The optimizer merges identical getters, and +the key paths that serve as invalidation channels then collide. The spike +that measured the design found exactly that. The rule is written into the +project's instructions. + +Generated code is also compiled against a corpus of hostile schema names: +fields spelled like keywords, and types named like generated members. A +sweep runs that in CI, so a schema that calls a field `init` does not find +the bug in production. ## What this buys an adopter -A store that is fast because it skips steps, and a proof for every skipped -step that the answer did not change. A second runtime held to the same files, -so the Android app and the iOS app normalize the same response into the same -records and surface the same error in the same place. A record of every -number, with what it was measured on, and the harness to rerun it. And a -vocabulary for what the store does when something is wrong, stale, failed, -missing, in error, each a value a view can read rather than a surprise it -cannot. +An adopter gets a store that is fast because it skips steps, and a proof, +for every skipped step, that the answer did not change. A second runtime is +held to the same files, so the Android app and the iOS app normalize the +same response into the same records and surface the same error in the same +place. + +There is a record of every number, with what it was measured on, and the +harness to rerun it, and a vocabulary for what the store does when something +is wrong: stale, failed, missing, in error. Each is a value a view can read, +rather than a surprise it cannot. Every claim can be checked. That is the second of the three commitments in -[the vision](../vision.md), and it is the one that lets the other two be -believed. +[the vision](../vision.md), and the one that lets the other two be believed. diff --git a/docs/articles/materialization-is-the-bill.md b/docs/articles/materialization-is-the-bill.md index 466b966f..2fa6f4bb 100644 --- a/docs/articles/materialization-is-the-bill.md +++ b/docs/articles/materialization-is-the-bill.md @@ -1,22 +1,27 @@ # Materialization is the bill -Baton is faster than Apollo iOS and Apollo Kotlin on the same data, and the +Baton is faster than Apollo iOS and Apollo Kotlin on the same data. The reason is not a cleverer store. On the one step the stores share, writing -records into an empty cache, Apollo iOS takes 1.39 ms and Baton 1.24 ms. The -reason is three steps Apollo takes on every response or every read that Baton -does not take at all: building a model of the operation from the bytes, -rebuilding it from the store on every read, and reading the store -asynchronously. Each is work a view never asked for. This page itemizes the -bill, on a Mac and on a phone, with the caveats that go with it. +records into an empty cache, Apollo iOS takes 1.39 ms and Baton takes 1.24 +ms. + +The reason is three steps Apollo takes, on every response or every read, +that Baton does not take at all. Apollo builds a model of the operation from +the bytes, rebuilds that model from the store on every read, and reads the +store asynchronously. Each is work a view never asked for. + +This page itemizes the bill, on a Mac and on a phone, with the caveats that +go with it. ## The bill on a Mac -The fixture is a recorded page of the Rick and Morty API: 686,254 bytes, about -6,500 JSON objects, 899 records after normalization. Both clients key entities -by `id`; Apollo's own query adds `__typename` to every object, so its response -is 849,101 bytes. Apple M1 Pro, macOS 26.5.2, release builds, 2 October 2026, -Apollo iOS 2.4.0 with its in-memory cache, configured as its documentation -says ([BENCHMARKS.md](../../BENCHMARKS.md), the 0.1.0 entry). +The fixture is a recorded page of the Rick and Morty API: 686,254 bytes, +about 6,500 JSON objects, and 899 records after normalization. Both clients +key entities by `id`. Apollo's own query adds `__typename` to every object, +so its response is 849,101 bytes. The numbers are from an Apple M1 Pro, +macOS 26.5.2, release builds, 2 October 2026, against Apollo iOS 2.4.0 with +its in-memory cache, configured as its documentation says +([BENCHMARKS.md](../../BENCHMARKS.md), the 0.1.0 entry). | Step | Baton | Apollo iOS 2.4 | |---|---|---| @@ -28,35 +33,39 @@ says ([BENCHMARKS.md](../../BENCHMARKS.md), the 0.1.0 entry). | One field | 26 ns, untracked | 296 ns, after that rebuild | | `JSONSerialization` alone, for scale | 4.47 ms | 5.73 ms | -Read the rows, not the ratios. The store is not where Apollo's time goes: its -publish is as fast as Baton's commit. The 317 ms is the materialization, the -response parsed into a tree and the tree walked into generated models and -cache records, and it matches a report the maintainers' own tracker already -held: about 6 ms of JSON deserialization against about 159 ms of type mapping -for an 874 KB response +Read the rows, not the ratios. The store is not where Apollo's time goes. +Its publish is as fast as Baton's commit. The 317 ms is the materialization: +the response parsed into a tree, then the tree walked into generated models +and cache records. It matches a report the maintainers' own tracker already +held: about 6 ms of JSON deserialization, against about 159 ms of type +mapping, for an 874 KB response ([apollo-ios#3600](https://github.com/apollographql/apollo-ios/issues/3600), -2024). Then a read pays the mapping again: 228 ms to execute the query against -the cache into a new model tree before a view can read one field. And a -refetch that changes nothing costs 3.99 ms and a new tree, because nothing -compares the old one with the new. +2024). + +Then a read pays the mapping again: 228 ms to execute the query against the +cache, into a new model tree, before a view can read one field. A refetch +that changes nothing costs 3.99 ms and a new tree, because nothing compares +the old one with the new. The last row is the one to keep. Tokenizing the bytes is cheap. Foundation -parses the whole response into dictionaries in 4.47 ms; Baton tokenizes and -normalizes it in 2.14 ms, faster than the general-purpose parser, because the -plan tells it which fields to expect, in which order, with which scalar types, -and it builds no tree on the way. The cost that dominates every client in this -comparison is what is built after the tokens, and Baton builds nothing a view -did not read. +parses the whole response into dictionaries in 4.47 ms. Baton tokenizes and +normalizes it in 2.14 ms, faster than the general-purpose parser, because +the plan tells it which fields to expect, in which order, with which scalar +types. It builds no tree on the way. The cost that dominates every client in +this comparison is what is built after the tokens. Baton builds nothing a +view did not read. ## The bill on a phone -Apollo Kotlin is not Apollo iOS. Its write is faster by an order of magnitude, -and its 2026 normalized cache is the best disk cache in this comparison. The -shape is the same. Google Pixel 9, Tensor G4, Android 17, release builds, 7 -October 2026, Apollo Kotlin 5.2.0 with the normalized cache 1.0.9, the memory -cache, keys configured as its documentation says -([BENCHMARKS.md](../../BENCHMARKS.md), "Apollo Kotlin 5.2.0, same operation, -same graph"). +Apollo Kotlin is not Apollo iOS. Its write is faster by an order of +magnitude, and its 2026 normalized cache is the best disk cache in this +comparison. The shape is the same. + +The numbers are from a Google Pixel 9, Tensor G4, Android 17, release +builds, 7 October 2026, against Apollo Kotlin 5.2.0 with the normalized +cache 1.0.9, the memory cache, keys configured as its documentation says +([BENCHMARKS.md](../../BENCHMARKS.md), +"Apollo Kotlin 5.2.0, same operation, same graph"). | Step | Baton | Apollo Kotlin 5.2 | |---|---|---| @@ -65,33 +74,36 @@ same graph"). | From the store to something a view can read | A 1.2 ms availability check; the lens then reads slots | 25 ms to rebuild the query into models | | One field | 43 ns | 2 ns, after that rebuild | -Apollo parses the bytes into the generated models, writes the models back to a -map, and normalizes the map into records: 30 ms of the 44, more than three -frames at 120 Hz, off the main thread as its documentation requires. Reading -the query back rebuilds the model tree, 25 ms, and only then is a field a -property load. That property load is faster than Baton's lens read by -twenty-odd times, and the bench says so: where a field is read many times from -data already read, Apollo's model is the faster thing to hold. A screen that -reads every field of its twenty rows, 160 reads, pays 7 µs through the lens -against the 25 ms read that comes first on Apollo's side. - -Apollo's own published figures tell the same story at larger sizes. Its Kotlin -performance page measured about 800 ms to normalize a nested query of about -6,000 records into the memory cache and about 530 ms to read them back into -models, on a 2017 Galaxy S8, excluding JSON parsing. A community report -against Apollo iOS 2.1.2 measured a cold read of 4,922 records from SQLite at -6 seconds, later more than 9, against 0.72 seconds from memory ([the +Apollo parses the bytes into the generated models, writes the models back to +a map, and normalizes the map into records. That is 30 ms of the 44, more +than three frames at 120 Hz, off the main thread, as its documentation +requires. Reading the query back rebuilds the model tree, 25 ms. Only then +is a field a property load. + +That property load is faster than Baton's lens read by twenty-odd times, and +the bench says so. Where a field is read many times from data already read, +Apollo's model is the faster thing to hold. A screen that reads every field +of its twenty rows, 160 reads, pays 7 µs through the lens, against the 25 ms +read that comes first on Apollo's side. + +Apollo's own published figures tell the same story at larger sizes. Its +Kotlin performance page measured about 800 ms to normalize a nested query of +about 6,000 records into the memory cache, and about 530 ms to read them +back into models, on a 2017 Galaxy S8, excluding JSON parsing. A community +report against Apollo iOS 2.1.2 measured a cold read of 4,922 records from +SQLite at 6 seconds, later more than 9, against 0.72 seconds from memory +([the thread](https://community.apollographql.com/t/ios-sqlitenormalizedcache-cold-start-can-be-slower-than-reloading-data-from-backend/9344)). -Those are not numbers from this repository, and they are the same bill, -itemized by the people who pay it. +Those are not numbers from this repository, but the same bill, itemized by +the people who pay it. ## What a user feels Store benches are not screens. The end-to-end entry drives the Kotlin sample -and its Apollo Kotlin twin, the same two Compose screens over each client, on -the same Pixel 9 on 8 October 2026, against a server in the app's own process -so that every run gets the same bytes ([BENCHMARKS.md](../../BENCHMARKS.md), -"end to end on a phone"). +and its Apollo Kotlin twin, the same two Compose screens, over each client, +on the same Pixel 9, on 8 October 2026, against a server in the app's own +process, so that every run gets the same bytes +([BENCHMARKS.md](../../BENCHMARKS.md), "end to end on a phone"). | Scenario | Baton | Apollo Kotlin | |---|---|---| @@ -100,67 +112,74 @@ so that every run gets the same bytes ([BENCHMARKS.md](../../BENCHMARKS.md), | Tap Next to the second page's first frame | 40.1 ms | 56.1 ms | | Scrolling, frame CPU time at P50 / P90 | 3.09 / 7.31 ms | 3.07 / 7.32 ms | -Scrolling is a tie, and it should be: the rows are the same composables over +Scrolling is a tie, and it should be. The rows are the same composables over data already read, and neither client is on the scroll path. The differences -are where the clients are: the first frame after a launch, a tap into a detail -whose header is already in the store, a page turn. On Apollo's side the first -frame of a warm start and of a detail is a spinner, because its cache is read -off the main thread and the collected `watch()` starts at `null`; the data -followed 63 ms later in that run. On Baton's side the data is in the first -frame, because the read is synchronous. +are where the clients are: the first frame after a launch, a tap into a +detail whose header is already in the store, and a page turn. + +On Apollo's side, the first frame of a warm start, and of a detail, is a +spinner. Its cache is read off the main thread, and the collected `watch()` +starts at `null`. The data followed 63 ms later in that run. On Baton's +side, the data is in the first frame, because the read is synchronous. ## The three steps, and what replaces them -**No model tree.** The compiler emits a normalization plan per operation: one -selection per distinct selection set, each field with its storage key and its -kind. The ingest follows the plan through the bytes, off the main actor, keys -each object by its type's key fields, and writes a change set of slots. String -values stay as byte ranges into the response until the commit decides which -entry won, so a value the store already holds is compared as bytes and never -allocated. There is no `Codable`, no dictionary and no `Any` on the read path, -in either runtime. +**No model tree.** The compiler emits a normalization plan per operation: +one selection per distinct selection set, and each field has its storage key +and its kind. The ingest follows the plan through the bytes, off the main +actor, keys each object by its type's key fields, and writes a change set +of slots. +String values stay as byte ranges into the response until the commit decides +which entry won. A value the store already holds is compared as bytes and +never allocated. There is no `Codable`, no dictionary, and no `Any` on the +read path, in either runtime. **No re-execution.** Each slot of each record is an invalidation channel. A commit on the main actor writes the slots that changed, nothing for a slot whose value is equal, and notifies the observed fields that changed, once, -when the batch ends. A refetch of an unchanged page notifies nobody. A -connection page merges into one list and notifies once, on the connection, -rather than once per row on screen: 41 pages, 2,100 nodes, 41 notifications in -the 0.4.0 entry. An optimistic cycle, a layer applied, the server's answer -landing and the layer resolved, costs 413 µs on the Mac and notifies only what -differs in the end. +when the batch ends. A refetch of an unchanged page notifies nobody. + +A connection page merges into one list and notifies once, on the connection, +rather than once per row on screen: 41 pages, 2,100 nodes, 41 notifications, +in the 0.4.0 entry. An optimistic cycle, a layer applied, the server's +answer landing, and the layer resolved, costs 413 µs on the Mac and notifies +only what differs in the end. **No waiting.** Reads are synchronous on the main actor. A handle resolves -against the store before the first body runs, so a screen whose data is in the -store renders it in its first frame, and after a launch the image on disk is -read before the first body. The availability check that decides whether the -store can answer costs 115 µs for the whole fixture's plan on the Mac and 1.2 -ms on the Pixel 9. +against the store before the first body runs, so a screen whose data is in +the store renders it in its first frame. After a launch, the image on disk +is read before the first body. The availability check that decides whether +the store can answer costs 115 µs for the whole fixture's plan on the Mac, +and 1.2 ms on the Pixel 9. ## How the numbers are kept honest -Every number above is in [BENCHMARKS.md](../../BENCHMARKS.md) with the -revision, the machine, the OS and the date, and that file is appended to, -never edited. The comparison harnesses, `benchmarks/apollo-comparison` for -Swift and `kotlin/benchmarks/apollo-comparison` for Kotlin, are in the -repository with the commands that run them. CI gates structure rather than -wall-clock time: the bytes of generated code per accessor line, and a file of -deterministic counts, such as the notifications a commit sends, diffed on -every run. A claim without a bench does not ship. - -The caveats matter and they are in the file. The iOS numbers are from a Mac, -not a phone. It is one fixture, large and repetitive, chosen because it is -public and reproducible. Apollo iOS is measured with default options and no -SQLite; Apollo Kotlin with its memory cache, both clients called directly with -no network, no interceptor chain and no watcher; and Apollo's response is 24% -larger by construction on both. On the phone Baton's Kotlin runtime runs three -to four times slower than the same code on the JVM, which is ART's allocation -cost and the next thing the profile names. Where Apollo is ahead, the disk -policy on Kotlin, the install base and the deployment floor on iOS, [the +Every number above is in [BENCHMARKS.md](../../BENCHMARKS.md), with the +revision, the machine, the OS, and the date. That file is appended to, never +edited. The comparison harnesses are in the repository, with the commands +that run them: `benchmarks/apollo-comparison` for Swift, and +`kotlin/benchmarks/apollo-comparison` for Kotlin. + +CI gates structure rather than wall-clock time: the bytes of generated code +per accessor line, and a file of deterministic counts, such as the +notifications a commit sends, diffed on every run. A claim without a bench +does not ship. + +The caveats matter, and they are in the file. The iOS numbers are from a +Mac, not a phone, and from one fixture, large and repetitive, chosen because +it is public and reproducible. Apollo iOS is measured with default options and +no SQLite. Apollo Kotlin is measured with its memory cache. Both clients are +called directly, with no network, no interceptor chain, and no watcher. +Apollo's response is 24% larger by construction, on both. + +On the phone, Baton's Kotlin runtime runs three to four times slower than +the same code on the JVM. That is ART's allocation cost, and the next thing +the profile names. Where Apollo is ahead, the disk policy on Kotlin, the +install base, and the deployment floor on iOS, [the comparison](../comparison.md) says so. What the shape gives an app is not a ratio. It is a response in the store a few milliseconds after the bytes arrive, a refetch that changes one field -re-rendering one row, and a screen the app has seen rendering from the store -in its first frame. Those are the things a data layer can promise a product -team, and they are the things this one measures. +and re-renders one row, and a screen the app has seen, rendering from the +store in its first frame. Those are the things a data layer can promise a +product team, and they are the things this one measures. diff --git a/docs/articles/relay-without-the-read-side.md b/docs/articles/relay-without-the-read-side.md index a86a1484..83f8e3f4 100644 --- a/docs/articles/relay-without-the-read-side.md +++ b/docs/articles/relay-without-the-read-side.md @@ -1,154 +1,176 @@ # Relay without the read side -Baton keeps Relay's compiler and the write side of its store, and deletes the -read side instead of porting it. Half of Relay's runtime exists to answer one -question that React cannot answer for itself: which component read which -field, so that a change re-renders exactly those. SwiftUI's Observation and -Compose's snapshot state answer that question natively. Porting the machinery -that answers it would have been a second copy of something the platform -already does, paid for on every read. +Baton keeps Relay's compiler and the write side of its store, and deletes +the read side instead of porting it. + +Half of Relay's runtime exists to answer one question React cannot answer +for itself: which component read which field, so a change re-renders +exactly those. SwiftUI's Observation answers that question, and so does +Compose's snapshot state. Porting the machinery that answers it would have +been a second copy of something the platform already does, paid for on every +read. ## The question Relay's runtime answers Relay's store is a flat map of records. When a component reads a fragment, -Relay walks the records to build a snapshot of the fragment's data and -remembers the set of records the walk touched, the seen records. When a -payload is published, Relay intersects the updated record ids with every -subscription's seen records, re-reads the snapshots that overlap, compares the -new snapshot with the old one, recycles the subtrees that did not change so -that references stay stable, and re-renders the components whose snapshot -changed. When data is missing it suspends the component by throwing a promise. -A generator-based collector sweeps records no root reaches. +Relay walks the records and builds a snapshot of the fragment's data, +remembering the set of records the walk touched. Those are the seen records. + +When a payload is published, Relay intersects the updated record ids with +every subscription's seen records, re-reads the snapshots that overlap, and +compares each new snapshot with the old one. Subtrees that did not change +are recycled so their references stay stable, and the components whose +snapshot changed are re-rendered. When data is missing, Relay suspends the +component by throwing a promise. A generator-based collector sweeps records +no root reaches. None of that is GraphQL. It is the bookkeeping a runtime needs when the framework it serves cannot report what a render read. React's render is a -function call that returns elements; nothing on the way out says which +function call that returns elements. Nothing on the way out says which object's which property the function touched. So Relay built the tracking -itself, and the unit it can track is the fragment, with a field-level path +itself. The unit it can track is the fragment, with a field-level path behind a flag that is off by default. ## The frameworks that can answer it SwiftUI's Observation tracks, for each body evaluation, every property of every observable object the body read, and re-runs only the bodies whose -dependencies changed. Compose does the same through snapshot state: a read of -a state object inside a composition scope records the dependency, and a write -invalidates the scopes that read it. Apple's own guidance for a list is one -small observable object per item, so that one change re-runs one row ([the +dependencies changed. Compose does the same through snapshot state. A read +of a state object inside a composition scope records the dependency, and a +write invalidates the scopes that read it. + +Apple's own guidance for a list is one small observable object per item, so +that one change re-runs one row ([the principle](../principles/store-is-the-ui-state.md)). Those are exactly the -mechanisms a normalized store wants: a record is one small object, a field is -one property of it. - -So in Baton a record is an observable object and each of its slots is an -invalidation channel. A fragment compiles to a lens, a typed read-only view -over one record with one accessor per declared field, and the accessor is -where a read is registered: reading `issue.title` loads the slot and tells the -framework that this body depends on the `title` of `Issue:42`. A commit writes -the slots that changed and notifies the fields that changed. The runtime has -no subscriptions of its own, no snapshot to build, no seen record set to -intersect, and no second copy of the data to keep equal to the first. The body -that read a field is invalidated when that field of that record changes and at -no other time; a refetch that answers with the same bytes notifies nothing, -because the store compares before it notifies. +mechanisms a normalized store wants. A record is one small object, and a +field is one property of it. + +So in Baton a record is an observable object, and each of its slots is an +invalidation channel. A fragment compiles to a lens, a typed, read-only view +over one record, with one accessor per field you declared. The accessor is +where a read is registered. You read `issue.title`, which loads the slot and +tells the framework that this body depends on the `title` of `Issue:42`. + +A commit writes the slots that changed and notifies the fields that changed. +The runtime has no subscriptions of its own, no snapshot to build, no seen +record set to intersect, and no second copy of the data to keep equal to the +first. The body that read a field is invalidated when that field of that +record changes, and at no other time. A refetch that answers with the same +bytes notifies nothing, because the store compares before it notifies. ## What that costs, measured -The cost of tracking is Observation's and nobody else's. On an Apple M1 Pro -with macOS 26.5.2, on 2 October 2026, a tracked lens read inside a row body of -eight fields cost 553 ns per field, and the spike behind the design had -measured 534 ns for the same read against 554 ns for a property of an +The cost of tracking is Observation's, and nobody else's. On an Apple M1 Pro +with macOS 26.5.2, on 2 October 2026, a tracked lens read inside a row body +of eight fields cost 553 ns per field. The spike behind the design had +measured 534 ns for the same read, against 554 ns for a property of an `@Observable` class ([BENCHMARKS.md](../../BENCHMARKS.md), the 0.1.0 entry). -An untracked read, outside any body, is a slot load, 26 ns. On a Google Pixel -9 through the Kotlin runtime a field costs 47 ns. A commit with one field -changed among twenty observed rows invalidates that one row, and the base -commit and the edited commit together cost 332 µs on the Mac. The same payload -committed again, nothing changed, costs the main actor 165 µs and no view. + +An untracked read, outside any body, is a slot load: 26 ns. On a Google +Pixel 9, through the Kotlin runtime, a field costs 47 ns. A commit with one +field changed among twenty observed rows invalidates that one row. The base +commit and the edited commit together cost 332 µs on the Mac, and the same +payload committed again, nothing changed, costs the main actor 165 µs and no +view. The comparison that matters is with the clients that do the work by hand. -Apollo iOS and Apollo Kotlin keep a watcher per query that remembers the field -keys the query's last execution touched; a write that overlaps re-executes the -whole operation and delivers a new model tree, on iOS with no equality check -at all. A Netflix engineer profiling a scrolling feed on Apollo Kotlin in -August 2026 found the recomputation of watcher keys at about a third of the -cache's CPU, by the author's own caveat from one session on a debuggable -emulator. Relay itself still pays for the snapshot and the re-read, because it -must. Baton's bookkeeping is the framework's, and the framework was going to -do it anyway for every other piece of state in the app. +Apollo iOS and Apollo Kotlin keep a watcher per query, which remembers the +field keys the query's last execution touched. A write that overlaps +re-executes the whole operation and delivers a new model tree. On iOS there +is no equality check at all. + +A Netflix engineer, profiling a scrolling feed on Apollo Kotlin in August +2026, found the recomputation of watcher keys at about a third of the +cache's CPU. That is the author's own caveat, from one session on a +debuggable emulator. Relay itself still pays for the snapshot and the +re-read, because it must. Baton's bookkeeping is the framework's, which was +going to do it anyway, for every other piece of state in the app. ## What stays from Relay -The parts of Relay that are about GraphQL transfer whole, and they are what -Baton built first: - -- The compiler's front end, pinned at a tag behind Baton's own driver, so the - directives are parsed and validated by the code that defines them ([the - decision](../decisions/relay-front-end.md)). -- The normalization plan: the artifact with every fragment inlined that writes - a payload into flat records. Relay emits it as a JavaScript object; Baton - emits it as a data table in Swift and in Kotlin. -- The release buffer, ten operations by default, so that going back to a - screen just left is free; the optimistic layer rebased under later commits; - the connection handler that merges pages into one list; the four fetch - policies; `@required`, `@catch`, `@throwOnFieldError`, `@defer`, `@stream`. -- The words. A fragment is a fragment, a connection a connection, an - environment an environment. A Relay developer reads a Baton file without a - glossary ([Relay's words](../principles/relays-words.md)). +The parts of Relay that are about GraphQL transfer whole. They are what +Baton built first. + +- The compiler's front end, pinned at a tag behind Baton's own driver, so + the directives are parsed and validated by the code that defines them + ([the decision](../decisions/relay-front-end.md)). +- The normalization plan is the artifact, with every fragment inlined, that + writes a payload into flat records. Relay emits it as a JavaScript object, + and Baton emits it as a data table, in Swift and in Kotlin. +- Baton also keeps the release buffer, ten operations by default, so that + going back to a screen you recently left is free; the optimistic layer, + rebased under later commits; the connection handler, which merges pages + into one list; the four fetch policies; and `@required`, `@catch`, + `@throwOnFieldError`, `@defer`, and `@stream`. +- The words are Relay's: a fragment is a fragment, a connection is a + connection, and an environment is an environment. A Relay developer reads + a Baton file without a glossary ([Relay's + words](../principles/relays-words.md)). What does not transfer is named in [Relay's -words](../principles/relays-words.md) so that nobody ports it by habit: +words](../principles/relays-words.md), so that nobody ports it by habit: snapshots and seen-record sets, structural recycling, suspension by thrown -promises, the generator-based collector, string-keyed records, Resolvers, -client edges, updatable fragments, multi-actor stores. [Porting from -Relay](../recipes/porting-from-relay.md) puts Relay's words beside Baton's and -says, for what is not ported, why and what would reopen it. +promises, the generator-based collector, string-keyed records, resolvers, +client edges, updatable fragments, and multi-actor stores. [Porting from +Relay](../recipes/porting-from-relay.md) puts Relay's words beside Baton's, +and for what is not ported it says why and what would reopen it. ## The reader becomes a type Relay's compiler already splits the two walks. The normalization artifact -inlines every fragment; the reader artifact keeps the fragment boundaries and, -at a spread, hands the child a pointer instead of the child's fields. Baton -keeps that split and turns the reader into a type. The lens is a record -reference, a context, and one accessor per declared field. A spread compiles -to an accessor that returns the child's lens. A plural field is a lazy -collection of lenses. A parent passes a child the lens, three references, and -has no accessor for the child's fields. - -Masking is therefore not enforced, it is structural. The lens has no accessor +inlines every fragment, while the reader artifact keeps the fragment +boundaries and, at a spread, hands the child a pointer instead of the +child's fields. + +Baton keeps that split and turns the reader into a type. The lens is a +record reference, a context, and one accessor per declared field. A spread +compiles to an accessor that returns the child's lens, and a plural field is +a lazy collection of lenses. A parent passes a child the lens, three +references, and has no accessor for the child's fields. + +Masking is therefore not enforced, but structural. The lens has no accessor for a field the fragment did not declare, and the compiler is the only thing that can add one. The same shape gives per-field observation, because the -accessor is where the read is registered, and small generated code, because a -lens holds no storage and conforms to nothing it does not need: one line per -field, 106 bytes of source per accessor line over the compiler's goldens, -fenced at 120 by the compiler's tests. +accessor is where the read is registered, and small generated code, because +a lens holds no storage and conforms to nothing it does not need. That code +is one line per field, 106 bytes of source per accessor line over the +compiler's goldens, fenced at 120 by the compiler's tests. ## Two things the spike taught -The design was measured before it was built, and two findings from that spike -are rules of the project now. A property wrapper cannot carry the GraphQL -text, because an attribute's arguments initialize the wrapper directly, so the -markers are attached macros that expand to nothing and the compiler reads the -source ([the decision](../decisions/marker-macros.md)). And the key paths that -serve as invalidation channels must have distinct bodies: computed getters -with identical bodies are merged by the optimizer in a release build, and -their key paths then collide inside the registrar, which the spike found the -hard way. Each channel now goes through one subscript, and the tests run in -release builds as well as debug, because that is the build that shows it. +The design was measured before it was built. Two findings from that spike +are rules of the project now. + +A property wrapper cannot carry the GraphQL text. An attribute's arguments +initialize the wrapper directly, so the markers are attached macros that +expand to nothing, and the compiler reads the source ([the +decision](../decisions/marker-macros.md)). + +The key paths that serve as invalidation channels must have distinct bodies. +Computed getters with identical bodies are merged by the optimizer in a +release build, and their key paths then collide inside the registrar. The +spike found that the hard way. Each channel now goes through one subscript. +The tests run in release builds as well as debug, because that is the build +that shows it. ## What it refuses -A lens is valid on the main actor, where the store lives, because view bodies -run there and because a cached screen has to render in its first frame. Code -that needs a value elsewhere hops there or takes an explicit snapshot, which -is what an `@inline` fragment compiles to. Derived state outside a view -observes the store through `Observations` in Swift and `snapshotFlow` in -Kotlin ([the recipe](../recipes/derived-state.md)); the runtime signals -nothing of its own ([the -decision](../decisions/derived-state-is-observed-not-signaled.md)). And the -floor is the 26 releases of Apple's platforms and Android 6 on Kotlin, because -the design is built on what those frameworks can see ([the +A lens is valid on the main actor, where the store lives. View bodies run +there, and a cached screen has to render in its first frame. Code that needs +a value elsewhere hops there, or takes an explicit snapshot. That snapshot +is what an `@inline` fragment compiles to. + +Derived state outside a view observes the store through `Observations` in +Swift and `snapshotFlow` in Kotlin ([the +recipe](../recipes/derived-state.md)). The runtime signals nothing of its +own ([the +decision](../decisions/derived-state-is-observed-not-signaled.md)). + +The floor is the 26 releases of Apple's platforms, and Android 6 on Kotlin, +because the design is built on what those frameworks can see ([the floors](../decisions/platform-floors.md)). -Those are the terms of the trade. In exchange, the data layer disappears from -product code: a fragment beside a body, and the data is there, and when a -title changes the `Text` that read it updates while the row beside it does -not. +Those are the terms of the trade. In exchange, the data layer disappears +from product code. A fragment sits beside a body, and the data is there. +When a title changes, the `Text` that read it updates. The row beside it +does not. diff --git a/docs/articles/settled-stays-settled.md b/docs/articles/settled-stays-settled.md index aefff0af..bb38ed12 100644 --- a/docs/articles/settled-stays-settled.md +++ b/docs/articles/settled-stays-settled.md @@ -1,50 +1,61 @@ # Settled stays settled -A library's claims are worth exactly what the process behind them would do if -a claim were false. Baton makes strong ones: cached data in the first frame, -one re-render per changed field, a response in the store in milliseconds, two -runtimes with one behaviour. This page is about the process, because the -process is what an adopter is actually betting on. It has seven habits, each -with the mechanism that fails when the habit is dropped. +A library's claims are worth exactly what the process behind them would do +if a claim were false. Baton makes strong ones: cached data in the first +frame, one re-render per changed field, a response in the store in +milliseconds, and two runtimes with one behaviour. + +This page is about the process, because the process is what you are actually +betting on. It has seven habits, and each has the mechanism that fails when +the habit is dropped. ## Research first, and an honest verdict Before a line was written, the GraphQL client field was read across native, -web and cross-platform, and the data layers that large apps built for -themselves. [The acknowledgements](../../ACKNOWLEDGEMENTS.md) are the record: -each project named there taught this one something specific, and the line says -what. The verdict of that reading was not flattering, and it was kept: no -maintained native client ships the combination Baton is, and the public asks -for its pieces are few ([the comparison](../comparison.md)). The demand that -sustains the project is the owner's own products, whose web clients run Relay -([the record](../decisions/relay-front-end.md)). [The vision](../vision.md) -says the rest: adoption may follow and is never chased, with no parity race -and no feature bazaar. A project that knows why it exists can refuse things, -and most of what follows is refusing. +web, and cross-platform, and so were the data layers that large apps built +for themselves. [The acknowledgements](../../ACKNOWLEDGEMENTS.md) are the +record. Each project named there taught this one something specific, and the +line says what. + +The verdict of that reading was not flattering, and it was kept. No +maintained native client ships the combination Baton is. The public asks for +its pieces are few ([the comparison](../comparison.md)). The demand that +sustains the project is the owner's own products, whose web clients run +Relay ([the record](../decisions/relay-front-end.md)). [The +vision](../vision.md) says the rest. Adoption may follow, and is never +chased: no parity race, and no feature bazaar. + +A project that knows why it exists can refuse things, and most of what +follows is refusing. ## Principles with a mechanism each -Eight principles constrain every decision: a fragment is a lens, the store is -the UI's state, the response is the oracle, the compiler decides, honest data, -what earns a concept, Relay's words, two runtimes and one compiler. Each file -has the same shape: the failure the principle avoids, the idea, the -consequences, what it is not, and a last section, "Spelled today", that names -the types and may rot while the rest must not. The rule behind the shape is -that a principle without a mechanism that fails when it rots is a wish. So the -runtime has no GraphQL parser, and a check in CI limits what the runtime may -import; a lens holds one anchor and nothing else, and a test asserts its size; -a commit's notifications equal the fields that changed, and a count in CI -diffs it. +Eight principles constrain every decision: a fragment is a lens, the store +is the UI's state, the response is the oracle, the compiler decides, honest +data, what earns a concept, Relay's words, and two runtimes and one +compiler. + +Each file has the same shape: the failure the principle avoids, the idea, +the consequences, what it is not, and a last section, "Spelled today", that +names the types and may rot, while the rest must not. + +The rule behind the shape is that a principle without a mechanism that fails +when it rots is a wish. So the runtime has no GraphQL parser, and a check in +CI limits what the runtime may import. A lens holds one anchor and nothing +else, and a test asserts its size. A commit's notifications equal the fields +that changed, and a count in CI diffs it. ## Decisions with the condition that reopens them -Choices made among real alternatives live in [decisions/](../decisions/), -fifty-six records at this writing, one file each, all the same shape: a status -line with the date, the principle served and what would reopen it; the -context; the decision; the evidence; what was not chosen. The reopening line -is the part that matters. The image is SQLite, and the line says: reopen if a -measurement on a phone shows the engine, not the records, keeping a cached -screen out of its first frame ([the +Choices made among real alternatives live in [decisions/](../decisions/): +fifty-six records at this writing, one file each, all the same shape. Each +has a status line, with the date, the principle served, and what would +reopen it, then the context, the decision, the evidence, and what was not +chosen. + +The reopening line is the part that matters. The image is SQLite, and the +line says: reopen if a measurement on a phone shows the engine, not the +records, keeping a cached screen out of its first frame ([the record](../decisions/the-image-is-sqlite.md)). Collection runs on the main actor, and the line names the device class and the frame budget that would move it ([the @@ -52,114 +63,132 @@ record](../decisions/the-check-and-collection-run-on-the-main-actor.md)). A retention budget per type was refused, and the line says "reopen a rule per type never" ([the record](../decisions/the-image-evicts-by-launch.md)). -Settled stays settled unless new evidence arrives. A record that is overtaken -is not edited; it is superseded, says so in its status line, and stays, so the -history reads as it happened: lookups were superseded whole, the derived phase -in part. Taste is not relitigated. When a review of 0.6.0 proposed that the -compiler should write slot numbers into generated code, the answer was a -record explaining why the process numbers them instead, with the measurement, -and that record is public ([the +Settled stays settled unless new evidence arrives. A record that is +overtaken is not edited. It is superseded, says so in its status line, and +stays, so the history reads as it happened. Lookups were superseded whole, +and the derived phase in part. Taste is not relitigated. + +When a review of 0.6.0 proposed that the compiler should write slot numbers +into generated code, the answer was a record explaining why the process +numbers them instead, with the measurement. That record is public ([the record](../decisions/slots-are-numbered-by-the-process.md)). ## Spikes before the first release, with gates written first -Nothing central was built on faith. Before 0.1.0 the two mechanisms everything -else depends on were spiked, with their pass and kill conditions written down -first. The front end: Relay's compiler crates behind Baton's own driver, on -stable Rust, and the record keeps the result, a stripped binary of 4.3 MB, 500 -fragments compiled in 8 ms warm, and Relay's own error for a misspelled field -landing on the exact character inside a Swift literal ([the -record](../decisions/relay-front-end.md)). The ingest: response bytes decoded -straight into records by a plan, measured against Foundation's parser plus a -hand-written mapping, and the 0.1.0 entry of the same day shows the shape of -the result, 2.14 ms to tokenize and normalize the fixture against 4.47 ms for -`JSONSerialization` alone ([BENCHMARKS.md](../../BENCHMARKS.md)). A gate that -fails parks the plan rather than negotiating it: when a later plan proposed a -50 µs budget for a derived verdict and the bench measured 567 µs, the step -parked at its first half and the benchmark file says so. +Nothing central was built on faith. Before 0.1.0, the two mechanisms +everything else depends on were spiked, with their pass and kill conditions +written down first. + +The front end is Relay's compiler crates, behind Baton's own driver, on +stable Rust. The record keeps the result: a stripped binary of 4.3 MB, 500 +fragments compiled in 8 ms warm, and Relay's own error for a misspelled +field, landing on the exact character inside a Swift literal ([the +record](../decisions/relay-front-end.md)). + +The ingest decodes response bytes straight into records by a plan, measured +against Foundation's parser plus a hand-written mapping. The 0.1.0 entry of +the same day shows the shape of the result: 2.14 ms to tokenize and +normalize the fixture, against 4.47 ms for `JSONSerialization` alone +([BENCHMARKS.md](../../BENCHMARKS.md)). + +A gate that fails parks the plan, rather than negotiating it. When a later +plan proposed a 50 µs budget for a derived verdict, and the bench measured +567 µs, the step parked at its first half, and the benchmark file says so. ## Fixtures as the oracle, benches before claims The two habits that keep a fast store honest have [a page of their -own](how-a-fast-store-stays-honest.md). In one paragraph: the specification -under `spec/` holds schemas, documents, recorded responses, the store dumps a -commit must produce, the reads a lens must answer and scripts over time, -sixty-seven cases and thirteen scripts at this writing, and both runtimes pass -the same files; a naive reference reader is the oracle for every fast path; -Relay's own store tests are harvested and measured against, with a status per -disagreement. Every advertised number has a bench, +own](how-a-fast-store-stays-honest.md). In one paragraph, the specification +under `spec/` holds schemas, documents, recorded responses, the store dumps +a commit must produce, the reads a lens must answer, and scripts over time: +sixty-seven cases and thirteen scripts at this writing. Both runtimes pass +the same files. A naive reference reader is the oracle for every fast path. +Relay's own store tests are harvested and measured against, with a status +per disagreement. + +Every advertised number has a bench. [BENCHMARKS.md](../../BENCHMARKS.md) records it with the revision, the -machine, the OS and the date, and the file is appended to, never edited. A +machine, the OS, and the date. The file is appended to, never edited. A claim without a bench does not ship. ## Issues as direction, not as axioms The first outside adopter filed twenty-one issues. Nineteen were answered on -the issues on 4 October 2026 with what stands and what is refused, each -refusal with a reopening trigger, and the two that asked for a Kotlin runtime -were answered by building one; the answers became decision records, and those -records were then ordered into one spine, the handle, the write, the lifetime, -the session and the keys, and built before any feature, one release a step -([the record](../decisions/the-decided-architecture-is-built-first.md)). The -need in an issue sets the direction. The shape of the answer is judged against -the principles, and when the shape asked for would have been a policy object -or a second way to do something, the answer said no with a reason and a -trigger. That is slower than saying yes and it is why the vocabulary is still -twelve words. +the issues on 4 October 2026, with what stands and what is refused. Each +refusal has a reopening trigger. The two that asked for a Kotlin runtime +were answered by building one. + +The answers became decision records. Those records were then ordered into +one spine: the handle, the write, the lifetime, the session, and the keys. +That spine was built before any feature, one release a step ([the +record](../decisions/the-decided-architecture-is-built-first.md)). + +The need in an issue sets the direction. The shape of the answer is judged +against the principles. When the shape asked for would have been a policy +object, or a second way to do something, the answer said no, with a reason +and a trigger. That is slower than saying yes, and it is why the vocabulary +is still twelve words. ## Releases whenever there is something to use -Sixteen releases shipped between 2 and 13 October 2026, from 0.1.0 to 0.16.0. -A release is cut when the changelog's Unreleased section holds something an -adopter can use and the local checks pass; it waits for no batch, and since -0.9.0 its name is taken from a list without asking anyone, a bread of Russian, -Ukrainian and Belarusian baking, so that a release never waits on a question -with no technical content; the eight before it were named one at a time on a -relay theme ([the -record](../decisions/releases-are-frequent-and-named-for-bread.md)). The -release commit touches the manifests, the changelog and the version the Gradle -recipe quotes, and nothing else; the workflow reads the version and the name -from the changelog's heading, and the compiler's prebuilt bundle ships in the -tag so that a package can depend on Baton by its tag. The changelog is written -for a person, and before 1.0 breaking changes are listed without apology; CI -reports public API breakage against a tagged release so that a reviewer can -see the list is whole. - -The same habit holds the docs. Public docs are updated in the same change that -makes them stale: the README, the changelog, the terminology, the samples. The -terminology names a concept before code does, and marks what is planned. And -the documentation site types nothing by hand: every generated file, operation -text, diagnostic and store on it is produced by the compiler or the runtime -when the site is built, so a page cannot show code the compiler would not -write. +Sixteen releases shipped between 2 and 13 October 2026, from 0.1.0 to +0.16.0. A release is cut when the changelog's Unreleased section holds +something an adopter can use, and the local checks pass. It waits for no +batch. + +Since 0.9.0, its name is taken from a list without asking anyone: a bread of +Russian, Ukrainian, and Belarusian baking, so that a release never waits on +a question with no technical content. The eight before it were named one at +a time, on a relay theme ([the +record](../decisions/releases-are-frequent-and-named-for-bread.md)). + +The release commit touches the manifests, the changelog, and the version the +Gradle recipe quotes, and nothing else. The workflow reads the version and +the name from the changelog's heading. The compiler's prebuilt bundle ships +in the tag, so that a package can depend on Baton by its tag. + +The changelog is written for a person. Before 1.0, breaking changes are +listed without apology. CI reports public API breakage against a tagged +release, so that a reviewer can see the list is whole. + +The same habit holds the docs. Public docs are updated in the same change +that makes them stale: the README, the changelog, the terminology, the +samples. The terminology names a concept before code does, and marks what is +planned. The documentation site types nothing by hand. Every generated file, +operation text, diagnostic, and store on it is produced by the compiler or +the runtime when the site is built. A page cannot show code the compiler +would not write. ## Two more habits, and who wrote the code -Two habits found real defects and are rules now. Tests run in release builds -as well as debug, because the optimizer merges identical getters and the key -paths that serve as invalidation channels then collide; a spike found it, and -the rule is in the project's instructions. And generated code is compiled -against a corpus of hostile schema names, in every position a name can take, -by a sweep that runs in CI, in both languages. +Two habits found real defects, and are rules now. Tests run in release +builds as well as debug, because the optimizer merges identical getters and +the key paths that serve as invalidation channels then collide. A spike +found it, and the rule is in the project's instructions. Generated code is +compiled against a corpus of hostile schema names, in every position a name +can take, by a sweep that runs in CI, in both languages. -The code was written with agentic tools. The design, the API and every +The code was written with agentic tools. The design, the API, and every decision are the owner's, and each is recorded in the files linked from this page. The habits above are what make that arrangement safe: a fixture that cannot be edited to fit code, a golden for every emitter, a bench for every claim, a sweep for every name, a release build for every test, and a record -for every choice. They would be the right habits for code written any other -way; they are the necessary ones for code written this way, and Baton's own -documentation is written so that an agent integrating it reads [the same +for every choice. + +They would be the right habits for code written any other way, and the +necessary ones for code written this way. Baton's own documentation is +written so that an agent integrating it reads [the same page](../recipes/agents.md) first. ## What this does not claim -Not that nothing is wrong. The API will break before 1.0, and the changelog -says where. Several decision records carry the line that no device of the -class they name was at hand when the plan asked for the number, and the line -stands until one is. The Kotlin runtime is days old and shipped by no app yet. -What the process claims is narrower and worth more: that what is wrong is -found by a mechanism rather than a user, recorded where anyone can read it, -and reopened on evidence rather than on taste. That is what production grade -means for a library at 0.16, and it is the standard the next release is held -to. +This does not claim that nothing is wrong. The API will break before 1.0, +and the changelog says where. Several decision records carry the line that +no device of the class they name was at hand when the plan asked for the +number. The line stands until one is. The Kotlin runtime is days old, and +shipped by no app yet. + +What the process claims is narrower, and worth more. What is wrong is found +by a mechanism, not by a user. It is recorded where anyone can read it, and +reopened on evidence, not on taste. That is what production grade means for +a library at 0.16, and the standard the next release is held to. diff --git a/docs/articles/the-first-frame.md b/docs/articles/the-first-frame.md index d1bc8753..f0738f7a 100644 --- a/docs/articles/the-first-frame.md +++ b/docs/articles/the-first-frame.md @@ -1,175 +1,201 @@ # The first frame has the data -Open a screen the app has already shown, and the first frame is a spinner. On -both Apollo clients that is not a bug to file; it is the design. Apollo Kotlin -documents that its cache operations are `suspend` functions that must not run -on the main thread, and its Compose helper collects a flow that starts at -`null`. Apollo iOS 2 reads its store through `async` functions, each hop -through an actor and a new task. A cache that must be awaited cannot answer a -view body, which is synchronous, so the body runs once without the data and -once with it. Baton exists to remove that frame, and the way it does so is a -chain of decisions, each with a number behind it. +Open a screen the app has already shown, and the first frame is a spinner. +On both Apollo clients that is not a bug to file, but the design. + +Apollo Kotlin documents that its cache operations are `suspend` functions, +and that they must not run on the main thread. Its Compose helper collects a +flow that starts at `null`. Apollo iOS 2 reads its store through `async` +functions, each hop through an actor and a new task. + +A cache that must be awaited cannot answer a view body. A view body is +synchronous, so the body runs once without the data and once with it. Baton +exists to remove that frame. The way it does so is a chain of decisions, +each with a number behind it. ## Why the frame matters The apps that publish on this all show cached content first. Facebook's 2012 -iOS rebuild rendered the cached feed at launch and read only what the first +iOS rebuild rendered the cached feed at launch, and read only what the first layout needed from disk ([Under the hood: Rebuilding Facebook for iOS](https://engineering.fb.com/2012/08/23/ios/under-the-hood-rebuilding-facebook-for-ios/)). Its 2015 Android work measured a story loading from the disk cache at 35 ms, over a 16.6 ms frame, and brought it to 4 ms with a format that is read in place ([Improving Facebook's performance on Android with FlatBuffers](https://engineering.fb.com/2015/07/31/android/improving-facebook-s-performance-on-android-with-flatbuffers/)). -Vrbo's Android app fetched everything a flow needed in one orchestrated query -and then read the detail screens from the cache alone, with no network loading -states ([How Apollo-Android client cache works at + +Vrbo's Android app fetched everything a flow needed in one orchestrated +query, then read the detail screens from the cache alone, with no network +loading states ([How Apollo-Android client cache works at Vrbo](https://medium.com/expedia-group-tech/how-apollo-android-client-cache-works-at-vrbo-73334d98af43)). -And Threads measured the cost of not doing so: 0.29 to 0.36 seconds of -injected navigation latency cost 0.54 to 0.81% of daily active users ([How we -think about Threads' iOS +Threads measured the cost of not doing so. 0.29 to 0.36 seconds of injected +navigation latency cost 0.54 to 0.81% of daily active users ([How we think +about Threads' iOS performance](https://engineering.fb.com/2024/12/18/ios/how-we-think-about-threads-ios-performance/)). + The first frame is where a data layer is judged. ## Reads are synchronous on the main actor -A view body is a synchronous function on the main actor. For the first body to -have the data, the read has to be a synchronous function on the main actor -too, and that decides where the store lives. In Baton the records are the main -actor's state: observable objects whose slots a lens loads directly. A read is -a slot load, 26 ns on an Apple M1 Pro, about half a microsecond inside a body -under Observation's tracking, 47 ns a field on a Pixel 9 through the Kotlin -runtime ([BENCHMARKS.md](../../BENCHMARKS.md)). +A view body is a synchronous function on the main actor. For the first body +to have the data, the read has to be a synchronous function on the main +actor too. That decides where the store lives. + +In Baton the records are the main actor's state, observable objects whose +slots a lens loads directly. A read is a slot load: 26 ns on an Apple M1 +Pro, about half a microsecond inside a body under Observation's tracking, +and 47 ns a field on a Pixel 9, through the Kotlin runtime +([BENCHMARKS.md](../../BENCHMARKS.md)). The handle does its work before the first body. When a view appears, its -operation value resolves against the store: an availability check walks the -operation's plan over the records and answers whether the store can render it. -For the whole 899-record fixture that check costs 115 µs on the Mac and 1.2 ms -on the phone. If it can, the phase is `ready` in the first body and no request -is sent under the default policy; if it cannot, the phase is `loading` and the -environment fetches. Under `storeAndNetwork` the first body renders what is -there and a refresh runs behind it, with the previous data staying on screen -and `isRefreshing` saying so. - -There is no asynchronous read API for views, and there will not be one. That -is rule two of the vision, and the refusal is what keeps the first frame -honest: nothing a view can call returns a promise of data. +operation value resolves against the store. An availability check walks the +operation's plan over the records and answers whether the store can render +it. For the whole 899-record fixture, that check costs 115 µs on the Mac and +1.2 ms on the phone. + +If it can, the phase is `ready` in the first body, and no request is sent +under the default policy. If it cannot, the phase is `loading`, and the +environment fetches. Under `storeAndNetwork`, the first body renders what is +there and a refresh runs behind it. The previous data stays on screen, and +`isRefreshing` says so. + +There is no asynchronous read API for views, and there will not be one, +which is rule two of the vision. The refusal is what keeps the first frame +honest. Nothing a view can call returns a promise of data. ## What runs on the main actor, and what does not -The posture written before the store existed was that the main actor does -reads and commits and nothing else. Building it moved three more things there, -on evidence: the availability check with hydration from the image, collection, -and the normalization of an optimistic response. The decision record says why -and gives the numbers ([the -decision](../decisions/the-check-and-collection-run-on-the-main-actor.md)). A -check on another actor could not fill a record from the image without coming -back to the main actor before the handle could be ready, and a ready that +The posture, written before the store existed, was that the main actor does +reads and commits and nothing else. Building it moved three more things +there, on evidence: the availability check, with hydration from the image; +collection; and the normalization of an optimistic response. + +The decision record says why, and gives the numbers ([the +decision](../decisions/the-check-and-collection-run-on-the-main-actor.md)). +A check on another actor could not fill a record from the image without +coming back to the main actor before the handle could be ready. A ready that waits is the asynchronous read the rule forbids. An optimistic response normalized elsewhere would show a frame late. A collection mark from a snapshot would copy every reachable record's links each pass. -Everything else runs off it. A response's bytes are decoded and normalized in -the fetch's own task; the image is written behind on its own queue; the image -is opened off the main actor when the app makes its environment. The numbers -that keep the main-actor work there, from the Mac: a collection pass over -about 9,000 records, best 0.24 ms, median 3.8 ms; hydration of the fixture's -898 rows into an empty store, 1.72 ms; the same check with the records already -in memory, 105 µs. Each is under a third of a frame on that machine. The -reopening condition is written down: a pass or a hydration that takes longer -than a frame on an iPhone 12-class device. +Everything else runs off it. A response's bytes are decoded and normalized +in the fetch's own task, the image is written behind on its own queue, and +the image is opened off the main actor when the app makes its environment. + +The numbers that keep the main-actor work there come from the Mac. A +collection pass over about 9,000 records takes 0.24 ms at best, and 3.8 ms +at the median. Hydration of the fixture's 898 rows into an empty store +takes 1.72 ms. The same check, with the records already in memory, takes +105 µs. Each is under a third of a frame on that +machine. The reopening condition is written down: a pass, or a hydration, +that takes longer than a frame on an iPhone 12-class device. ## The image, and the next launch -A warm start is the launch after a kill, and for its first frame to have the -data the store has to outlive the process. The image is one SQLite file -through the library the system ships, nothing vendored: a binary row per -record keyed by the record's key, the query root a row per field, the time -each operation last fetched, and a table of names, because slot numbers belong -to a process. Every server batch is written behind the commit, by one writer, -in WAL mode with `synchronous=NORMAL`, so a crash may lose the last moments -and never the file's consistency. Optimistic layers never reach it. +A warm start is the launch after a kill. For its first frame to have the +data, the store has to outlive the process. + +The image is one SQLite file, through the library the system ships, and +nothing is vendored. It holds a binary row per record, keyed by the record's +key; the query root, a row per field; the time each operation last fetched; +and a table of names, because slot numbers belong to a process. + +Every server batch is written behind the commit, by one writer, in WAL mode +with `synchronous=NORMAL`. A crash may lose the last moments, and never the +file's consistency. Optimistic layers never reach it. Reading is the availability check. When memory cannot answer, the walk runs -again with the connection at hand, inside one read transaction, and fills what -memory lacks. On the Mac a launch in a new process has the fixture in the -store 1.78 ms after its handle asks, when the file was opened off the main -actor before the first view; 4.68 ms when the main actor had to wait for the -open. Keeping the image costs a commit 0.12 ms on the main actor and 1.16 ms -off it, and the file is 168 KB for the 686 KB response +again with the connection at hand, inside one read transaction, and fills +what memory lacks. On the Mac, a launch in a new process has the fixture in +the store 1.78 ms after its handle asks, when the file was opened off the +main actor before the first view, and 4.68 ms when the main actor had to +wait for the open. Keeping the image costs a commit 0.12 ms on the main +actor and 1.16 ms off it. The file is 168 KB for the 686 KB response ([BENCHMARKS.md](../../BENCHMARKS.md), the 0.6.0 entry). The image is a cache, not a database, and three rules keep it one. An entry -that cannot be read is a miss, never a migration: an image written under -another schema, another format or another identity configuration starts again, -which is LinkedIn's rule from 2016 and the reason a schema change cannot -strand an app. A row that goes one whole launch unread is dropped at the next, -and a size limit is checked at launch ([the -decision](../decisions/the-image-evicts-by-launch.md)). And the image belongs -to one store: a sign-out ends the environment, which closes the image, and the +that cannot be read is a miss, never a migration. An image written under +another schema, another format, or another identity configuration starts +again. That is LinkedIn's rule from 2016, and the reason a schema change +cannot strand an app. + +A row that goes one whole launch unread is dropped at the next, and a size +limit is checked at launch ([the +decision](../decisions/the-image-evicts-by-launch.md)). The image belongs to +one store: a sign-out ends the environment, which closes the image, and the next environment starts its own ([the decision](../decisions/the-environment-is-the-session.md)). -SQLite was chosen over a memory-mapped B-tree and over a file format of -Baton's own, and the record says why ([the +SQLite was chosen over a memory-mapped B-tree, and over a file format of +Baton's own. The record says why ([the decision](../decisions/the-image-is-sqlite.md)). The spike measured the -alternatives hydrating faster on the Mac, and the decision went to the -simplest engine anyway: hydration was already the smaller part of a launch, -and the alternatives cost vendored C, a licence notice in every binary, I/O -errors that arrive as signals, and crash consistency that would be Baton's to -prove. The rule was that a Mac's numbers can only show whether engines differ -radically, and unless they do, the simplest one wins. +alternatives hydrating faster on the Mac. The decision went to the simplest +engine anyway, because hydration was already the smaller part of a launch. +The alternatives cost vendored C, a licence notice in every binary, I/O +errors that arrive as signals, and crash consistency that would be Baton's +to prove. The rule was that a Mac's numbers can only show whether engines +differ radically. Unless they do, the simplest one wins. ## What a phone measured -The Kotlin sample and its Apollo Kotlin twin, the same two Compose screens, on -a Google Pixel 9 on 8 October 2026, fifteen cold starts each over a store a -priming launch had left on disk ([BENCHMARKS.md](../../BENCHMARKS.md), "end to -end on a phone"). Baton's list is in its first frame, 239.6 ms from launch: -the check read what memory lacked from the image on the main thread before the -first composition. Apollo's first frame comes at 209 ms and is the spinner; -its list follows at 272 ms, from the SQL cache, once the collected flow emits. -A tap into a detail whose header the list already holds shows the header in -the detail's first frame, 22 ms after the tap, against 33 ms and a spinner -first. - -The same run found something against Baton, and the file says that too: its -first frame came 23 to 30 ms after Apollo's. The traces, read with Perfetto on -the phone the next day, put it in the first composition, where the main thread -slept for 23 ms behind the image's writer creating the file under the disk's -lock, a cost every first launch of an app paid. 0.15.0 fixed it: a read of a -file that did not exist at construction now answers from memory alone, and on -the same phone the cold start's first frame came 26 ms sooner, level with the -twin's, and the list 24 ms sooner, 255 ms from launch against the twin's 327. -That is the discipline the first frame needs: measure on a device, trace where -the time goes, fix the cause, measure again, and leave the record in the file. +The measurement is the Kotlin sample and its Apollo Kotlin twin, the same +two Compose screens, on a Google Pixel 9, on 8 October 2026: fifteen cold +starts each, over a store a priming launch had left on disk +([BENCHMARKS.md](../../BENCHMARKS.md), "end to end on a phone"). + +Baton's list is in its first frame, 239.6 ms from launch. The check read +what memory lacked from the image, on the main thread, before the first +composition. Apollo's first frame comes at 209 ms, and it is the spinner. +Its list follows at 272 ms, from the SQL cache, once the collected flow +emits. A tap into a detail whose header the list already holds shows the +header in the detail's first frame, 22 ms after the tap, against 33 ms and a +spinner first. + +The same run found something against Baton, and the file says that too. Its +first frame came 23 to 30 ms after Apollo's. The traces, read with Perfetto +on the phone the next day, put it in the first composition. The main thread +slept for 23 ms behind the image's writer, creating the file under the +disk's lock, a cost every first launch of an app paid. + +0.15.0 fixed it. A read of a file that did not exist at construction now +answers from memory alone. On the same phone, the cold start's first frame +came 26 ms sooner, level with the twin's. The list came 24 ms sooner: 255 ms +from launch, against the twin's 327. That is the discipline the first frame +needs. Measure on a device, trace where the time goes, and fix the cause. +Then measure again, and leave the record in the file. ## Staleness is a phase, not a surprise -Data in the first frame is only a virtue if the view can tell how old it is. A -handle's `phase` is `loading`, `ready` or `failed`, and beside it +Data in the first frame is a virtue only if the view can tell how old it is. +A handle's `phase` is `loading`, `ready`, or `failed`. Beside it, `isRefreshing` and `isStale` keep their meaning across a launch, because the -image stores when each operation last fetched. An operation states its own -expiration in its document, `@cacheExpiration(seconds:)`, the one directive -Baton has that Relay does not, because neither Relay nor the specification has -a word for it ([the -decision](../decisions/an-operation-states-its-expiration.md)). The last fetch -is a value a view can read, idle, in flight or failed with its kind, so a -refresh that fails behind data is seen without the data going. And -`revalidate()` is one call the app makes when it returns to the foreground, -refetching what is stale, with the policy staying with the holder ([the +image stores when each operation last fetched. + +An operation states its own expiration in its document, +`@cacheExpiration(seconds:)`. That is the one directive Baton has that Relay +does not, because neither Relay nor the specification has a word for it +([the +decision](../decisions/an-operation-states-its-expiration.md)). + +The last fetch is a value a view can read: idle, in flight, or failed with +its kind. A refresh that fails behind data is seen, without the data going. +`revalidate()` is one call the app makes when it returns to the foreground. +It refetches what is stale. The policy stays with the holder ([the decision](../decisions/revalidation-is-the-apps-call.md)). ## What it costs The main actor does hydration, so an image far larger than the benches model -is a cost the frame would feel; that is the reopening condition above, and -eviction by launch and the size limit are what keep the image near the screens -the app actually shows. One process uses a file, so an extension or a widget -cannot share the image. Eviction is by launch and by a size limit, not a trim -of the largest records, where Apollo Kotlin's cache has TTL, partial reads and -a trim. Those are the open edges, and [the comparison](../comparison.md) lists -them beside what Apollo is ahead on. - -In exchange, the screen the user left is the screen they get back, before any -request, and the detail they tap into has its header in the first frame. No -loading flash for a screen the app has seen. +is a cost the frame would feel. That is the reopening condition above. +Eviction by launch, and the size limit, are what keep the image near the +screens the app actually shows. + +One process uses a file, so an extension or a widget cannot share the image. +Eviction is by launch and by a size limit, not a trim of the largest +records. Apollo Kotlin's cache has TTL, partial reads, and a trim. Those are +the open edges, and [the comparison](../comparison.md) lists them beside +what Apollo is ahead on. + +In exchange, the screen you left is the screen you get back, before any +request. The detail you tap into has its header in the first frame. There is +no loading flash for a screen the app has seen. diff --git a/docs/articles/the-small-lies.md b/docs/articles/the-small-lies.md index 6f1b1e22..3245f106 100644 --- a/docs/articles/the-small-lies.md +++ b/docs/articles/the-small-lies.md @@ -1,176 +1,207 @@ # The small lies a client tells -GraphQL clients lie in small ways that compound. They type every field -nullable because any field can fail, so product code handles permutations that -never happen. They hide a field error behind a `null` and the view shows an -empty label. They return nothing for data a related write made disappear, and -the first `.count` crashes. They serve a cached value as if it were fresh, and -a product team routes around the cache with mutations, as Netflix wrote it did -([Unlocking dynamic +GraphQL clients lie in small ways, and the lies compound. + +They type every field nullable, because any field can fail, so product code +handles permutations that never happen, and they hide a field error behind a +`null`, so the view shows an empty label. They return nothing for data a +related write made disappear, and the first `.count` crashes; they also serve +a cached value as if it were fresh, and a product team then routes around +the cache with mutations, as Netflix wrote that it did ([Unlocking dynamic pages](https://netflixtechblog.com/unlocking-dynamic-pages-the-evolution-of-netflixs-client-server-graphql-apis-8b4631a59b39), -2025). Relay spent its eighteenth release fixing the first two with `@catch`, -`@throwOnFieldError` and semantic nullability. The opposite failure is a -client that crashes on any imperfection, which SwiftData's invalidated-model -trap stands for. Baton's rule is one sentence: data is what the schema says, -and what the schema cannot say is a value a view can read, never a surprise it -cannot. This page is the list of lies and the mechanism that refuses each. +2025). + +Relay spent its eighteenth release fixing the first two, with `@catch`, +`@throwOnFieldError`, and semantic nullability. The opposite failure is a +client that crashes on any imperfection. SwiftData's invalidated-model trap +stands for that. + +Baton's rule is one sentence: data is what the schema says, and what the +schema cannot say is a value a view can read, never a surprise it cannot. +This page is the list of lies, and the mechanism that refuses each. ## The first lie: every field is optional -Types follow the schema. A non-null field is a non-optional property; a +Types follow the schema. A non-null field is a non-optional property. A nullable one is optional. `title` on GitHub's `Issue` is `String!` in the -schema and a `String` in Swift; `name` on Rick and Morty's `Character` is -`String` and reads as an optional. A view stops guarding what the server -promises and stops trusting what it does not. +schema and a `String` in Swift. `name` on Rick and Morty's `Character` is +`String`, and it reads as an optional. A view stops guarding what the server +promises, and stops trusting what it does not. The rule goes down into lists. A list `[String]`, whose elements the schema -types nullable, reads as `[String?]?`, and a null element reads as nil; a list -`[String!]!` reads as `[String]`. That was a shortcut in 0.1.0, which dropped -null elements on the way with no decision recorded, and the record that fixed -it says why the exception for lists of records stands: a `List` of lenses is -keyed by its records' identities, for `ForEach` and for diffing, and a null -entry has no identity to be keyed by, so the store keeps the entry and the -lens shows the records ([the +types nullable, reads as `[String?]?`. A null element reads as nil. A list +`[String!]!` reads as `[String]`. + +That was a shortcut in 0.1.0, and it dropped null elements on the way, with +no decision recorded. The record that fixed it says why the exception for +lists of records stands. A `List` of lenses is keyed by its records' +identities, for `ForEach` and for diffing. A null entry has no identity to +be keyed by. +So the store keeps the entry, and the lens shows the records ([the decision](../decisions/a-lists-null-elements-are-typed.md)). The schema is sometimes the one lying, and the directives are for that. `@required(action:)` says a view cannot do without a field the schema allows -to be null, and moves the null up to where the product wants to handle it: -`NONE` and `LOG` make the field non-optional and bubble the null to the lens -boundary, so the accessor that produces the lens produces nil instead, and -`LOG` logs the path; `THROW` makes the field's own accessor throw, `get -throws` in Swift and a throwing getter in Kotlin, at exactly the read. A null -that bubbles all the way to an operation's root fails the operation with the -path of the field, because a phase has no null data to offer a view ([the -decision](../decisions/error-directives-in-swift.md)). What that record -refused is the usual answer: throwing accessors everywhere, or `Result` -everywhere, so that any error could surface at any read. Then every body is a -`try` and the schema's nullability stops meaning anything. +to be null. It moves the null up to where the product wants to handle it. + +`NONE` and `LOG` make the field non-optional, and bubble the null to the +lens boundary: the accessor that produces the lens produces nil instead, and +`LOG` logs the path. `THROW` makes the field's own accessor throw, `get +throws` in Swift and a throwing getter in Kotlin, at exactly the read. + +A null that bubbles all the way to an operation's root fails the operation, +with the path of the field. A phase has no null data to offer a view ([the +decision](../decisions/error-directives-in-swift.md)). + +What that record refused is the usual answer: throwing accessors everywhere, +or `Result` everywhere, so that any error could surface at any read. Then +every body is a `try`, and the schema's nullability stops meaning anything. ## The second lie: an error is a null -A field error is a server's failure to resolve one field, with the rest of the -response intact. Most clients return the null and drop the error, or hand the -error to whoever made the request and not to the view that reads the field. -Baton stores the error beside the field it names, with its message, its path -and its `extensions`, resolved by path at ingest, kept in memory and in the -image, and cleared by the next payload that answers the field. A cached read -sees the same error the network read saw. A plain accessor keeps reading the -field as null; `@catch(to: RESULT)` reads a `Result`, success with the value -or failure with the errors, so the view can say what went wrong; `@catch(to: -NULL)` keeps the optional for a field the view would rather blank. -`@throwOnFieldError` on a fragment makes its spread accessor throw, and on an -operation puts the handle in `failed` with the errors while the data stays in -the store. A schema's `@semanticNonNull` reads non-optional under either, as -Relay's rule says. +A field error is a server's failure to resolve one field, with the rest of +the response intact. Most clients return the null and drop the error, or +hand the error to whoever made the request, and not to the view that reads +the field. + +Baton stores the error beside the field it names, with its message, its +path, and its `extensions`. The error is resolved by path at ingest, kept in +memory and in the image, and cleared by the next payload that answers the +field. A cached read sees the same error the network read saw. + +A plain accessor keeps reading the field as null. `@catch(to: RESULT)` reads +a `Result`: success with the value, or failure with the errors, so the view +can say what went wrong. `@catch(to: NULL)` keeps the optional, for a field +the view would rather blank. `@throwOnFieldError` on a fragment makes its +spread accessor throw. On an operation, it puts the handle in `failed` with +the errors, while the data stays in the store. A schema's +`@semanticNonNull` reads non-optional under either, as Relay's rule says. The fixtures hold responses with errors on them. The notifications script -commits a response with errors for three fields and requires those three slots -to notify, then commits one that answers them and requires the three to notify -again; the specification's expected reads require a `@catch` read, a plain -read and a cached read of the same errored field to agree with the response. -The GitHub sample shows the point: a missing repository comes back as `null` +commits a response with errors for three fields, and requires those three +slots to notify. It then commits one that answers them, and requires the +three to notify again. The specification's expected reads require a +`@catch` read, a plain read, and a cached read of the same errored field to +agree with the response. + +The GitHub sample shows the point. A missing repository comes back as `null` with a field error, and `repository @catch` shows the server's sentence instead of a guess. A response without errors pays nothing at ingest for the machinery. On an -Apple M1 Pro on 3 October 2026, twenty field errors added about a quarter of a -millisecond to the ingest of the 686 KB fixture, most of it the index the path -walk needs, built only when errors exist; a `@catch` read of a field without -an error costs 122 ns against 29 ns for a plain read; a `satisfied` check of a -lens with one `@required` field costs 29 ns +Apple M1 Pro, on 3 October 2026, twenty field errors added about a quarter +of a millisecond to the ingest of the 686 KB fixture. Most of it is the +index the path walk needs, built only when errors exist. A `@catch` read of +a field without an error costs 122 ns, against 29 ns for a plain read. A +`satisfied` check of a lens with one `@required` field costs 29 ns ([BENCHMARKS.md](../../BENCHMARKS.md), the 0.5.0 entry). ## The third lie: a failure is a string The specification names two kinds of error. A request error is raised before -execution begins, and the response then has no data; a field error is raised -while a field executes, and the response has partial data. A client that gives -a view `any Error` makes every render site match on types, and the runtime -itself cast at eight places before the decision that closed the set ([the -decision](../decisions/a-failure-says-its-kind.md)). A fetch now fails in one -of four kinds: the transport's, carrying what the transport threw unchanged; a -request error, carrying the server's errors; a malformed response, one the -plan cannot read; or the environment's, when nothing can send the request. The -same kinds classify how a subscription's stream ended and what the log -reports, so there is no second vocabulary to drift from the first. And an -error keeps its `extensions`, where the specification's own example puts a -code, so an app branches on the server's code and never on its message. - -A failure behind data is not a failure of the data. When a refresh fails, the -data stays on screen and the handle's `fetch` carries the failure with its -kind and the time it failed, so a view shows the content and a notice rather -than a blank and an alert. +execution begins, and the response then has no data. A field error is raised +while a field executes, and the response has partial data. + +A client that gives a view `any Error` makes every render site match on +types. The runtime itself cast at eight places, before the decision that +closed the set ([the decision](../decisions/a-failure-says-its-kind.md)). + +A fetch now fails in one of four kinds. The transport's kind carries what +the transport threw, unchanged, and a request error carries the server's +errors. A malformed response is one the plan cannot read, and the +environment's kind applies when nothing can send the request. + +The same kinds classify how a subscription's stream ended, and what the log +reports. There is no second vocabulary to drift from the first. An error +keeps its `extensions`, where the specification's own example puts a code. +An app branches on the server's code, and never on its message. + +A failure behind data is not a failure of the data. When a refresh fails, +the data stays on screen. The handle's `fetch` carries the failure, with its +kind and the time it failed. A view shows the content and a notice, and does +not show a blank and an alert. ## The fourth lie: missing data is nothing Missing data is the one case the types cannot express. It arises only when -another operation retargets a link to a record fetched with fewer fields: a -list fetched a location's name, a detail fetched its dimension, and a later +another operation retargets a link to a record fetched with fewer fields. A +list fetched a location's name, and a detail fetched its dimension. A later response points a character's `origin` at a location the store knows only by -name. A client that returns nothing here crashes on the first `.count`; a -client that suspends hides the screen. Baton does neither and invents nothing: -the lens reads nil, or the type's zero value where there is no nil, the -environment logs a `missing` event naming the type and the field, the owning -operation is marked stale and refetched once, and a debug build is loud about -it. The view shows a blank for a moment and then the truth. The heal script -fixes the sequence: the read logs the miss and the owner refetches, the -refetch restores the value, and a second miss after the heal's own refetch is -logged as unexpected and healed no further ([the +name. + +A client that returns nothing here crashes on the first `.count`. A client +that suspends hides the screen. Baton does neither, and invents nothing. The +lens reads nil, or the type's zero value where there is no nil, and the +environment logs a `missing` event, naming the type and the field. The +owning operation is marked stale and refetched once, and a debug build is +loud about it. The view shows a blank for a moment, and then the truth. + +The heal script fixes the sequence. The read logs the miss, and the owner +refetches. The refetch restores the value. A second miss, after the heal's +own refetch, is logged as unexpected and healed no further ([the principle](../principles/honest-data.md)). -A response that omits a field the operation selected is not missing data. It -fails the fetch as malformed, so a hole in the store can only come from a +A response that omits a field the operation selected is not missing data, +and fails the fetch as malformed. A hole in the store can only come from a retargeted link, never from a server that stopped sending a field. ## The fifth lie: a value that does not fit is zero A null in a non-null field, or a string where an integer was promised, is -reported as unexpected through the log and reads as nil or as the zero value; -it is never silently a value. The first adopter put the sharper case: an -amount that does not parse must never render as `0` -([#11](https://github.com/shergin/baton/issues/11)). So a custom scalar mapped -to an app's type, `Decimal`, `Date`, `URL`, or a Kotlin type with its -converter, converts at the read and is a fallible read: its accessor is -optional wherever the schema puts the field, unless `@required` or -`@throwOnFieldError` says what a failure does, and a text that does not -convert is reported and never zero ([the -decision](../decisions/a-mapped-scalar-is-a-fallible-read.md)). A mapped type -has no honest zero: zero is an amount, a date's zero is a date, and a URL has -none. - -Enums are the same boundary with one difference: the conversion cannot fail, -so it keeps the schema's nullability. Each schema enum compiles to an enum -with a case per value, spelled as the schema spells it, and one more carrying -the text of a value the build did not know, `unknown(String)` in Swift and -`Undeclared` in Kotlin, as Relay types every enum with a future value. A -schema that grows after the build is not a client failure ([the +reported as unexpected through the log. It reads as nil, or as the zero +value, and is never silently a value. + +The first adopter put the sharper case. An amount that does not parse must +never render as `0` ([#11](https://github.com/shergin/baton/issues/11)). So +a custom scalar mapped to an app's type, `Decimal`, `Date`, `URL`, or a +Kotlin type with its converter, converts at the read and is a fallible read. +Its accessor is optional wherever the schema puts the field, unless +`@required` or `@throwOnFieldError` says what a failure does. A text that +does not convert is reported, and never zero ([the +decision](../decisions/a-mapped-scalar-is-a-fallible-read.md)). + +A mapped type has no honest zero. Zero is an amount, a date's zero is a +date, and a URL has none. + +Enums are the same boundary, with one difference. The conversion cannot +fail, so it keeps the schema's nullability. Each schema enum compiles to an +enum with a case per value, spelled as the schema spells it, and one more +case carrying the text of a value the build did not know. That case is +`unknown(String)` in Swift and `Undeclared` in Kotlin, as Relay types every +enum with a future value. A schema that grows after the build is not a +client failure ([the decision](../decisions/an-enum-reads-as-a-generated-enum.md)). ## The sixth lie: cached is fresh -A handle's phase is `loading`, `ready` or `failed`, and beside it `isStale` -says whether the data is older than its operation's expiration or was marked -stale by an invalidation. The age survives a launch, because the image keeps -when each operation last fetched. The expiration is stated in the document, -`@cacheExpiration(seconds:)`, the one directive Baton has that Relay does not. -Stale data stays visible; a `storeOrNetwork` attach refetches it, and -`revalidate()` on the app's return refetches whatever went stale meanwhile. -Nothing is served as fresh without a way to tell. +A handle's phase is `loading`, `ready`, or `failed`. Beside it, `isStale` +says whether the data is older than its operation's expiration, or was +marked stale by an invalidation. The age survives a launch, because the +image keeps when each operation last fetched. + +The expiration is stated in the document, `@cacheExpiration(seconds:)`. That +is the one directive Baton has that Relay does not. Stale data stays +visible. A `storeOrNetwork` attach refetches it. `revalidate()` on the app's +return refetches whatever went stale meanwhile. Nothing is served as fresh +without a way to tell. ## What it costs, and what it buys -A sloppy schema costs `if let`. The Rick and Morty API marks nearly everything -nullable, which is why the rows on this site read `character.name ?? -"Unknown"`; GitHub's marks `title` and `number` non-null, so a row over GitHub -reads them straight. The error machinery costs nothing at ingest without -errors, and a `@catch` read costs about a hundred nanoseconds more than a -plain one per field, error or not. And a mapped scalar's accessor is optional -where a less honest client would have handed over a zero. - -What it buys is a data layer whose every imperfection is a value: an optional -where the schema allows null, a `Result` where a field failed, a kind where a -fetch failed, a log event and a refetch where data went missing, a case where -an enum grew, and a flag where data went stale. Those are the cases that page -people at two in the morning, and in Baton each of them is something a view -reads and a test asserts, on both runtimes, under the same fixtures. +A sloppy schema costs `if let`. The Rick and Morty API marks nearly +everything nullable, which is why the rows on this site read +`character.name ?? "Unknown"`. GitHub's marks `title` and `number` non-null, +so a row over GitHub reads them straight. + +The error machinery costs nothing at ingest without errors. A `@catch` read +costs about a hundred nanoseconds more than a plain one, per field, error or +not. A mapped scalar's accessor is optional, where a less honest client +would have handed over a zero. + +What it buys is a data layer whose every imperfection is a value: an +optional, where the schema allows null; a `Result`, where a field failed; a +kind, where a fetch failed; a log event and a refetch, where data went +missing; a case, where an enum grew; and a flag, where data went stale. + +Those are the cases that page people at two in the morning. In Baton, each +of them is something a view reads and a test asserts, on both runtimes, +under the same fixtures. diff --git a/docs/articles/twelve-words.md b/docs/articles/twelve-words.md index 8aa92234..a4b61415 100644 --- a/docs/articles/twelve-words.md +++ b/docs/articles/twelve-words.md @@ -1,83 +1,97 @@ # Twelve words, and why the list is closed -GraphQL clients die of configuration. Each real complaint becomes an option: a -cache policy here, a type policy there, a merge function, a link in a chain, a -flag on codegen. Ten years later the client has four codegen modes, a dozen -cache policies and a documentation site explaining which combinations work. -The complaints were real; the answers multiplied the surface instead of fixing -the mechanism. Baton's vocabulary is twelve concepts and Relay's directives, -and a feature that is not a composition of them does not ship. This page is -what that rule refuses, what it buys, and how it looks beside a client that -went the other way. +GraphQL clients die of configuration. Each real complaint becomes an option: +a cache policy here, a type policy there, a merge function, a link in a +chain, a flag on codegen. + +Ten years later the client has four codegen modes, a dozen cache policies, +and a documentation site explaining which combinations work. The complaints +were real. The answers multiplied the surface, instead of fixing the +mechanism. + +Baton's vocabulary is twelve concepts, and Relay's directives. A feature +that is not a composition of them does not ship. This page is what that rule +refuses, what it buys, and how it looks beside a client that went the other +way. ## The twelve -Schema, document, directive, lens, record, store, plan, operation value, -connection, environment, transport, phase. The server's menu; a query, a -mutation, a subscription or a fragment beside a view; the only way behaviour -attaches to data; a typed read-only view over one record; an observable object -keyed by type and id; all records, owned by the main thread; how a response -becomes records, decided by the compiler; an operation's variables, the thing -a parent constructs; a list that pages, merged in the store; the session, one -store and one transport; one verb, `send`, yielding payloads; loading, ready -or failed, always readable. +The twelve are schema, document, directive, lens, record, store, plan, +operation value, connection, environment, transport, and phase, in that +order. + +A schema is the server's menu. A document is a query, a mutation, a +subscription, or a fragment beside a view. A directive is the only way +behaviour attaches to data. A lens is a typed, read-only view over one +record. A record is an observable object, keyed by type and id. A store is +all records, owned by the main thread. A plan is how a response becomes +records, decided by the compiler. An operation value is an operation's +variables, the thing a parent constructs. A connection is a list that pages, +merged in the store. An environment is the session: one store and one +transport. A transport is one verb, `send`, yielding payloads. A phase is +loading, ready, or failed, always readable. [The terminology](../terminology.md) marks each of its entries with its place -in that inventory: the concept it is or is a form of, or the concepts it is a -composition of. An owner is a lens and an operation value. A mutation's action -is an operation value and a payload. A heal is a phase, a store event and a -fetch. Nothing ships that is not named there, and a name comes before a type -in code. - -A concept enters through one test, with two clauses, both required: real -screens demand it, screens people build rather than screens a completeness -argument names, and no composition of the existing concepts expresses it ([the -principle](../principles/what-earns-a-concept.md)). Two words are Baton's own, -because the things they name are new: a lens, which is Relay's fragment -reference and fragment data as one value, and a plan, the normalization -artifact as data. Everything else is Relay's or the specification's. +in that inventory: the concept it is, or is a form of, or the concepts it is +a composition of. An owner is a lens and an operation value. A mutation's +action is an operation value and a payload. A heal is a phase, a store +event, and a fetch. Nothing ships that is not named there. A name comes +before a type in code. + +A concept enters through one test, with two clauses, both required. Real +screens demand it, and those are screens people build rather than screens a +completeness argument names. No composition of the existing concepts +expresses it ([the principle](../principles/what-earns-a-concept.md)). + +Two words are Baton's own, because the things they name are new. A lens is +Relay's fragment reference and fragment data as one value. A plan is the +normalization artifact as data. Everything else is Relay's, or the +specification's. ## What the rule refused -The first adopter filed twenty-one issues in the project's first week, and -answering them is the best record of the rule at work. Each refusal carries -the reason and the condition that would reopen it; the decisions directory has -the long form. +The first adopter filed twenty-one issues in the project's first week. +Answering them is the best record of the rule at work. Each refusal carries +the reason, and the condition that would reopen it. The decisions directory +has the long form. - **A retention policy per type.** The request was a budget: keep so many of - these, evict those first. The answer is the mechanism that already exists, a - release buffer of ten roots, collection from the roots, and eviction from - the image by launch and a size limit; a budget per type was refused, with no - reopening condition, as a policy object at run time for what recency decides - ([the decision](../decisions/the-image-evicts-by-launch.md)). -- **Shipped transport wrappers.** Retries, a deadline, a token refresh. The - transport has one verb, credentials are read per attempt, and the wrapper is + these, and evict those first. The answer is the mechanism that already + exists: a release buffer of ten roots, collection from the roots, and + eviction from the image by launch and a size limit. A budget per type was + refused, with no + reopening condition, as a policy object at run time for what recency + decides ([the decision](../decisions/the-image-evicts-by-launch.md)). +- **Shipped transport wrappers.** Retries, a deadline, and a token refresh + were refused. The transport has one verb, and credentials are read per + attempt. The wrapper is [a recipe](../recipes/exchange.md) of a few dozen lines the app owns, compiled in both languages and tested over a loopback server. A library wrapper would be a policy object with options. - **A `@snapshot` directive.** Relay already has the word: `@inline`, a - fragment that compiles to a value instead of a lens, built when the spread's - accessor is called ([the + fragment that compiles to a value instead of a lens, built when the + spread's accessor is called ([the decision](../decisions/a-fragment-has-one-reading.md)). - **A store swapped under live handles at sign-out.** `Environment.replaceStore` would have needed a generation checked on every - write path. Instead the environment is the session: a sign-in makes one, a - sign-out ends it, and a handle still held reads `gone` ([the + write path. Instead the environment is the session: a sign-in makes one, + and a sign-out ends it. A handle still held reads `gone` ([the decision](../decisions/the-environment-is-the-session.md)). -- **Imperative store updaters.** Every write so far has been a directive on a - fragment or a payload for an operation, and `commitPayload` is the one door +- **Imperative store updaters.** Every write so far has been a directive on + a fragment, or a payload for an operation. `commitPayload` is the one door a payload takes. A write neither can express reopens it. - **A session type, a transport mode, a cache-update function, an age per - record, automatic persisted queries.** Each is in the records with its + record, automatic persisted queries.** Each is in the records, with its reason. -Page-based pagination, a binary wire format and offline sync are on a watch +Page-based pagination, a binary wire format, and offline sync are on a watch list in the planning notes, each with a trigger, not in the plan. Leaving a -problem unsolved is a fair answer; a half-built feature in the library is not. +problem unsolved is a fair answer. A half-built feature in the library is +not. ## Subtraction is a contribution -The rule cuts both ways, and the changelog records removals as plainly as +The rule cuts both ways. The changelog records removals as plainly as additions. Four reporting hooks became one log of value-free events. A generated line per lens, `typeName`, went when nothing needed it. Two spellings of `fetch` became one. The placeholder environment went when the @@ -86,78 +100,92 @@ itself is removed before 1.0. ## Beside a client that dropped GraphQL -The sharpest comparison is not with Apollo but with a client that took Relay's -ideas and removed GraphQL. [fate](https://github.com/nkzw-tech/fate), a data -client for the web whose 1.0 shipped in May 2026, read here from its -repository at 1.7.7 in October 2026, keeps Relay's three insights, a -normalized cache, declarative data dependencies and views colocated with their -data, and drops the language as the adoption barrier: a view is a plain object -literal, a spread is an object spread, and a planner flattens views into field -paths at run time. It is a serious piece of work by one author, and where it -agrees with Baton it agrees with decisions Baton had already settled: a -release buffer of ten, an optimistic layer over the confirmed base rebased -beneath later writes, a partial optimistic record whose absent fields leave -the store alone, a request marked stalled after one refetch, an expiration per -request, a hydration scope that refuses another client's snapshot. That is -evidence, from a different starting point, that the mechanisms are right. - -Where it differs is where Baton's principles decide, and each difference is a -concept Baton would have had to add: +The sharpest comparison is not with Apollo. It is with a client that took +Relay's ideas and removed GraphQL. +[fate](https://github.com/nkzw-tech/fate) is a data client for the web whose +1.0 shipped in May 2026, read here from its repository at 1.7.7 in October +2026. It keeps Relay's three insights: a normalized cache, declarative data +dependencies, and views colocated with their data. It drops the language as +the adoption barrier. A view is a plain object literal. A spread is an +object spread. A planner flattens views into field paths at run time. + +fate is a serious piece of work by one author. Where it agrees with Baton, it +agrees with decisions Baton had already settled: a release buffer of ten; an +optimistic layer over the confirmed base, rebased beneath later writes; a +partial optimistic record, whose absent fields leave the store alone; a +request marked stalled after one refetch; an expiration per request; and a +hydration scope that refuses another client's snapshot. That is evidence, +from a different starting point, that the mechanisms are right. + +Where it differs is where Baton's principles decide. Each difference is a +concept Baton would have had to add. - **A planner at run time, and agents in place of a compiler.** Baton's rule - is that the compiler decides; a diagnostic at the character is the loop a - developer or an agent iterates on, and a plan computed once at build time is + is that the compiler decides. A diagnostic at the character is the loop a + developer, or an agent, iterates on. A plan computed once at build time is a plan the phone never computes. - **Automatic list insertion by heuristic.** fate guesses which lists a - created record belongs in. In Baton an edge directive names its connection, - because a guess about membership is a policy in the store. + created record belongs in. In Baton, an edge directive names its + connection, because a guess about membership is a policy in the store. - **A wire protocol of its own.** Baton asks nothing of a server beyond the - specification, and runs against fate's own GraphQL template server as a - proof of that: the responses it recorded there are fixtures in `spec/fate/`, - passed by both runtimes. + specification. It runs against fate's own GraphQL template server, as a + proof of that. The responses it recorded there are fixtures in + `spec/fate/`, passed by both runtimes. - **An offline mutation journal.** Baton is not an offline sync engine, and - says so on the first page; the concept is on a watch list kept in the + it says so on the first page. The concept is on a watch list kept in the planning notes, with its trigger. -None of this is a verdict on fate for the web. It is the closed inventory -doing its job: each of those four would have been a thirteenth word, and the +None of this is a verdict on fate for the web, but the closed inventory +doing its job. Each of those four would have been a thirteenth word. The test says what each would have had to show first. ## Beside the configured clients -Apollo's native clients are the other direction, and their own documentation -shows the cost. Apollo Kotlin's documentation says its response-based codegen -can grow exponentially with fragment nesting, so operation-based is the -recommended default ([the -docs](https://www.apollographql.com/docs/kotlin/advanced/response-based)), and -that is two codegen modes before the cache's options begin. Apollo iOS users -reported about 20 MB of app size and about 50,000 generated lines from a -sixteen-line fragment change under field merging, the option that controls -whether a parent's model exposes a child's fields +Apollo's native clients are the other direction. Their own documentation +shows the cost. Apollo Kotlin's documentation says its response-based +codegen can grow exponentially with fragment nesting, so operation-based is +the recommended default ([the +docs](https://www.apollographql.com/docs/kotlin/advanced/response-based)). +That is two codegen modes before the cache's options begin. + +Apollo iOS users reported about 20 MB of app size, and about 50,000 generated +lines, from a sixteen-line fragment change under field merging. Field merging +is the option that controls whether a parent's model exposes a child's +fields ([apollo-ios#2560](https://github.com/apollographql/apollo-ios/issues/2560)). -Identity is a type policy, a field policy or a function; freshness is a fetch -policy per call plus an expiration in the cache; the cache is memory, SQL or a -chain, all of it read off the main thread. Every one of those options answers -a real complaint. Baton's bet is that the complaint is better answered by a -mechanism with no knob: masking is the shape of the type, so there is no -field-merging option; identity is read from `baton.json` by the compiler, so -there is no key generator; reads are synchronous, so there is no thread policy -for the cache. + +Identity is a type policy, a field policy, or a function. Freshness is a +fetch policy per call, plus an expiration in the cache. The cache is memory, +SQL, or a chain, all of it read off the main thread. Every one of those +options answers a real complaint. + +Baton's bet is that the complaint is better answered by a mechanism with no +knob. Masking is the shape of the type, so there is no field-merging option. +Identity is read from `baton.json` by the compiler, so there is no key +generator. Reads are synchronous, so there is no thread policy for the +cache. ## What it buys -A vocabulary that fits on one page, so a new engineer or a coding agent can -hold the whole model. One way to do each thing, so a review asks whether the -fragment is right and not which of three mechanisms was chosen. A second -runtime written against the same twelve words within a week, because there -were only twelve to write. Documentation that is short because the surface is. -And depth where it counts: each concept was designed, measured against the -oracle and built once per runtime, which a client that covers twice the ground -does half as well, and the half shows in the first frame and in the bugs. +What it buys is a vocabulary that fits on one page, so a new engineer, or a +coding agent, can hold the whole model. There is one way to do each thing, +so a review asks whether the fragment is right, and not which of three +mechanisms was chosen. A second runtime was written against the same twelve +words within a week, because there were only twelve to write. The +documentation is short because the surface is. + +There is also depth where it counts. Each concept was designed, measured +against the oracle, and built once per runtime. A client that covers twice +the ground +does that half as well, and the half shows in the first frame and in the +bugs. The cost is that some problems are not solved here. An app whose state is -mostly not a graph on a server, one that needs offline sync, or one that pages -by number against a server that cannot change, finds the edge of the inventory -quickly, and the watch list in the planning notes says what would move it. -Better a few strict rules than lots of features: that is how a screen stays -one request, a view stays one fragment, and a change stays one re-render. +mostly not a graph on a server, one that needs offline sync, or one that +pages by number against a server that cannot change, finds the edge of the +inventory quickly. The watch list in the planning notes says what would move +it. + +A few strict rules are better than lots of features, which is how a screen +stays one request, a view stays one fragment, and a change stays one +re-render. diff --git a/docs/articles/two-runtimes-no-bridge.md b/docs/articles/two-runtimes-no-bridge.md index 0ad9867e..20ac2cbb 100644 --- a/docs/articles/two-runtimes-no-bridge.md +++ b/docs/articles/two-runtimes-no-bridge.md @@ -1,106 +1,121 @@ # Two runtimes, no bridge -Baton's compiler is written in Rust, and the obvious next step is a runtime -written in Rust too: one store, one ingest, one persistence layer, bound into -Swift and Kotlin behind thin wrappers. Several sync and messaging SDKs ship -that way, and Meta's own native GraphQL client is a C++ core with wrappers. -Baton refuses it. The Swift runtime is Swift, the Kotlin runtime is Kotlin, -and Rust runs only on the build machine. The runtime is written twice on -purpose, and what holds the two to one behaviour is not shared code but a -shared compiler, a shared contract and shared fixtures. +Baton's compiler is written in Rust. The obvious next step is a runtime +written in Rust too: one store, one ingest, one persistence layer, bound +into Swift and Kotlin behind thin wrappers. + +Several sync and messaging SDKs ship that way. Meta's own native GraphQL +client is a C++ core with wrappers. Baton refuses it. The Swift runtime is +Swift, and the Kotlin runtime is Kotlin. Rust runs only on the build +machine. + +The runtime is written twice on purpose. What holds the two to one behaviour +is not shared code, but a shared compiler, a shared contract, and shared +fixtures. ## The read path forbids a bridge -The design's whole point is that a view reads a field as a synchronous slot -load that registers with the UI framework's observation. On an Apple M1 Pro -that read costs 26 ns; on the JVM 22 ns; on a Google Pixel 9 47 ns -([BENCHMARKS.md](../../BENCHMARKS.md), the 0.1.0 entry and the Apollo Kotlin -rerun of 8 October 2026). A record behind a foreign-function boundary cannot -offer that. A read across the boundary returns a string that has to be copied -into a native string on every render, or the native side caches what it read, -which is a second store with a synchronization problem. Either way the view -holds a copy, and copies are the materialization this library exists to -remove. - -The registrar the framework tracks is a native object per record in any case: -Observation's in Swift, a snapshot-state channel per slot in Kotlin. A Rust -record would need a native shadow to be observable at all. And everything that -walks records lives where the records live: the commit, collection, the -availability check, the optimistic rebase, the connection merge. With native -records those walks are native, or they cross the boundary once per record. +The design's whole point is a read. You read a field as a synchronous slot +load, and that load registers with the UI framework's observation. On an +Apple M1 Pro that read costs 26 ns, on the JVM 22 ns, and on a Google Pixel +9 47 ns ([BENCHMARKS.md](../../BENCHMARKS.md), the 0.1.0 entry and the Apollo +Kotlin rerun of 8 October 2026). + +A record behind a foreign-function boundary cannot offer that. A read across +the boundary returns a string that has to be copied into a native string on +every render, or the native side caches what it read, which is a second +store with a synchronization problem. Either way the view holds a copy. +Copies are the materialization this library exists to remove. + +The registrar the framework tracks is a native object per record, in any +case. In Swift, that object is Observation's, and in Kotlin it is a +snapshot-state channel per slot. A Rust record would need a native shadow +to be observable at all. + +Everything that walks records lives where the records live: the commit, +collection, the availability check, the optimistic rebase, and the +connection merge. With native records, those walks are native. Otherwise +they cross the boundary once per record. Meta's shared C++ core hands product code immutable generated native models, -which are copies, and LinkedIn's did the same; Meta's own 2023 guidance for +which are copies, and LinkedIn's did the same. Meta's own 2023 guidance for its iOS app is that product-facing code should no longer contain C++. -Generated bindings inherited default main-actor isolation in Xcode 26 and -broke for UniFFI, swift-protobuf and openapi-generator users in 2025 and 2026. -A lean Rust core adds two to four megabytes per platform and a second -toolchain for every contributor. The decision record has the list ([Native -runtimes, not a shared core](../decisions/native-runtimes.md)). +Generated bindings inherited default main-actor isolation in Xcode 26. +They broke for UniFFI, swift-protobuf, and openapi-generator users in 2025 +and 2026. A lean +Rust core adds two to four megabytes per platform, and a second toolchain +for every contributor. The decision record has the list ([Native runtimes, +not a shared core](../decisions/native-runtimes.md)). ## What is shared instead -Move the shared part to build time. The compiler aggregates fragments, applies -arguments, inserts identity fields, computes storage keys, hashes persisted -ids, and emits a normalization plan per operation and a lens per fragment. -Both runtimes interpret the same plans, so most of the semantics is computed -once, in one language, and the native code is a small interpreter plus the -platform's own networking, observation and storage. One plan, two emitters: -the generated Swift and the generated Kotlin for an operation carry the same -selections, the same storage keys and the same kinds. - -What remains written twice, the tokenizer, the store, retention, connections, -the image, is specified in files no language owns: - -- [The runtime contract](../../spec/runtime.md), one paragraph a rule, each - naming the fixture that holds it or marked unheld. -- [The fixtures](../../spec/README.md): schemas, the authors' documents with - their client directives, recorded responses, the records a store must hold - after each one as a dump in Relay's `__ref` shape, the reads a generated - lens must answer, and scripts over time for phases, notifications, lifetime, - optimistic layers, connections, the availability check, the heal and - streams. -- The harvest of Relay's own store tests, translated into Baton's keys and run - on both runtimes ([the +Move the shared part to build time. The compiler aggregates fragments, +applies arguments, and inserts identity fields. It also computes storage +keys, hashes persisted ids, and emits a normalization plan per operation +and a lens per fragment. + +Both runtimes interpret the same plans. Most of the semantics is computed +once, in one language. The native code is a small interpreter, plus the +platform's own networking, observation, and storage: one plan, two emitters. +The generated Swift and the generated Kotlin for an operation carry the same +selections, the same storage keys, and the same kinds. + +What remains written twice is the tokenizer, the store, retention, +connections, and the image, specified in files no language owns. + +- [The runtime contract](../../spec/runtime.md) is one paragraph a rule. + Each names the fixture that holds it, or is marked unheld. +- [The fixtures](../../spec/README.md) are schemas, the authors' documents + with their client directives, recorded responses, the records a store must + hold after each one, as a dump in Relay's `__ref` shape, the reads a + generated lens must answer, and scripts over time, for phases, + notifications, lifetime, optimistic layers, connections, the availability + check, the heal, and streams. +- The harvest of Relay's own store tests, translated into Baton's keys and + run on both runtimes ([the decision](../decisions/relays-tests-are-measured-not-obeyed.md)). A divergence is a failing test on both sides. The image's bytes are each -runtime's own; what is shared is that a second store over the image reads the -same records. +runtime's own. What is shared is that a second store over the image reads +the same records. ## How the second runtime was written -The Kotlin runtime was written in October 2026 against the fixtures the Swift -runtime already passed, and the order it was built in is the argument for the -arrangement. First the store and the ingest to the dump: records as snapshot -state per slot, keys numbered by the store, the tokenizer over bytes, until -every case's dump matched byte for byte, with plans transcribed from the Swift -goldens until the Kotlin emitter printed them. Then the readers and lenses, -until every expected read of every operation answered. Then the environment, -handles, phases, retention and collection, run through the scripted transport -until every script passed. Only then the image over the AndroidX SQLite -driver, the HTTP and WebSocket transports, the Compose integration, and a -sample on a device. - -Two findings from that port shaped both runtimes, which is what a second +The Kotlin runtime was written in October 2026, against the fixtures the +Swift runtime already passed. The order it was built in is the argument for +the arrangement. + +First came the store and the ingest, to the dump: records as snapshot state +per slot, keys numbered by the store, and the tokenizer over bytes, until +every case's dump matched byte for byte, with plans transcribed from the +Swift goldens until the Kotlin emitter printed them. + +Then the readers and lenses followed, until every expected read of every +operation answered. Then the environment, handles, phases, retention, and +collection followed, run through the scripted transport until every script +passed. Only then came the image, over the AndroidX SQLite driver, the HTTP +and WebSocket transports, the Compose integration, and a sample on a device. + +Two findings from that port shaped both runtimes. That is what a second implementation is for. A list element reads as the scalar of its type does, -which the contract said and the Swift lists did not do, so Swift was aligned. -And a subscription with no transport retried on its backoff forever, since -only a request error ended a stream, so both runtimes now end a stream on an -environment failure as they do on a request error. +which the contract said and the Swift lists did not do, so Swift was +aligned. A subscription with no transport retried on its backoff forever, +since only a request error ended a stream. Both runtimes now end a stream on +an environment failure, as they do on a request error. The Kotlin runtime is common Kotlin first, with the JVM and Android as its -actuals: a platform supplies the file operations, the SQLite driver, the -socket and the dispatcher, and nothing else ([the +actuals. A platform supplies the file operations, the SQLite driver, the +socket, and the dispatcher, and nothing else ([the decision](../decisions/the-kotlin-runtime-is-common-first.md)). Compose Multiplatform is the same runtime on more targets, not a third one. ## What the two runtimes cost on their platforms -The fixture's 899 records; the Swift row is the 0.1.0 entry of 2 October 2026, -a release build, and the Kotlin rows are "the Kotlin ingest on a device" of 7 -October, the phone a release build and the JVM row a timing that entry gives -for scale rather than a benchmark ([BENCHMARKS.md](../../BENCHMARKS.md)): +The numbers are for the fixture's 899 records. The Swift row is the 0.1.0 +entry of 2 October 2026, a release build. The Kotlin rows are "the Kotlin +ingest on a device" of 7 October, and the phone is a release build. The JVM +row is a timing that entry gives for scale, rather than a benchmark +([BENCHMARKS.md](../../BENCHMARKS.md)). | Where | Ingest, off the main thread | Commit, on the main thread | |---|---|---| @@ -109,41 +124,47 @@ for scale rather than a benchmark ([BENCHMARKS.md](../../BENCHMARKS.md)): | Google Pixel 9, the Kotlin runtime | 5.1 ms | 1.3 ms | The commit, the main thread's share, takes a sixth of a 120 Hz frame on the -phone. The phone runs the same Kotlin three to four times slower than the JVM, -which is ART's allocation cost, and the decision record names the contained -remedy if the Kotlin tokenizer ever fails its budget: a Rust ingest library -for Android only, producing a flat change set the Kotlin store applies, with -iOS unchanged. Nothing today asks for it. +phone. The phone runs the same Kotlin three to four times slower than the +JVM, and that is ART's allocation cost. The decision record names the +contained remedy, if the Kotlin tokenizer ever fails its budget: a Rust +ingest library for Android only, producing a flat change set the Kotlin +store applies, with iOS unchanged. Nothing today asks for it. ## What differs, by design -The two runtimes are one machine, and [the map](../runtimes.md) puts a -mechanism under the same file name in both trees, checked by a script that -fails when a file is missing from either. What differs is the host and the -platform. Swift observes through Observation and the marker macros; Kotlin +The two runtimes are one machine. [The map](../runtimes.md) puts a mechanism +under the same file name in both trees, checked by a script that fails when +a file is missing from either. What differs is the host, and the platform. + +Swift observes through Observation and the marker macros. Kotlin observes through snapshot state and annotations on the composable, with a document -carried in a `$$` raw string because a GraphQL variable's `$` is a Kotlin -template. Swift's threads are actors; Kotlin's are dispatchers and a lock. The -image sits on the system's SQLite in Swift and on the AndroidX driver in -Kotlin. A Swift accessor that can throw is a Kotlin getter that throws; -`@catch` reads as a `Result` on both. A Swift `AsyncSequence` is a Kotlin -`Flow`. Each runtime feels like its platform wrote it, which is the other half -of the principle ([Two runtimes, one +carried in a `$$` raw string, because a GraphQL variable's `$` is a Kotlin +template. Swift's threads are actors. Kotlin's are dispatchers and a lock. +The image sits on the system's SQLite in Swift, and on the AndroidX driver +in Kotlin. + +A Swift accessor that can throw is a Kotlin getter that throws. `@catch` +reads as a `Result` on both. A Swift `AsyncSequence` is a Kotlin `Flow`. +Each runtime feels like its platform wrote it, which is the other half of +the principle ([Two runtimes, one compiler](../principles/two-runtimes-one-compiler.md)). ## What an adopter gets -The Swift runtime depends on Foundation, Observation and the SQLite the system -ships; the Kotlin runtime on the platform's equivalents. No native library -ships inside either, and the compiler is the only Rust a user ever runs, -prebuilt, on the build machine. Networking is the platform's stack, with its -TLS, its proxies, its background behaviour and its HTTP/3. A platform -developer can read, fix and vendor their runtime without the other platform's -toolchain. - -And a team on both platforms gets one vocabulary and one set of guarantees. -The same fragment compiles for both; the same response normalizes into the -same records; the same field error survives caching on both; the same script -of phases holds on both. That is a stronger equivalence than a shared binary -gives, because it is checked where it matters, at the behaviour, and it costs -the read path nothing. +The Swift runtime depends on Foundation, Observation, and the SQLite the +system ships. The Kotlin runtime depends on the platform's equivalents. No +native library ships inside either. The compiler is the only Rust you ever +run, prebuilt, on the build machine. + +Networking is the platform's stack, with its TLS, its proxies, its +background behaviour, and its HTTP/3. A platform developer can read, fix, +and vendor their runtime without the other platform's toolchain. + +A team on both platforms gets one vocabulary, and one set of guarantees. The +same fragment compiles for both. The same response normalizes into the same +records. The same field error survives caching on both. The same script of +phases holds on both. + +That is a stronger equivalence than a shared binary gives, because it is +checked where it matters, at the behaviour, and it costs the read path +nothing. diff --git a/docs/articles/what-meta-built.md b/docs/articles/what-meta-built.md index 275854d3..2c15a68b 100644 --- a/docs/articles/what-meta-built.md +++ b/docs/articles/what-meta-built.md @@ -3,56 +3,62 @@ The most experienced native GraphQL client in the world is one you cannot download. GraphQL was created in 2012 for the native rebuild of the Facebook apps ([GraphQL: A data query -language](https://engineering.fb.com/2015/09/14/core-infra/graphql-a-data-query-language/)), -and Meta's native apps, Facebook and Instagram among them, have run an -in-house client ever since. Its current incarnation is called Pando. It is not -open source, and Meta has not published its storage layout or a single -performance number for it. What is public is a trail of engineering posts from -2012 to 2015, a 2017 post on where Relay Modern's ideas came from, a 2025 -podcast with an engineer on the Mobile GraphQL Platform team, and a handful of -conference abstracts. That trail is the best available evidence on what a -native GraphQL client needs at the extreme, and it is worth reading beside -Baton, because Baton makes most of the same choices and goes further on the -one Meta calls a goal. +language](https://engineering.fb.com/2015/09/14/core-infra/graphql-a-data-query-language/)). +Meta's native apps, Facebook and Instagram among them, have run an in-house +client ever since. + +Its current incarnation is called Pando, and it is not open source. Meta has +not published its storage layout, or a single performance number for it. +What is public is a trail of engineering posts from 2012 to 2015, a 2017 +post on where Relay Modern's ideas came from, a 2025 podcast with an +engineer on the Mobile GraphQL Platform team, and a handful of conference +abstracts. + +That trail is the best available evidence on what a native GraphQL client +needs at the extreme, and it is worth reading beside Baton. Baton makes +most of the same choices, and goes further on the one Meta calls a goal. ## The public record -The 2012 iOS rebuild showed previously cached content at launch instead of a -spinner, kept the network, the JSON parsing and the disk off the main thread, -and stored story heights so that launch read only what the first layout needed -([Under the hood: Rebuilding Facebook for +The 2012 iOS rebuild showed previously cached content at launch, instead of +a spinner. It kept the network, the JSON parsing, and the disk off the main +thread. It stored story heights, so that launch read only what the first +layout needed ([Under the hood: Rebuilding Facebook for iOS](https://engineering.fb.com/2012/08/23/ios/under-the-hood-rebuilding-facebook-for-ios/)). By 2014 the iOS app's data layer was Core Data, a fully normalized SQLite -store, and it had become the bottleneck: entities had gone from dozens to -hundreds, every parse ran a fetch-or-create, and creating and querying model -objects took longer with every entity added. The replacement had three -principles: models are immutable; storage is denormalized, with a cache per -part of the app; and consistency is asynchronous and opt-in, a controller on -background queues that notices a field change and issues updated models. News -Feed came out nearly 50% faster ([Making News Feed nearly 50% faster on +store, and it had become the bottleneck. Entities had gone from dozens to +hundreds. Every parse ran a fetch-or-create. Creating and querying model +objects took longer with every entity added. + +The replacement had three principles: models are immutable; storage is +denormalized, with a cache per part of the app; and consistency is +asynchronous and opt-in: a controller on background queues that notices a +field change and issues updated models. News Feed came out nearly 50% +faster ([Making +News Feed nearly 50% faster on iOS](https://engineering.fb.com/2014/10/31/ios/making-news-feed-nearly-50-faster-on-ios/)). -In 2015 the Android app measured its JSON cost: 35 ms to parse a 20 KB +In 2015 the Android app measured its JSON cost. 35 ms to parse a 20 KB response, more than a 16.6 ms frame, with about 100 KB of transient memory allocated per parse. The fix was FlatBuffers, a format read in place with no -objects built, and the UI reading storage directly through cursors. Story load -time from the disk cache went from 35 ms to 4 ms, transient allocations fell -by 75%, and cold start improved by 10 to 15% ([Improving Facebook's +objects built, and the UI reading storage directly through cursors. Story +load time from the disk cache went from 35 ms to 4 ms. Transient allocations +fell by 75%. Cold start improved by 10 to 15% ([Improving Facebook's performance on Android with FlatBuffers](https://engineering.fb.com/2015/07/31/android/improving-facebook-s-performance-on-android-with-flatbuffers/)). -In 2016 Facebook's native code generation moved from type-based models, with a -getter for every server field, to fragment-based models that carry only the -fields a fragment fetches, so that an unfetched field is a compile error +In 2016 Facebook's native code generation moved from type-based models, with +a getter for every server field, to fragment-based models. Those carry only +the fields a fragment fetches, so an unfetched field is a compile error rather than a runtime one ([Native code generation at Facebook](https://www.apollographql.com/blog/community/native-code-generation-at-facebook-7d6d0db4f3ee/), a 2016 talk summary). The 2017 Relay Modern announcement says where ahead-of-time compilation came -from: the native apps had found that building queries by concatenating strings -and uploading them over slow connections was costly, with queries sometimes -growing into the tens of thousands of lines, so static queries were +from. The native apps had found that building queries by concatenating +strings, and uploading them over slow connections, was costly. Queries +sometimes grew into the tens of thousands of lines. So static queries were constructed once at build time, saved on the server, and replaced in the app with a tiny identifier. Relay Modern adopted the same model for the web ([Relay Modern: Simpler, faster, more @@ -60,153 +66,167 @@ extensible](https://engineering.fb.com/2017/04/18/web/relay-modern-simpler-faste The 2025 podcast is the only public description of the client as it is now. The engineer calls it a framework "very similar to Relay", named Pando, -implemented in C++ in a shared layer with native wrappers around it. It has -two parts for a product developer: a build-time component that generates -type-safe accessors in Kotlin or Java and in Objective-C or Swift for exactly -the fields a query selected, and a runtime that executes queries and -integrates the network, the cache and the consistency engine. The models are -immutable. Consistency is a publish-subscribe engine that tells everywhere -else that a value changed without refetching; generated builders let product -code make a local change, a hidden post or a pending like, that flows through -the same engine, and an optimistic mutation publishes the expected state and -rolls back on failure. Pagination follows the Relay connection specification -from one annotated field. The team tries not to make its APIs too automatic, -and colocating a view's data with the view, Relay's shape, is a longer-term -goal on mobile, not the current state ([Mobile GraphQL at Meta in +implemented in C++ in a shared layer, with native wrappers around it. + +It has two parts for a product developer: a build-time component that +generates type-safe accessors, in Kotlin or Java and in Objective-C or +Swift, for exactly the fields a query selected, and a runtime that executes +queries and integrates the network, the cache, and the consistency engine. + +The models are immutable. Consistency is a publish-subscribe engine that +tells everywhere else that a value changed, without refetching. Generated +builders let product code make a local change, a hidden post or a pending +like, that flows through the same engine. An optimistic mutation publishes +the expected state and rolls back on failure. Pagination follows the Relay +connection specification, from one annotated field. + +The team tries not to make its APIs too automatic. Colocating a view's data +with the view, Relay's shape, is a longer-term goal on mobile, not the +current state ([Mobile GraphQL at Meta in 2025](https://www.metacareers.com/podcast/mobile-graphql-at-meta-in-2025/)). Two more pieces of the record bear on the shape. A 2023 retrospective on the -iOS app's architecture says C++ was the lever that built Meta's abstractions -and that product-facing code should no longer contain it, so that Swift can be -adopted ([The evolution of Facebook's iOS app +iOS app's architecture says C++ was the lever that built Meta's abstractions, +and that product-facing code should no longer contain it, so that Swift can +be adopted ([The evolution of Facebook's iOS app architecture](https://engineering.fb.com/2023/02/06/ios/facebook-ios-app-architecture/)). -And the 2024 post on Threads' performance quantifies why any of this matters: -injecting 0.29 to 0.36 seconds of navigation latency cost 0.54 to 0.81% of +The 2024 post on Threads' performance quantifies why any of this matters. +Injecting 0.29 to 0.36 seconds of navigation latency cost 0.54 to 0.81% of daily active users ([How we think about Threads' iOS performance](https://engineering.fb.com/2024/12/18/ios/how-we-think-about-threads-ios-performance/)). ## Where Baton makes the same choices -Agreement with a client that has run on billions of phones is evidence, and -the list is long. +Agreement with a client that has run on billions of phones is evidence. The +list is long. - **The operation is compiled ahead of time.** Every Baton operation is - static. The compiler validates it against the schema, assembles it from the - fragments it spreads, hashes it to an id, and under Relay's `persistConfig` - the app sends the id alone, as Meta's apps sent a tiny identifier in 2017 - ([the decision](../decisions/an-operation-is-sent-as-text-or-id.md)). -- **Product code gets typed accessors for exactly the fields it selected.** A - Baton fragment compiles to a lens with one accessor per declared field and - nothing else, which is the 2016 move from type-based to fragment-based - models, with masking as the shape of the type. + static. The compiler validates it against the schema, assembles it from + the fragments it spreads, and hashes it to an id. Under Relay's + `persistConfig`, the app sends the id alone, as Meta's apps sent a tiny + identifier in 2017 ([the + decision](../decisions/an-operation-is-sent-as-text-or-id.md)). +- **Product code gets typed accessors for exactly the fields it selected.** + A Baton fragment compiles to a lens, with one accessor per declared field + and nothing else. That is the 2016 move from type-based models to + fragment-based models, with masking as the shape of the type. - **Never build objects a view did not ask for.** The FlatBuffers lesson is that the cost is in the objects, not the bytes. Baton's ingest decodes a - response's bytes straight into record slots by the plan the compiler - emitted, with no model tree in between, and a lens reads a slot. On an Apple - M1 Pro on 2 October 2026 a 686 KB response is in the store 3.4 ms after the - bytes arrive, and `JSONSerialization` alone takes 4.47 ms on the same bytes - ([BENCHMARKS.md](../../BENCHMARKS.md), the 0.1.0 entry). + response's bytes straight into record slots, by the plan the compiler + emitted, with no model tree in between. A lens reads a slot. On an Apple + M1 Pro, on 2 October 2026, a 686 KB response is in the store 3.4 ms after + the bytes arrive. `JSONSerialization` alone takes 4.47 ms on the same + bytes ([BENCHMARKS.md](../../BENCHMARKS.md), the 0.1.0 entry). - **Cached content in the first frame.** Reads are synchronous on the main - actor, the handle resolves against the store before the first body, and an + actor. The handle resolves against the store before the first body. An image on disk is read before the first body on the next launch ([The first frame has the data](the-first-frame.md)). -- **Consistency without refetching, and optimistic writes that roll back.** A - mutation's payload lands in the one record a character is, and every view +- **Consistency without refetching, and optimistic writes that roll back.** + A mutation's payload lands in the one record a character is. Every view reading it sees the change. An optimistic response goes through the same - ingest as a server's answer and is reverted if the server refuses. + ingest as a server's answer, and is reverted if the server refuses. - **Pagination from one annotated field.** `@connection` on a refetchable - fragment; the store merges the pages; the lens pages with `loadNext`. + fragment. The store merges the pages. The lens pages with `loadNext`. - **Incremental delivery.** `@defer` and `@stream`, which grew partly out of Meta's needs, are compiled and merged into the store in both runtimes. ## Where Baton goes further -**Colocation is the current state, not a goal.** The fragment sits on the view -that renders it, in the Swift or Kotlin file, as a real GraphQL document, and -the compiler assembles the screen's operation from the fragments spread into -it. Nobody writes the screen's query. The 2025 podcast names this as where -Meta's mobile stack is trying to get. It is where Baton starts. +**Colocation is the current state, not a goal.** The fragment sits on the +view that renders it, in the Swift or Kotlin file, as a real GraphQL +document. The compiler assembles the screen's operation from the fragments +spread into it. Nobody writes the screen's query. The 2025 podcast names +this as where Meta's mobile stack is trying to get, and it is where Baton +starts. **The typed view points at a live record, not at a copy.** Meta's family of designs, from the 2014 iOS layer to Pando, hands product code an immutable -model tree and runs a consistency engine that republishes new trees when an -entity changes. Reads of a tree are free; updates cost work proportional to -the trees that mention the id, and the app holds two copies of every fact, one -in the cache and one in the model. Baton collapses the two. The normalized -record is the only copy, and the lens is the immutable typed view Meta -generates, pointed at a live slot. The consistency engine is the UI -framework's own observation: a commit notifies the slots that changed, and the -bodies that read them run ([Relay without the read -side](relay-without-the-read-side.md)). There is no second tree to rebuild and -no engine to keep it consistent. - -**No shared C++ core.** Pando is C++ with wrappers, which is one -implementation of the hard parts for two platforms. Baton's compiler is Rust -and runs on the build machine; the runtimes are Swift and Kotlin, written -twice on purpose, held to one behaviour by one compiler's plans and one set of -fixtures ([Two runtimes, no bridge](two-runtimes-no-bridge.md)). Meta's own -2023 guidance, that product-facing code should not contain C++, points the -same way; a lens read that costs 26 ns as a native slot load would cost a -string copy across a foreign-function boundary on every render, or a native -shadow cache, which is a second store. +model tree. A consistency engine republishes new trees when an entity +changes. Reads of a tree are free, and updates cost work proportional to +the trees that mention the id. The app holds two copies of every fact, one +in the cache and one in the model. + +Baton collapses the two, so the normalized record is the only copy. The +lens is the immutable typed view Meta generates, pointed at a live slot. The +consistency engine is the UI framework's own observation. A commit notifies +the slots that changed, and the bodies that read them run ([Relay without +the read side](relay-without-the-read-side.md)). There is no second tree to +rebuild, and no engine to keep it consistent. + +**No shared C++ core.** Pando is C++ with wrappers, one implementation of +the hard parts for two platforms. Baton's compiler is Rust, and it runs on +the build machine. The runtimes are Swift and Kotlin, written twice on +purpose, held to one behaviour by one compiler's plans and +one set of fixtures ([Two runtimes, no +bridge](two-runtimes-no-bridge.md)). + +Meta's own 2023 guidance, that product-facing code should not contain C++, +points the same way. A lens read that costs 26 ns as a native slot load +would cost a string copy across a foreign-function boundary on every render, +or a native shadow cache, which is a second store. **Any server that follows the specification.** Pando is built for Meta's -schema, Meta's servers and Meta's transport. Baton asks nothing of a server +schema, Meta's servers, and Meta's transport. Baton asks nothing of a server beyond GraphQL over HTTP, with `graphql-transport-ws` and `graphql-sse` for -subscriptions, and it has been run against fate's GraphQL template server on -Yoga, GitHub's API and the public Rick and Morty API. The wire is JSON. A -binary response format is on a watch list in the planning notes with a -trigger, not in the plan; a GraphQLConf 2026 session abstract from WhatsApp -reports that its Argo encoding makes responses 27 to 50% smaller than JSON, -which is the kind of evidence that would move it. +subscriptions. It has been run against fate's GraphQL template server on +Yoga, GitHub's API, and the public Rick and Morty API. The wire is JSON. + +A binary response format is on a watch list in the planning notes, with a +trigger, not in the plan. A GraphQLConf 2026 session abstract from WhatsApp +reports that its Argo encoding makes responses 27 to 50% smaller than JSON. +That is the kind of evidence that would move it. **Relay's words, Relay's compiler.** Because the front end is Relay's own, pinned behind Baton's driver, a team that runs Relay on the web and Baton on native speaks one language: the same directives, the same connection -specification, the same error directives. Meta's web and native stacks share a -north star; Baton's share a parser. +specification, and the same error directives. Meta's web and native stacks +share a north star. Baton's share a parser. **Every number is public.** Meta has not published Pando's numbers. Baton's -are in [BENCHMARKS.md](../../BENCHMARKS.md) with the revision, the machine, -the OS and the date, the comparison harnesses are in the repository, and -anyone can rerun or correct them. +are in [BENCHMARKS.md](../../BENCHMARKS.md), with the revision, the machine, +the OS, and the date. The comparison harnesses are in the repository. Anyone +can rerun them, or correct them. ## What Meta has that Baton does not Honesty is part of the argument. Pando runs at a scale no open-source client -has been tested at, has for years, and has a team around it. Meta has pushed -the specification from the client side: `@defer` and `@stream`, then `@async`, -presented at GraphQLConf 2025 so that products ask for data only when it is -needed ([What launched at GraphQLConf -2025](https://graphql.org/blog/2025-10-20-graphql-conf-2025-article-1/)); -batching with dependencies between queries, in use internally by 2016 ([New +has been tested at, has for years, and has a team around it. + +Meta has pushed the specification from the client side. `@defer` and +`@stream`, then `@async`, presented at GraphQLConf 2025 so that products ask +for data only when it is needed ([What launched at GraphQLConf +2025](https://graphql.org/blog/2025-10-20-graphql-conf-2025-article-1/)). +Batching with dependencies between queries, in use internally by 2016 ([New features in -GraphQL](https://www.apollographql.com/blog/community/backend/new-features-in-graphql-batch-defer-stream-live-and-subscribe/)); -QUIC for GraphQL requests in the Facebook app, with a 20% cut in tail latency -([How Facebook is bringing QUIC to +GraphQL](https://www.apollographql.com/blog/community/backend/new-features-in-graphql-batch-defer-stream-live-and-subscribe/)). +QUIC for GraphQL requests in the Facebook app, with a 20% cut in tail +latency ([How Facebook is bringing QUIC to billions](https://engineering.fb.com/2020/10/21/networking-traffic/how-facebook-is-bringing-quic-to-billions/)). -Baton's transport is the platform's own stack, which gives it HTTP/3 where the -server supports it and nothing the specification does not have. And Baton is -at 0.16, with an API that will break before 1.0 and no install base to speak -of. + +Baton's transport is the platform's own stack. That gives it HTTP/3 where +the server supports it, and nothing the specification does not have. Baton +is at 0.16, with an API that will break before 1.0, and no install base to +speak of. ## The lesson Every public description of Meta's native stack, from 2014 to 2025, has the -same shape: compile the operation ahead of time, hand product code immutable -typed accessors for exactly the fields it asked for, never materialize what a -view does not read, keep the synchronous read path cheaper than a frame, and -show cached content first. That is the shape Baton ships, for SwiftUI and -Compose, with the platform's own observation in place of the consistency -engine and the fragment on the view today. It is the design the people with -the hardest version of the problem converged on, and it is the one you can add -to a package manifest this afternoon. +same shape. Compile the operation ahead of time, and hand product code +immutable typed accessors for exactly the fields it asked for. Never +materialize what a view does not read. Keep the synchronous read path +cheaper than a frame, and show cached content first. + +That is the shape Baton ships, for SwiftUI and Compose, with the platform's +own observation in place of the consistency engine, and the fragment on the +view today. It is the design the people with the hardest version of the +problem converged on, and the one you can add to a package manifest this +afternoon. ## Sources -The engineering posts linked above; the podcast transcript, which was read -through an automated extraction, so its wording is paraphrased except for the -three words quoted; the GraphQLConf 2025 and 2026 session abstracts on -`graphql.org` and the conferences' schedule pages. None of them describes -Pando's storage layout, wire format or performance, and none announces its +The engineering posts linked above. The podcast transcript, which was read +through an automated extraction, so its wording is paraphrased except for +the three words quoted. The GraphQLConf 2025 and 2026 session abstracts on +`graphql.org`, and the conferences' schedule pages. None of them describes +Pando's storage layout, wire format, or performance, and none announces its release. Baton's numbers are from [BENCHMARKS.md](../../BENCHMARKS.md). diff --git a/docs/principles/compiler-decides.md b/docs/principles/compiler-decides.md index 5c1e9b4e..ca0864d8 100644 --- a/docs/principles/compiler-decides.md +++ b/docs/principles/compiler-decides.md @@ -1,55 +1,63 @@ # The compiler decides; the runtime executes -Every operation is static. Identity, aggregation, masking, storage keys, -persisted ids, nullability and diagnostics are settled at build time. The -runtime interprets plans; it never parses GraphQL or consults a policy -object, and no path a view or a commit takes hashes a field name. +Every operation is static, so the runtime interprets the plan and never +parses GraphQL. + +Identity, aggregation, masking, storage keys, persisted ids, nullability +and diagnostics are settled at build time, and the runtime never consults +a policy object. No path a view or a commit takes hashes a field name. ## Why -A client that reasons about GraphQL at run time pays for it on every request -and in every binary: a parser, a validator, an executor over selection -metadata, string-keyed records, type policies looked up by name. It also -cannot tell a developer at build time that a field does not exist, that a -parent never spread the fragment a child reads, or that two operations will -collide in the cache. Meta's native apps built their queries once at build -time and sent an identifier instead of the text, before Relay Modern adopted -the same model; the clients that did not are the ones with about 159 ms of -type mapping behind 6 ms of parsing. +A client that reasons about GraphQL at run time pays for it on every +request and in every binary. It carries a parser, a validator and an +executor over selection metadata, with records keyed by strings and type +policies looked up by name. The same client cannot tell you at build time +that a field does not exist, that a parent never spread the fragment a +child reads, or that two operations will collide in the cache. Meta's +native apps built their queries once, at build time, and sent an identifier +instead of the text, before Relay Modern adopted the same model. The +clients that did not are the ones with about 159 ms of type mapping behind +6 ms of parsing. -The opposite failure is a compiler that knows too little: one that validates -documents but leaves aggregation, identity and cache behaviour to runtime -configuration. Then the same cache bugs return, with a build step added. +The opposite failure is a compiler that knows too little. It validates +documents, then leaves aggregation, identity and cache behaviour to runtime +configuration, so the same cache bugs come back. You only added a build +step. ## The idea The compiler reads the schema, the identity configuration and every document -in the app, whether in Swift source or `.graphql` files. It validates, applies -fragment arguments by cloning per unique argument set, inlines fragments into -one normalization plan per operation, inserts the key fields and typenames -identity needs, computes every storage key and emits a constant for it, -hashes the operation text to a persisted id the transport can send, and -emits the lens types and the plans. The process numbers each constant the -first time it is touched, in one table every module shares, because modules -compile apart and share one store -([Slots are numbered by the process](../decisions/slots-are-numbered-by-the-process.md)); -a key with variables is resolved once per owner, and a field read through -an interface or union once per concrete type. Errors carry the -file, line and column of the GraphQL text inside the Swift source. The front -end is Relay's compiler, pinned, behind a driver that is ours; the plan format -is the seam between the two. +in the app, whether in Swift source or `.graphql` files. It validates, then +applies fragment arguments by cloning per unique argument set and inlines +fragments into one normalization plan per operation. Key fields and the +typenames identity needs are inserted. Every storage key is computed and +emitted as a constant, and the operation text is hashed to a persisted id +the transport can send. Lens types and the plans are emitted too. + +The process numbers each constant the first time it is touched, and one +table is shared by every module, because modules compile apart and share +one store +([Slots are numbered by the process](../decisions/slots-are-numbered-by-the-process.md)). +A key with variables is resolved once per owner, and a field read through +an interface or union is resolved once per concrete type. Errors carry the +file, line and column of the GraphQL text inside the Swift source. The +front end is Relay's compiler, pinned, behind a driver that is ours, and +the plan format is the seam between the two. The runtime is a tokenizer that follows a plan, a store that merges slots, -accessors that read them, and a transport that sends ids. It has no opinion. +accessors that read them, and a transport that sends ids. It has no +opinion. ## Consequences - Nothing is configurable at run time that could be decided at build time. - Identity lives in schema configuration; fetch behaviour is a directive or a - value on a handle. -- Diagnostics are a build artifact and appear inline in the editor. -- A compile-time plan means a compile-time size: the compiler's tests fence - the bytes of generated code per accessor line at 120, over the goldens. + Identity lives in schema configuration, and fetch behaviour is a directive + or a value on a handle. +- Diagnostics are a build artifact, and they appear inline in the editor. +- A compile-time plan means a compile-time size, so the compiler's tests + fence the bytes of generated code per accessor line at 120, over the + goldens. - The Swift and Kotlin runtimes consume the same plans, so they can only disagree in execution, which the fixtures catch. - Adopting a new specification feature is our decision, not a dependency's. @@ -68,11 +76,11 @@ See [Relay's words](relays-words.md) for why the front end is Relay's, and ## Spelled today -`batonc`, run by a SwiftPM and Xcode build-tool plugin over a binary +`batonc` runs as a SwiftPM and Xcode build-tool plugin, over a binary artifact bundle, with `baton.json` beside the target or the package. Each -source that declares GraphQL, a Swift file or a `.graphql` or `.gql` file, -writes one output named by its path in the target (`Screens/Home.swift` -writes `Screens_Home.baton.swift`), and the module's types, slots and sites -go to one shared `Baton.baton.swift`, so a `Baton.swift` at the target's -root may not declare GraphQL. A document with an error writes nothing. This -section may rot; the rest must not. +source that declares GraphQL, whether a Swift file or a `.graphql` or +`.gql` file, writes one output named by its path in the target. +`Screens/Home.swift` writes `Screens_Home.baton.swift`. The module's types, +slots and sites go to one shared `Baton.baton.swift`, so a `Baton.swift` at +the target's root may not declare GraphQL. A document with an error writes +nothing. This section may rot. The rest must not. diff --git a/docs/principles/fragment-is-a-lens.md b/docs/principles/fragment-is-a-lens.md index 48cf5a39..7c301559 100644 --- a/docs/principles/fragment-is-a-lens.md +++ b/docs/principles/fragment-is-a-lens.md @@ -1,57 +1,59 @@ # A fragment is a lens -A fragment compiles to a typed, read-only view over one record: a reference -and one accessor per declared field. Nothing is decoded to hand it to a view. -The value a parent passes to a child is the fragment itself: a record -reference and a context. +A fragment compiles to a lens, a typed window onto one record. + +You get a reference and one accessor per field you declared, and nothing is +decoded to hand it to a view. The value a parent passes to a child is the +fragment itself: a record reference and a context. ## Why Every native GraphQL client before this one decoded responses into models. -Apollo's generated structs wrap a dictionary and cast on every access; its +Apollo's generated structs wrap a dictionary and cast on every access. Its Kotlin sibling parses into models, re-serializes them into records, and rebuilds the model tree on every read. Meta measured the cost in 2015: -parsing a 20 KB response on Android took 35 ms, more than a frame, and the -fix that removed it was a format read in place, with no objects built. -Baton's own bench shows where the time goes: Foundation tokenizes a 686 KB -response in 4.47 ms, and Apollo iOS builds its models from the same bytes -in 317 ms. The models also leak. A parent that holds a child's fragment as a -struct can read its fields, so components grow implicit dependencies, and the -generated code grows with every field merged into every type that mentions -it: twenty megabytes of app size from one codegen option, in one reported -case. +parsing a 20 KB response on Android took 35 ms, more than a frame. The fix +that removed it was a format read in place, with no objects built. Baton's +own bench shows where the time goes: Foundation tokenizes a 686 KB response +in 4.47 ms, and Apollo iOS builds its models from the same bytes in 317 ms. +The models also leak: a parent that holds a child's fragment as a struct +can read its fields, so components grow implicit dependencies. The generated +code grows with every field merged into every type that mentions it: twenty +megabytes of app size, from one codegen option, in one reported case. -The opposite failure is a client that hands views the raw records. Then -masking is a convention, types are strings, and the first refactor of a -fragment breaks a view nobody knew depended on it. +The opposite failure is a client that hands views the raw records: masking +is a convention, types are strings, and the first refactor of a fragment +breaks a view nobody knew depended on it. ## The idea The compiler knows every fragment and every field it selects. For each -fragment it emits a struct holding a record reference (and a context when the -fragment uses variables) and a one-line accessor per field. Reading -`character.name` loads a slot. A linked field returns another lens; a plural -field returns a lazy collection of lenses, identifiable by record. A spread -into another fragment becomes a named accessor returning that fragment's -lens, optional exactly when the spread is conditional or deferred. +fragment it emits a struct holding a record reference (and a context when +the fragment uses variables) and a one-line accessor per field. Reading +`character.name` loads a slot. A linked field returns another lens, and a +plural field returns a lazy collection of lenses, identifiable by record. A +spread into another fragment becomes a named accessor returning that +fragment's lens, optional exactly when the spread is conditional or +deferred. Masking falls out of the shape: the lens has no accessor for a field the -fragment did not declare, and the compiler is the only thing that can add one. -Observation falls out of the shape too: the accessor is where a read is -registered, so a view body depends on the fields it read and nothing else. +fragment did not declare, and the compiler is the only thing that can add +one. Observation falls out of the shape too: the accessor is where a read +is registered, so a view body depends on the fields it read and nothing +else. ## Consequences -- There is no model to copy, mutate, persist or keep in sync. The store is the - only data. -- Generated code for a lens is one line per field plus a few data tables. - No `Codable`, no `Hashable` beyond identity, no initializers except +- There is no model to copy, mutate, persist or keep in sync, and the store + is the only data. +- Generated code for a lens is one line per field plus a few data tables: + no `Codable`, no `Hashable` beyond identity, and no initializers except opt-in test builders. The explicit snapshot is generated too, as a - fragment's other reading: an `@inline` fragment compiles to a value, not + fragment's other reading. An `@inline` fragment compiles to a value, not a lens ([the decision](../decisions/a-fragment-has-one-reading.md)). -- A lens is valid only on the main actor, where the store lives. Code that - needs a value elsewhere hops there or takes an explicit snapshot. -- Equality of lenses is identity: same record, same context. SwiftUI's +- A lens is valid only on the main actor, where the store lives, so code + that needs a value elsewhere hops there or takes an explicit snapshot. +- Equality of lenses is identity (same record, same context), so SwiftUI's diffing of a view holding a lens compares references. - A fragment's name appears once in Swift, as the type of the property that holds it. @@ -61,7 +63,7 @@ registered, so a view body depends on the fields it read and nothing else. - Decoding responses into `Codable` models and normalizing from the models. - A parent reading a child's fields through a shared struct. - Fragment references as protocols a generic view is constrained by. -- Materializing a snapshot of a fragment to decide what re-renders; +- Materializing a snapshot of a fragment to decide what re-renders, because Observation decides that. See [The store is the UI's state](store-is-the-ui-state.md) for what a read @@ -71,18 +73,18 @@ lens. ## Spelled today A fragment `CharacterRow_character` compiles to -`struct CharacterRow_character: Baton.Lens`, which holds its anchor and has -one accessor per field it declares; an operation root compiles to -`CharactersScreenQuery.Data`, and a linked field to a nested struct such as -`Data.Characters`. A spread is an accessor such as `characterRow` that -returns the child's lens, and a plural link is a `Baton.List`, a -`RandomAccessCollection` of lenses. A lens is `Equatable` by its anchor, -so a row view may be too. A view declares +`struct CharacterRow_character: Baton.Lens`, which holds its anchor and one +accessor per field it declares. An operation root compiles to +`CharactersScreenQuery.Data`, and a linked field compiles to a nested struct +such as `Data.Characters`. A spread is an accessor such as `characterRow` +that returns the child's lens, and a plural link is a `Baton.List`, a +`RandomAccessCollection` of lenses. A lens is `Equatable` by its anchor, so +a row view may be too. A view declares `@Fragment("…") var character: CharacterRow_character`. The anchor a lens -holds, the initializer that takes one and every reader behind the accessors -are `@_spi(Generated)`, which the generated files import and an app's files -do not, so another module cannot make one fragment's lens from another's. -Swift does not check SPI inside the module that declares it, so in the -module the lenses were generated in, `Other(anchor: lens.anchor)` still -compiles, while the anchor's readers stay out of reach. This section may -rot; the rest must not. +holds, the initializer that takes one, and every reader behind the accessors +are `@_spi(Generated)`. The generated files import that SPI, and an app's +files do not, so another module cannot make one fragment's lens from +another's. Swift does not check SPI inside the module that declares it, so +in the module the lenses were generated in, `Other(anchor: lens.anchor)` +still compiles, while the anchor's readers stay out of reach. This section +may rot. The rest must not. diff --git a/docs/principles/honest-data.md b/docs/principles/honest-data.md index 46779a5d..878a8ad7 100644 --- a/docs/principles/honest-data.md +++ b/docs/principles/honest-data.md @@ -1,56 +1,59 @@ # Honest data -Nullability is what the schema says. Missing data is reported and healed, -never invented silently. Field errors survive caching and are visible where -they occurred. Staleness is a phase a view can read, not a surprise it -cannot. +Nullability is what the schema says, and missing data is reported and +healed, never invented silently. + +Field errors survive caching and are visible where they occurred. Staleness +is a phase a view can read, not a surprise it cannot. ## Why -GraphQL clients lie in small ways that compound. They type every field +GraphQL clients lie in small ways that compound. Every field is typed nullable because any field can fail, so product code handles permutations -that never happen. They hide a field error behind a `null` and the view shows -an empty label. They return `undefined` for data a related write made -disappear, and the first `.length` crashes. They serve a cached value as if -it were fresh, and a product team routes around the cache with mutations. -Relay spent its 18th release fixing the first two with `@catch`, -`@throwOnFieldError` and semantic nullability; Netflix wrote about the last. +that never happen. A field error is hidden behind a `null`, and the view +shows an empty label. Data a related write made disappear is returned as +`undefined`, and the first `.length` crashes. A cached value is served as +if it were fresh, and a product team routes around the cache with +mutations. Relay spent its 18th release fixing the first two, with +`@catch`, `@throwOnFieldError` and semantic nullability. Netflix wrote +about the last. -The opposite failure is a client that crashes on any imperfection: SwiftData's -invalidated-model trap is the precedent nobody wants. +The opposite failure is a client that crashes on any imperfection: +SwiftData's invalidated-model trap is the precedent nobody wants. ## The idea -Types follow the schema. A non-null field is a non-optional property; a -nullable one is optional; `@required` moves the null up to where the product -wants it; `@catch` turns a failure into a `Result`; a schema that declares -semantic non-null gets non-optional types where a handling policy is in -place; a server that advertises `onError` behaviour is asked for it. +Types follow the schema: a non-null field is a non-optional property, and a +nullable one is optional. `@required` moves the null up to where the product +wants it, and `@catch` turns a failure into a `Result`. A schema that +declares semantic non-null gets non-optional types where a handling policy +is in place, and a server that advertises `onError` behaviour is asked for +it. -Errors are data: the normalizer stores a field's error beside the field, so a -cached read sees the same error the network read saw. Staleness is data: a -handle's phase says whether the content is current, refreshing or failed, and -the previous content stays visible while a refresh runs. +Errors are data: the normalizer stores a field's error beside the field, so +a cached read sees the same error the network read saw. Staleness is data +too: a handle's phase says whether the content is current, refreshing or +failed, and the previous content stays visible while a refresh runs. Missing data is the one case the types cannot express. It arises only when a link is retargeted by another operation to a record fetched with fewer -fields. Then a lens returns the type's zero value for that read, the store -records the event, marks the owning operation stale and refetches it, and a -debug build raises a runtime issue naming the fragment, the record and the -field. A view shows a blank for a moment and then the truth; a developer sees -exactly where and why. +fields, and then a lens returns the type's zero value for that read. The +store records the event, marks the owning operation stale and refetches it. +A debug build raises a runtime issue naming the fragment, the record and +the field. A view shows a blank for a moment and then the truth. You see +exactly where, and why. ## Consequences - Reading a non-null field never crashes and never returns a made-up value - silently: either the value, or a zero value with a recorded event and a + silently: you get the value, or a zero value with a recorded event and a refetch under way. -- Field errors are part of the fixtures; a cached read must surface the same - error as a fresh one. -- `isRefreshing` and `failed(error)` are ordinary states of a handle; a view - decides what to show. -- Debug builds are loud about missing data; release builds are quiet and - self-healing. +- Field errors are part of the fixtures, so a cached read must surface the + same error as a fresh one. +- `isRefreshing` and `failed(error)` are ordinary states of a handle, and a + view decides what to show. +- Debug builds are loud about missing data, while release builds are quiet + and self-healing. - The directives that express this are Relay's, so a web team's habits transfer. @@ -63,30 +66,30 @@ exactly where and why. - Inventing default values that look like data. See [The response is the oracle](response-is-the-oracle.md) for where truth -comes from and [Relay's words](relays-words.md) for the directive vocabulary. +comes from, and [Relay's words](relays-words.md) for the directive +vocabulary. ## Spelled today -As of 0.6.0: `phase` on an operation value (`.loading`, `.ready(data)`, -`.failed(error)`) with `isRefreshing` and `isStale`, which keep their meaning -across a launch because the image stores when each operation fetched; -since 0.7.0, `fetch` beside it, the last fetch as a value, idle, in flight -or failed with a `Failure` of one of four kinds and the time it failed, so -a fetch that fails behind data is seen without the data going; field -errors stored beside the field, in memory and in the image, and read through -`@catch(to:)` as a `Result`; -`@required(action:)` bubbling at the lens boundary, logging a -`requiredFieldMissing` event, or throwing from the accessor; -`@throwOnFieldError` failing the operation or throwing at the spread, with -`@semanticNonNull` types under it; `onError` in `baton.json`, sent with every -operation the target compiles; -the environment's `log` with a `missing` event for missing data and an +As of 0.6.0, `phase` on an operation value is `.loading`, `.ready(data)` or +`.failed(error)`, with `isRefreshing` and `isStale`. Those keep their +meaning across a launch, because the image stores when each operation +fetched. Since 0.7.0, `fetch` sits beside `phase` and is the last fetch as +a value: idle, in flight, or failed, with a `Failure` of one of four kinds +and the time it failed. A fetch that fails behind data is seen without the +data going. Field errors are stored beside the field, in memory and in the +image, and read through `@catch(to:)` as a `Result`. `@required(action:)` +bubbles at the lens boundary, logs a `requiredFieldMissing` event, or throws +from the accessor. `@throwOnFieldError` fails the operation or throws at +the spread, with `@semanticNonNull` types under it. `onError` in +`baton.json` is sent with every operation the target compiles. The +environment's `log` has a `missing` event for missing data, and an `unexpected` one for a null in a field typed non-null or a value of another -kind, with zero values -from the `required*` readers and one placeholder record per type behind a -non-null link without data. A server's response that omits a field the -operation selected fails the fetch as malformed, so missing data arises only -when a link is retargeted; an optimistic response and `commitPayload` may -carry part of a selection. The heal: a read that finds data missing marks -the owning operation stale and refetches it, once per fetch, if a holder -allows the network. This section may rot; the rest must not. +kind. Zero values come from the `required*` readers, and one placeholder +record per type sits behind a non-null link without data. A server's +response that omits a field the operation selected fails the fetch as +malformed, so missing data arises only when a link is retargeted. An +optimistic response and `commitPayload` may carry part of a selection. The +heal is that a read which finds data missing marks the owning operation +stale and refetches it, once per fetch, if a holder allows the network. +This section may rot. The rest must not. diff --git a/docs/principles/relays-words.md b/docs/principles/relays-words.md index 0cc1c539..82bd0b97 100644 --- a/docs/principles/relays-words.md +++ b/docs/principles/relays-words.md @@ -1,18 +1,20 @@ # Relay's words -Where Relay or the GraphQL specification has a word, Baton uses it: the -directives, the connection specification, the fetch policies, the glossary. -Baton invents a word only where neither has one, and drops what is React's or -Meta's rather than GraphQL's. +Where Relay or the GraphQL specification has a word, Baton uses it, and +invents a word only where neither has one. + +That covers the directives, the connection specification, the fetch policies +and the glossary. Baton also drops what is React's, or Meta's, rather than +GraphQL's. ## Why A team that runs Relay on the web and a different client on native keeps two mental models of the same schema: two pagination conventions, two names for -"refetch this piece", two error-handling stories. The clients that invented -their own vocabulary (type policies, links, field policies, codegen modes) -taught their users words that mean nothing anywhere else. Relay's words are -the ones a decade of schema design has been shaped around: `Node`, +"refetch this piece", and two error-handling stories. The clients that +invented their own vocabulary (type policies, links, field policies, codegen +modes) taught their users words that mean nothing anywhere else. Relay's +words are the ones a decade of schema design has been shaped around: `Node`, `@connection`, `@refetchable`, `@required`, `@catch`, `@argumentDefinitions`. The opposite failure is porting Relay whole. Half of its runtime exists to @@ -25,34 +27,35 @@ Baton consumes the Relay compiler's front end, so Relay's directives are parsed and validated by the code that defines them. The directive set is Relay's, with one exception, `@cacheExpiration(seconds:)`, for what neither Relay nor the specification has a word -([the decision](../decisions/an-operation-states-its-expiration.md)); the -connection handling is Relay's; the fetch policies are Relay's -four; the glossary terms (operation, fragment, record, store, environment, -retain, release buffer) are Relay's. Configuration keeps Relay's key names, +([the decision](../decisions/an-operation-states-its-expiration.md)). +Connection handling is Relay's, the fetch policies are Relay's four, and +the glossary terms are Relay's: operation, fragment, record, store, +environment, retain, release buffer. Configuration keeps Relay's key names, in `baton.json`: `schema` today, and `schemaExtensions`, `customScalarTypes` -and `persistConfig` as each is built; `relay.config.json` itself is not read. +and `persistConfig` as each is built. `relay.config.json` itself is not +read. What Baton does not port is named in the vision: snapshots and seen-record -sets, structural recycling, suspension by thrown promises, the generator-based -collector, string-keyed records, Resolvers, client edges, data-driven -dependencies, updatable fragments, multi-actor stores, and filename-based -naming rules. +sets, structural recycling, suspension by thrown promises, the +generator-based collector, string-keyed records, Resolvers, client edges, +data-driven dependencies, updatable fragments, multi-actor stores, and +filename-based naming rules. -Two words are Baton's, because the thing they name is new: a *lens* (Relay -has a fragment reference and fragment data; here they are one value) and a -*plan* (the normalization artifact as data). +Two words are Baton's, because the thing they name is new. A *lens* is +Relay's fragment reference and fragment data, made one value. A *plan* is +the normalization artifact as data. ## Consequences - A Relay developer reads a Baton fragment without a glossary. - Server conventions are opt-in capabilities, as they are in Relay's - compiler: `Node` for refetch, connections for pagination, neither required - to normalize. + compiler. `Node` is for refetch, connections are for pagination, and + neither is required to normalize. - A Baton-specific directive needs the membership test in - [What earns a concept](what-earns-a-concept.md) and a reason Relay has no + [What earns a concept](what-earns-a-concept.md), and a reason Relay has no word for it. -- Terminology entries carry their lineage: where the word comes from and why - it was chosen over the alternatives. +- Terminology entries carry their lineage: where the word comes from, and + why it was chosen over the alternatives. ## Not this @@ -65,10 +68,10 @@ See [Honest data](honest-data.md) for how Relay's error directives are used. ## Spelled today -Understood by the compiler and honored by the runtime: +These are understood by the compiler and honored by the runtime: `@argumentDefinitions`, `@arguments`, `@connection`, `@refetchable`, `@alias`, `@appendEdge`, `@prependEdge`, `@appendNode`, `@prependNode`, `@deleteEdge`, `@deleteRecord`, `@include`, `@skip`, `@required`, `@catch`, `@throwOnFieldError`, `@semanticNonNull` (schema), `@defer`, `@stream`, `@stream_connection`, `@inline`. -This section may rot; the rest must not. +This section may rot. The rest must not. diff --git a/docs/principles/response-is-the-oracle.md b/docs/principles/response-is-the-oracle.md index 3f5ce604..742c0cde 100644 --- a/docs/principles/response-is-the-oracle.md +++ b/docs/principles/response-is-the-oracle.md @@ -1,19 +1,21 @@ # The response is the oracle -Whatever path a value takes, reading it through a lens equals reading the raw -response at the same path. Anything faster than the naive reading has to -reproduce it exactly, and a fixture proves that it does. +Whatever path a value takes, reading it through a lens equals reading the +raw response at the same path. + +Anything faster than the naive reading has to reproduce that exactly. A +fixture proves that it does. ## Why A normalized store is a long chain of clever steps: a one-pass tokenizer -driven by a plan, interned storage keys, shared records written by many -operations, optimistic overlays rebased on every commit, a persisted image -read back on the next launch. Each step is a place to lose a value, merge two -records that are not the same thing, or show one operation's data through -another's selection. The clients that broke in production broke here: -identity without stable keys, cache misses after a key-generator change, -stale data served as fresh. +driven by a plan, interned storage keys, and shared records written by many +operations. Optimistic overlays are rebased on every commit, and a persisted +image is read back on the next launch. Each step is a place to lose a value, +to merge two records that are not the same thing, or to show one operation's +data through another's selection. The clients that broke in production broke +here: identity without stable keys, cache misses after a key-generator +change, and stale data served as fresh. The opposite failure is to trust the chain because each step was tested in isolation. The bugs are between steps. @@ -21,17 +23,17 @@ isolation. The bugs are between steps. ## The idea For data this client fetched, the truth is the response as it arrived. A -naive reference reader exists only to be the oracle: it walks the raw JSON by -the same path a lens would and returns the value found there. Every fixture -pairs recorded responses with the lens reads and the store dump they must -produce, and every runtime passes the same fixtures. A new optimization is -admitted when the fixtures still pass and a bench shows the gain. +naive reference reader exists only to be the oracle: it walks the raw JSON +by the same path a lens would and returns the value found there. Every +fixture pairs recorded responses with the lens reads and the store dump they +must produce, and every runtime passes the same fixtures. A new optimization +is admitted when the fixtures still pass and a bench shows the gain. ## Consequences - `spec/` holds schema, documents, recorded responses, expected store contents and expected reads in a language-neutral form. The Swift runtime - passes them; the Kotlin runtime will pass the same files. + passes them, and the Kotlin runtime will pass the same files. - Optimistic data is still data: the oracle for an optimistic read is the optimistic response at that path, until the server's response replaces it. - Persisted data is still data: a value read back from disk equals the value @@ -39,7 +41,7 @@ admitted when the fixtures still pass and a bench shows the gain. - Identity is part of the oracle: two records are the same record when their typename and key fields match, by configuration the compiler checked. - A refactor of the store, the tokenizer or the record layout is - behavior-frozen: the fixtures do not change. + behavior-frozen, so the fixtures do not change. ## Not this @@ -54,27 +56,28 @@ value to give. ## Spelled today -`spec/` holds the schemas, the documents, the recorded responses, the -store dumps and the expected reads, read in place by the Swift tests and -benchmarks through `BatonSpec`; `spec/manifest.json` lists the cases, and -`spec/README.md` is its contract. The expected reads go through the lens -the compiler generated for the document, so they do not share the plan the -store walks by. Beside them, the reference reader `Oracle` in the test -target walks a response's JSON, as `JSONSerialization` reads it, and the -store's records by the same resolved plan. `OracleTests` requires every -case of the manifest to read as its rows say, and the two walks to yield -the same leaves for every response in `spec/` that has data and a document -that reads it: after the commit; for those under the query root, from the -image in a second store; and under an optimistic layer that overrides one -leaf, then after it is reverted. A deferred response is checked as its first part with the later -parts merged in at their paths. `tokenizer/custom-tokens.json` is read -through the lens instead, because the layer's JSON round trip would rewrite -the tokens it keeps as text. Beside each response the oracle reads, -`.store.json` is the store the commit must produce: every record -by key, links written as Relay writes them (`{"__ref": key}`), in sorted -order, one record a line; identity and record layout change only with a -reviewed change to those files (`BATON_BLESS=1 swift test` rewrites them). +`spec/` holds the schemas, the documents, the recorded responses, the store +dumps and the expected reads. The Swift tests and benchmarks read them in +place through `BatonSpec`. `spec/manifest.json` lists the cases, and +`spec/README.md` is its contract. The expected reads go through the lens the +compiler generated for the document, so they do not share the plan the store +walks by. Beside them, the reference reader `Oracle` in the test target +walks a response's JSON, as `JSONSerialization` reads it, and the store's +records by the same resolved plan. `OracleTests` requires every case of the +manifest to read as its rows say, and the two walks must yield the same +leaves for every response in `spec/` that has data and a document that +reads it. That agreement holds after the commit. For responses under the +query root, the same check is made from the image in a second store. The +walks must also agree under an optimistic layer that overrides one leaf, +and again after that layer is reverted. A deferred response is checked as +its first part, with the later parts merged in at their paths. +`tokenizer/custom-tokens.json` is read through the lens instead, because +the layer's JSON round trip would rewrite the tokens it keeps as text. +Beside each response the oracle reads, `.store.json` is the store +the commit must produce, and every record is there by key. Links are +written as Relay writes them (`{"__ref": key}`), in sorted order, one +record a line. Identity and record layout change only with a reviewed +change to those files (`BATON_BLESS=1 swift test` rewrites them). `spec/tokenizer/malformed.json` lists the responses that are not well -formed, each with the outcome it must have. Planned: the same files run by -the Kotlin runtime. -This section may rot; the rest must not. +formed, each with the outcome it must have. Running the same files on the +Kotlin runtime is planned. This section may rot. The rest must not. diff --git a/docs/principles/store-is-the-ui-state.md b/docs/principles/store-is-the-ui-state.md index e9f05e7d..db17e97c 100644 --- a/docs/principles/store-is-the-ui-state.md +++ b/docs/principles/store-is-the-ui-state.md @@ -1,65 +1,67 @@ # The store is the UI's state -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. Reads are -synchronous on the main actor and cost nanoseconds. The main actor reads, -commits, checks what the store holds, collects, and normalizes an -optimistic response; decoding and normalizing a response and the image's -writes run off it. +Records are observable objects, so a view body that read a field is +invalidated when that field of that record changes, and at no other time. + +Reads are synchronous on the main actor and cost nanoseconds. That actor +reads and commits, and it checks what the store holds. Collection runs +there too, as does normalizing an optimistic response. Decoding and +normalizing a response, and the image's writes, run off the main actor. ## Why Relay built a subscription machine because React could not tell it which -component read which field: snapshots, seen-record sets, re-reads on overlap, -structural recycling to keep references stable. Apollo's native clients built -less and got less: watchers per query that re-execute the whole operation when -any field it read changes, with no equality check on iOS, and store reads that -are asynchronous on both platforms, so a warm cache still shows a loading -frame. One production profile found a third of all cache CPU spent -recomputing watcher keys. +component read which field. It kept snapshots and seen-record sets, re-read +on overlap, and recycled structure so references stayed stable. Apollo's +native clients built less and got less: watchers per query re-execute the +whole operation when any field it read changes, with no equality check on +iOS. Store reads are asynchronous on both platforms, so a warm cache still +shows a loading frame. One production profile found a third of all cache +CPU spent recomputing watcher keys. -The opposite failure is to do nothing: hand views plain values and let the -whole screen re-render on every response. Fast to build, impossible to scale -past a list. +The opposite failure is to do nothing: hand views plain values, and let the +whole screen re-render on every response. That is fast to build and +impossible to scale past a list. ## The idea -SwiftUI tracks which observable properties a body reads and re-runs only the -bodies whose dependencies changed. Compose does the same through snapshot -state. A normalized record is exactly the "one small observable object per -item" the platform recommends. So each record carries a registrar; a lens -accessor registers the read of one field of one record through a static key -path; a commit notifies changed fields of records that someone observed. The -runtime has no subscriptions of its own. +SwiftUI tracks which observable properties a body reads, and re-runs only +the bodies whose dependencies changed. Compose does the same through +snapshot state. A normalized record is exactly the "one small observable +object per item" the platform recommends, so each record carries a +registrar. A lens accessor registers the read of one field of one record +through a static key path, and a commit notifies changed fields of records +that someone observed. The runtime has no subscriptions of its own. Reads are synchronous because view bodies are synchronous, and because a cached screen must render in its first frame. The store therefore lives on -the main actor, with the work that has to touch its records: the commit, the -availability check, which reads the image when memory lacks a record, and -collection. One more thing runs there by design: an optimistic response, a -value the call passes, is normalized on the main actor, so its layer shows -in the same turn as the call rather than a frame later. Nothing else does: -a response's bytes are decoded and normalized off it, in the fetch's own -task, and the image is written behind. A commit writes the -slots that changed and notifies the observed fields that changed. Why the -check and collection stay on the main actor, and the numbers that keep +the main actor, with the work that has to touch its records. That work is +the commit, the availability check, which reads the image when memory lacks +a record, and collection. One more thing runs there by design: an optimistic +response, a value the call passes, is normalized on the main actor, so its +layer shows in the same turn as the call rather than a frame later. Nothing +else does. A response's bytes are decoded and normalized off the main actor, +in the fetch's own task, and the image is written behind. A commit writes +the slots that changed and notifies the observed fields that changed. Why +the check and collection stay on the main actor, and the numbers that keep them there, is [a decision of its own](../decisions/the-check-and-collection-run-on-the-main-actor.md). ## Consequences - No asynchronous read API exists for views. A handle resolves against the - store before the first body; data that is present is present immediately. -- The store detects change before it notifies: a write of an equal value is - not a change. -- Commits are atomic. A view never sees half a response, and an optimistic - update that is reverted and re-applied in the same commit notifies only the - net difference. -- `withAnimation { commit }` animates data-driven changes, because a commit is - ordinary main-actor state mutation. + store before the first body, so data that is present is present + immediately. +- The store detects change before it notifies, so a write of an equal value + is not a change. +- Commits are atomic, so a view never sees half a response. An optimistic + update that is reverted and re-applied in the same commit notifies only + the net difference. +- `withAnimation { commit }` animates data-driven changes, because a commit + is ordinary main-actor state mutation. - UIKit gets the same precision through automatic observation tracking. -- The cost is Observation's and nobody else's: a tracked field read costs - what an `@Observable` property costs; an untracked one less. +- The cost is Observation's and nobody else's. A tracked field read costs + what an `@Observable` property costs, and an untracked one costs less. ## Not this @@ -72,14 +74,14 @@ them there, is growing past a frame. See [A fragment is a lens](fragment-is-a-lens.md) for where a read is -registered and [The response is the oracle](response-is-the-oracle.md) for +registered, and [The response is the oracle](response-is-the-oracle.md) for what a commit must preserve. ## Spelled today -`Record` is the observable object, with one invalidation channel per slot: -a key path through one subscript, made on first use and shared by every -record. `Store.commit(_ changes:)` runs on the main actor; `Ingest` decodes -off it, in the fetch's own task; `Environment` holds the store and runs -the ingest for its fetches. This section may -rot; the rest must not. +`Record` is the observable object, with one invalidation channel per slot. +That channel is a key path through one subscript, made on first use and +shared by every record. `Store.commit(_ changes:)` runs on the main actor, +and `Ingest` decodes off it, in the fetch's own task. `Environment` holds +the store and runs the ingest for its fetches. This section may rot. The +rest must not. diff --git a/docs/principles/two-runtimes-one-compiler.md b/docs/principles/two-runtimes-one-compiler.md index 5a0e5833..4497724a 100644 --- a/docs/principles/two-runtimes-one-compiler.md +++ b/docs/principles/two-runtimes-one-compiler.md @@ -1,55 +1,56 @@ # Two runtimes, one compiler -Each runtime is written in its platform's language and feels like it. What -the platforms share is decided before the app runs: the compiler, the plan it -emits, the vocabulary, and the fixtures both runtimes must pass. Nothing is -shared through a bridge. +Each runtime is written in its platform's language and feels like it, and +nothing is shared through a bridge. + +What the platforms share is decided before the app runs: the compiler, the +plan it emits, the vocabulary, and the fixtures both runtimes must pass. ## Why A compiler written in Rust makes a shared Rust runtime look natural: one store, one ingest, one persistence layer, thin bindings. The design forbids -it at its heart. A view body reads a field as a synchronous slot load that -registers with Observation; a Rust record behind a foreign-function call -would hand back a string to copy on every render, or be mirrored by a native -cache that is a second store. The registrar the UI framework tracks is a -native object per record in any case. Whatever walks records (commits, -garbage collection, availability checks, optimistic rebase, connection -merging) lives where the records live. The large native apps that run a -shared C++ core pay for it with materialized native copies of their data, -which is the model this library exists to avoid. +that runtime, at its heart. A view body reads a field as a synchronous slot +load that registers with Observation. A Rust record behind a foreign-function +call would hand back a string to copy on every render, or be mirrored by a +native cache that is a second store. The registrar the UI framework tracks +is a native object per record in any case. Whatever walks records lives +where the records live: commits, garbage collection, availability checks, +optimistic rebase, connection merging. The large native apps that run a +shared C++ core pay for it with materialized native copies of their data. +That is the model this library exists to avoid. -The opposite failure is two runtimes that drift: a response that normalizes -one way on iOS and another on Android, a connection that merges differently, -an error that survives caching on one platform only. +The opposite failure is two runtimes that drift: a response normalizes one +way on iOS and another on Android, a connection merges differently, and an +error survives caching on one platform only. ## The idea Move the shared part to build time. The compiler aggregates fragments, -applies arguments, inserts identity fields, interns storage keys, hashes -persisted ids, and emits a normalization plan per operation and a lens per -fragment. Both runtimes interpret the same plans, so most of the semantics -is computed once, in one language, and the native code is a small -interpreter plus the platform's own networking, observation and storage. +applies arguments and inserts identity fields. It interns storage keys and +hashes persisted ids, then emits a normalization plan per operation and a +lens per fragment. Both runtimes interpret the same plans, so most of the +semantics is computed once, in one language. The native code is a small +interpreter, plus the platform's own networking, observation and storage. -What remains written twice (the tokenizer, the store, retention, connections, -the image) is specified in files no language owns: the runtime contract, -which says what a runtime does with a plan, a response and a store, and the -fixtures, recorded responses with the store contents and lens reads they -must produce. A divergence is a failing test on both sides. The image's -bytes are each runtime's own; what is shared is that a second store over -the image reads the same records. +What remains written twice is the tokenizer, the store, retention, +connections and the image, specified in files no language owns. The runtime +contract says what a runtime does with a plan, a response and a store, and +the fixtures are recorded responses, with the store contents and lens reads +they must produce. A divergence is a failing test on both sides. The +image's bytes are each runtime's own, and what is shared is that a second +store over the image reads the same records. ## Consequences - The Swift runtime depends on Foundation, Observation and the SQLite the - system ships; the Kotlin runtime on the platform's equivalents. No native - library ships inside either. -- A platform developer can read, fix and vendor their runtime without the - other platform's toolchain. + system ships, and the Kotlin runtime depends on the platform's + equivalents. No native library ships inside either. +- You can read, fix and vendor your runtime without the other platform's + toolchain. - Networking uses the platform stack, with its TLS, proxies, background behavior and HTTP/3. -- The compiler is the only Rust a user ever runs, and it runs on the build +- The compiler is the only Rust you ever run, and it runs on the build machine, never on the phone. - Every runtime feature lands with its fixture, and the fixture is shared. @@ -63,21 +64,22 @@ the image reads the same records. in the fixtures. See [The compiler decides](compiler-decides.md) for what the shared build -step owns, [The response is the oracle](response-is-the-oracle.md) for the -fixtures, and the decision record -[Native runtimes, not a shared core](../decisions/native-runtimes.md) for the -measurements and the one condition under which this bends. +step owns. See [The response is the oracle](response-is-the-oracle.md) for +the fixtures. The decision record +[Native runtimes, not a shared core](../decisions/native-runtimes.md) has the +measurements, and the one condition under which this bends. ## Spelled today `swift/` holds the Swift runtime, its macros, its build plugin, its -benchmarks and its tests; `kotlin/` holds the Kotlin runtime, common first +benchmarks and its tests. `kotlin/` holds the Kotlin runtime, common first, with the JVM and Android as its actuals, its Gradle plugin, its benchmarks -and its tests; `compiler/` holds `batonc`, which emits Swift and Kotlin -from one plan; `spec/` holds the schemas, the documents, the responses, -the store dumps and the expected reads both runtimes are held to, listed in -`spec/manifest.json`, and `spec/runtime.md`, the contract: the rules that -produce them, each naming the fixture that holds it or marked unheld. -Compose Multiplatform is the Kotlin runtime on more targets, not a third -runtime ([The Kotlin runtime is common first](../decisions/the-kotlin-runtime-is-common-first.md)). -This section may rot; the rest must not. +and its tests. `compiler/` holds `batonc`, which emits Swift and Kotlin from +one plan. `spec/` holds the schemas, the documents, the responses, the store +dumps and the expected reads both runtimes are held to, listed in +`spec/manifest.json`, and `spec/runtime.md`, the contract. The contract is +the rules that produce them, each naming the fixture that holds it or marked +unheld. Compose Multiplatform is the Kotlin runtime on more targets, not a +third runtime +([The Kotlin runtime is common first](../decisions/the-kotlin-runtime-is-common-first.md)). +This section may rot. The rest must not. diff --git a/docs/principles/what-earns-a-concept.md b/docs/principles/what-earns-a-concept.md index a7d60c3c..604c358f 100644 --- a/docs/principles/what-earns-a-concept.md +++ b/docs/principles/what-earns-a-concept.md @@ -1,39 +1,41 @@ # What earns a concept +A concept sits in the vocabulary only when real screens demand it and no +composition of the existing concepts expresses it. Both clauses are +required. + Baton does not try to solve every problem a data layer meets. It does more with less: few concepts, each with a clear design and a careful -implementation. A concept sits in the vocabulary only when real screens -demand it and no composition of the existing concepts expresses it. Both -clauses. Everything else is a directive the compiler already understands, a -value on a handle, or an example. +implementation. Everything else is a directive the compiler already +understands, a value on a handle, or an example. ## Why -GraphQL clients die of configuration. Each real complaint becomes an option: -a cache policy here, a type policy there, a merge function, a link in a chain, -a flag on codegen. Ten years later the client has four codegen modes, a -dozen cache policies and a documentation site explaining which combinations -work. The complaints were real; the answers multiplied the surface instead of -fixing the mechanism. +GraphQL clients die of configuration, as each real complaint becomes an +option: a cache policy here, a type policy there, a merge function, a link +in a chain, a flag on codegen. Ten years later the client has four codegen +modes, a dozen cache policies and a documentation site explaining which +combinations work. The complaints were real, but the answers multiplied the +surface instead of fixing the mechanism. Breadth also costs depth. Each concept has to be designed, measured, proved -against the oracle and built once per runtime; a client that covers twice the -ground does each part half as well, and the half shows in the first frame -and in the bugs. +against the oracle and built once per runtime. A client that covers twice +the ground does each part half as well, and the half shows in the first +frame and in the bugs. The opposite failure is a client so pure it cannot do the job. Pagination, -optimistic updates and error handling are real; a store that refuses them is -a demo. +optimistic updates and error handling are real, and a store that refuses +them is a demo. ## The idea -The concept inventory is closed and short: schema, document, directive, lens, -record, store, plan, operation value, connection, environment, transport, -phase. A feature must be a composition of these, or a Relay directive the -compiler compiles into a plan. A new concept must pay for itself across many -features, and it enters through this test: +The concept inventory is closed and short: schema, document, directive, +lens, record, store, plan, operation value, connection, environment, +transport, phase. A feature must be a composition of these, or a Relay +directive the compiler compiles into a plan. A new concept must pay for +itself across many features, and it enters through this test: -- Real screens demand it. Screens people build, not screens a completeness +- Real screens demand it: screens people build, not screens a completeness argument names. - No composition of the existing concepts expresses it. @@ -42,15 +44,15 @@ gets a type in code, and a principle file if it constrains the others. ## Consequences -- Subtraction is a contribution. A concept that stops paying for itself is +- Subtraction is a contribution: a concept that stops paying for itself is removed before 1.0. -- Leaving a problem unsolved is a fair answer: the app, an example or the +- Leaving a problem unsolved is a fair answer. The app, an example or the watch list can own it. A half-built feature in the library is not. - Requests that fail the test become examples: here is how to do that with what exists. - Page-based pagination, imperative store updaters, a binary wire format and offline sync are on a watch list with triggers, not in the plan. -- The terminology is the gate: nothing ships that is not named there. +- The terminology is the gate, and nothing ships that is not named there. ## Not this @@ -63,9 +65,9 @@ See [Relay's words](relays-words.md) for where the names come from. ## Spelled today -The inventory is closed as listed above, twelve concepts. +The inventory is closed as the twelve concepts listed above. [The terminology](../terminology.md) marks each of its entries with its place: the concept it is or is a form of, or the concepts it is a composition of. The watch list, each item with the trigger that would promote it, lives in the project's planning notes, which are not published. -This section may rot; the rest must not. +This section may rot. The rest must not. diff --git a/docs/recipes/accounts.md b/docs/recipes/accounts.md index 6b86962d..6342b2d6 100644 --- a/docs/recipes/accounts.md +++ b/docs/recipes/accounts.md @@ -1,36 +1,37 @@ # Accounts: a sign-out, a switch, and what the disk keeps -An app with accounts holds data that belongs to one of them, in memory and -in the store's image on disk. After a sign-out none of it may stay -readable: not in memory, not on disk, not through a response that lands -late. Baton has no session type and no switch on the store for this. The -environment is the session: a sign-in makes one, a sign-out ends it, and -which one is current is the app's state +An app with accounts holds data for one of them, in memory and in the +store's image on disk. After a sign-out, none of it may stay readable: +not in memory, not on disk, and not through a response that lands late. + +Baton has no session type, and no switch on the store for this. The +environment is the session: a sign-in makes one, and a sign-out ends it. +Which one is current is the app's state ([the decision](../decisions/the-environment-is-the-session.md)). The -image belongs to that one environment, and what keeps one account's rows -from the next is the image's identity, the account in its path or in its +image belongs to that one environment. What keeps one account's rows from +the next is the image's identity, the account in its path or in its `version`, not a purge ([the decision](../decisions/an-image-belongs-to-one-store.md)). -Nothing on this page is a second API: `Environment.end()`, the +Nothing on this page is a second API. You use `Environment.end()`, the persistence's path and `version`, `removeAll()`, and `baton.json`'s `transient` block. The sign-out below is quoted from the two GitHub -samples, which compile in this repository; the rest is what the lifetime +samples, which compile in this repository. The rest is what the lifetime and persistence tests prove, named where they prove it. ## Where the account lives An image is one SQLite file, made for one store with a path and a `version`. An image written under another `version` has its rows deleted -in the transaction that opens it, before anything is read. The account -goes in one or the other: +in the transaction that opens it, before anything is read. Put the account +in one or the other: -- **In the path**, one file per account. Caches coexist, and a switch back - to an account finds its cache warm. Pick this when users switch accounts - and expect each to open as they left it. +- **In the path**, one file per account. Caches coexist, and a switch + back to an account finds its cache warm. Pick this when users switch + accounts and expect each to open as they left it. - **In the `version`**, one file for the app. Opened for another account, the file is emptied before any read. Pick this when one account at a - time is enough and one file is simpler to protect, inspect or delete. + time is enough, and one file is simpler to protect, inspect, or delete. ```swift // One file per account. @@ -48,20 +49,20 @@ Persistence.named("Main", version = "${Types.schemaDigest}:$account") On Android the runtime holds no `Context`, so a named image takes its directory from the app, `Persistence.named(name, directory = -cacheDir.path, version = …)`; `Persistence(url:)` in Swift and +cacheDir.path, version = …)`. `Persistence(url:)` in Swift and `Persistence(path)` in Kotlin place the file where the app says. `account` is the identity the app learned at sign-in, a user id from the -sign-in response, not the credential: a refreshed token is the same -identity and the same environment, and the built-in transports read -credentials per attempt for that reason. The two GitHub samples take one -personal access token and no account, so their images carry the schema's -digest alone, which the generated `Types.schemaDigest` already folds the -`transient` lists into. +sign-in response, not the credential. A refreshed token is the same +identity and the same environment, which is why the built-in transports +read credentials per attempt. The two GitHub samples take one personal +access token and no account, so their images carry the schema's digest +alone. The generated `Types.schemaDigest` already folds the `transient` +lists into that digest. ## The order of a sign-out -The Swift GitHub sample, +The Swift GitHub sample is [`examples/GitHubTriage/GitHubTriageApp.swift`](../../examples/GitHubTriage/GitHubTriageApp.swift): ```swift @@ -85,9 +86,9 @@ private func signOut() async { } ``` -The Kotlin one, -[`kotlin/samples/github/src/jvmMain/kotlin/baton/github/Main.kt`](../../kotlin/samples/github/src/jvmMain/kotlin/baton/github/Main.kt), -where the token is a state of the composition and `onSignedOut` clears it: +The Kotlin one is +[`kotlin/samples/github/src/jvmMain/kotlin/baton/github/Main.kt`](../../kotlin/samples/github/src/jvmMain/kotlin/baton/github/Main.kt). +There the token is a state of the composition, and `onSignedOut` clears it: ```kotlin val environment = remember(token) { @@ -105,31 +106,31 @@ onSignOut = { } ``` -Three steps, in this order, and a crash after any of them leaves the -account signed in and nothing readable by another: +Three steps, in this order. A crash after any of them leaves the account +signed in, and leaves nothing another account can read: 1. **End the environment.** `end()` cancels every fetch and stream the - environment started, drops the roots, clears every record, forgets the - session's keys and closes the image, giving its file back. In Kotlin it - is a `suspend` function. A crash here leaves the file intact under the - account's own identity: the next launch, still signed in, reads its own - cache. -2. **Remove the image.** `removeAll()` deletes the file, the interned names - and argument values with it, and drops the work queued before it. The - log goes before the database, so an interrupted removal leaves a + environment started. It drops the roots, clears every record, forgets + the session's keys, and closes the image, giving its file back. In + Kotlin it is a `suspend` function. A crash here leaves the file intact + under the account's own identity, and the next launch, still signed + in, reads its own cache. +2. **Remove the image.** `removeAll()` deletes the file, the interned + names and argument values with it, and drops the work queued before it. + The log goes before the database, so an interrupted removal leaves a consistent older image rather than a log to replay into the next file. A marker beside the file has the next open finish a deletion a crash interrupted, or one a locked device put off ([the decision](../decisions/the-images-file-is-protected-and-waited-for.md)). - The removal is hygiene, which can be interrupted and repeated; the + The removal is hygiene: it can be interrupted and repeated. The identity is what keeps another account out. 3. **Forget the credential last.** A crash before this step leaves the account signed in, with or without its cache, which is the state the next launch can finish from. -Signing out and signing in again is a new environment, as the Swift -sample's `makeEnvironment()` and the Kotlin sample's `remember(token)` -make one; a store is never cleared or replaced in place. +Signing out and signing in again is a new environment. The Swift sample's +`makeEnvironment()` makes one, and so does the Kotlin sample's +`remember(token)`. A store is never cleared or replaced in place. ## What is still held after the end @@ -143,41 +144,42 @@ make one; a store is never cleared or replaced in place. | A subscription | Its stream ends on `gone`. | | Any later call on the environment | Fails on `gone`: a fetch, a mutation, a payload committed by hand, a handle made after the end. | -An ended store commits nothing, checked at the one door a payload takes, -which is what covers a response cancellation cannot reach. The rule is -the contract's "A session ends once" +An ended store commits nothing. The check sits at the one door a payload +takes, which is what covers a response cancellation cannot reach. The +rule is the contract's "A session ends once" ([`spec/runtime.md`](../../spec/runtime.md#7-the-lifetime-roots-retention-ages-collection)), held by the script `end`, which both runtimes run. ## A switch without a sign-out To switch accounts and keep the first one's cache, end the environment -and keep the file: with the account in the path, the next environment -opens the other account's file and the first stays warm; with the account -in the `version`, the next environment empties the one file, and the -first account refetches when it returns. Either way the first account's -rows are never read by the second: an image opened under another -`version` is emptied before any read, and another path is another file. +and keep the file. With the account in the path, the next environment +opens the other account's file, and the first stays warm. With the +account in the `version`, the next environment empties the one file, and +the first account refetches when it returns. Either way, the second +account never reads the first account's rows. An image opened under +another `version` is emptied before any read, and another path is +another file. ## The walkthrough: A signs out, B signs in, relaunch offline -The case to check before shipping, step by step, with the account in the +Check this case before you ship. Step by step, with the account in the `version` and the sign-out above: 1. **A opens a private screen.** The response is committed to memory and written behind the commit to the image, under A's identity. -2. **A signs out.** `end()`, `removeAll()`, forget the credential. If A's - response was still in flight, it lands after the end and reaches - neither memory nor the image. If the removal was interrupted, the next - open finishes it. -3. **B signs in.** A new environment with its own image on the same path. - The file is gone, or, had the removal been skipped, is emptied by B's - open before any read. B opens the same screen: the check misses, and - the screen fetches from the network. +2. **A signs out.** `end()`, then `removeAll()`, then forget the + credential. If A's response was still in flight, it lands after the + end and reaches neither memory nor the image. If the removal was + interrupted, the next open finishes it. +3. **B signs in.** That sign-in is a new environment, with its own image + on the same path. The file is gone. Had the removal been skipped, B's open + empties it before any read. B opens the same screen, the check misses, + and the screen fetches from the network. 4. **Relaunch offline as B.** B's image holds B's rows and B's fetch time, so the screen renders from disk and is not stale. 5. **A signs in again.** A's environment finds nothing of A's earlier - session, because the file was removed; with the account in the path + session, because the file was removed. With the account in the path, and the switch above instead of the sign-out, A's cache is still warm. The Swift persistence tests prove each step: "each step of a sign-out @@ -197,21 +199,22 @@ operation again in the new one", "a handle a model holds reads gone after the end and tells its observer, and a subscription it holds ends on gone", and "every call after the end fails on gone". The Kotlin runtime runs the `end` script and proves `removeAll` deletes an image and the work queued -before it; the two-account walkthrough is not yet a Kotlin test. +before it. The two-account walkthrough is not yet a Kotlin test. ## Keeping data off the disk -A purge is weaker than never writing. Three controls, each decided when -the image is made or at build time, none a switch at run time: +A purge is weaker than never writing. There are three controls, each +decided when the image is made or at build time, and none is a switch +at run time: - **`transient` types and root fields.** `baton.json`'s `transient` block names types whose records never reach the image, an interface standing - for its implementers, and root fields such as `Query.search` whose cells, - storage keys and fetch stamps are never written, since a root field's key - carries its arguments and an operation's stamp carries its variables. - Memory is unaffected; the next launch misses on what was left out and - fetches it. The lists join the image's `version`, so a list changed is - an image started again + for its implementers, and root fields such as `Query.search`. Those + fields' cells, storage keys, and fetch stamps are never written, since + a root field's key carries its arguments and an operation's stamp + carries its variables. Memory is unaffected, and the next launch misses + on what was left out and fetches it. The lists join the image's + `version`, so a changed list is an image started again ([the decision](../decisions/what-may-reach-the-image.md); the fixtures `spec/tests/secrets.json` and `character-secret.json`). @@ -223,9 +226,9 @@ the image is made or at build time, none a switch at run time: ``` - **The platform's protection class.** `Persistence(name:version:protection:)` - takes a `FileProtectionType`; Apple's SQLite makes the file and its + takes a `FileProtectionType`, and Apple's SQLite makes the file and its write-ahead log under it. Under `.complete` the file cannot be opened - while the device is locked: the writer keeps its work for the next try + while the device is locked. The writer keeps its work for the next try, and a read meanwhile misses, rather than the image being discarded. An image made under another class starts again. Kotlin has no protection class. @@ -233,5 +236,5 @@ the image is made or at build time, none a switch at run time: and the end clears it. To wipe everything an account has, for a security setting or a remote -sign-out, do what the sign-out does: end the environment, remove the image, -and make a new environment for whoever is signed in next. +sign-out, do what the sign-out does: end the environment, remove the +image, and make a new environment for whoever is signed in next. diff --git a/docs/recipes/agents.md b/docs/recipes/agents.md index 37c6980c..9d261225 100644 --- a/docs/recipes/agents.md +++ b/docs/recipes/agents.md @@ -4,54 +4,54 @@ This page is for a coding agent asked to integrate Baton into an app, or to write a screen in an app that has it. It says what to read, the shape a screen takes, what never to write, what to run, and what to check before handing the change back. Every word it uses is defined in -[`terminology.md`](../terminology.md); where this page and that file +[`terminology.md`](../terminology.md). Where this page and that file differ, that file is right. -A SwiftPM checkout of Baton carries this `docs/` tree, so an agent reads it +A SwiftPM checkout of Baton carries this `docs/` tree, so you read it locally, at the version the app resolved. The Maven artifact carries no -docs; read them on GitHub at the tag the app depends on. +docs, so read them on GitHub at the tag the app depends on. ## Read first 1. The [README](../../README.md): what Baton is and is not, and the SwiftUI and Compose spellings side by side. -2. [`terminology.md`](../terminology.md): the closed vocabulary. Do not +2. [`terminology.md`](../terminology.md), the closed vocabulary. Do not name a concept it does not name. 3. The platform's build: - - SwiftPM: add the package, `https://github.com/shergin/baton`, depend - on `.product(name: "Baton", package: "baton")`, and put + - SwiftPM: add the package, `https://github.com/shergin/baton`. Depend + on `.product(name: "Baton", package: "baton")`. Put `plugins: [.plugin(name: "BatonPlugin", package: "baton")]` on each - target that holds documents. The plugin - reads `baton.json` from the target's directory, then the package root - ([`batonc.md`](batonc.md#under-the-swiftpm-plugin)); the samples' + target that holds documents. The plugin reads `baton.json` from the + target's directory, then the package root + ([`batonc.md`](batonc.md#under-the-swiftpm-plugin)). The samples' `examples/RickAndMorty/baton.json` is the smallest one. - Gradle: [`gradle.md`](gradle.md), the runtime from Maven Central and the plugin `com.shergin.baton`. - Bazel: [`bazel.md`](bazel.md). - Anything else: [`batonc.md`](batonc.md), the command's contract. -4. [`testing.md`](testing.md): a store without a server, for previews and +4. [`testing.md`](testing.md), a store without a server, for previews and tests. -Then read one sample screen whole before writing one: Swift under -`examples/GitHubTriage/`, Kotlin under `kotlin/samples/github/`. They cover -a query, fragments, a connection, a mutation with an optimistic response, -an edge directive, `@required` and `@catch`. Coming from Relay, +Then read one sample screen whole before writing one. Swift is under +`examples/GitHubTriage/`, and Kotlin is under `kotlin/samples/github/`. +They cover a query, fragments, a connection, a mutation with an optimistic +response, an edge directive, `@required`, and `@catch`. Coming from Relay, [`porting-from-relay.md`](porting-from-relay.md) puts the words side by side. ## The shape of a screen - **One query per screen.** The screen declares one `@Query` whose - document spreads its children's fragments; the compiler assembles the - operation. A part that may arrive later is a + document spreads its children's fragments, and the compiler assembles + the operation. A part that may arrive later is a [deferred fragment](../terminology.md#documents), not a second query. The parent, or a navigation path, constructs the query's [operation value](../terminology.md#generated) from its variables, as `IssueQuery(id: issue.id)`. - **A fragment per view, beside its body.** Each view that reads server - data declares a `@Fragment` for exactly the fields it reads and takes - the fragment's [lens](../terminology.md#generated). Fragment names follow - Relay's convention, `_`, as `IssueRow_issue`. + data declares a `@Fragment` for exactly the fields it reads, and takes + the fragment's [lens](../terminology.md#generated). Fragment names + follow Relay's convention, `_`, as `IssueRow_issue`. - **The parent passes the child its lens through the spread's accessor.** `...IssueRow_issue` in the parent compiles to an accessor, `issueRow`, and the parent writes `IssueRow(issue: issue.issueRow)`. `@alias(as:)` @@ -63,23 +63,23 @@ side. and `hasNext`, and calls `loadNext` when the end shows ([Pagination](../terminology.md#lists)). The store merges the pages. - **A write is an action.** A `@Mutation` compiles to an - [action](../terminology.md#generated), called with one argument per - variable and an optional typed `OptimisticResponse`. When the write adds - to or removes from a list, the payload field carries an - [edge directive](../terminology.md#lists), `@appendEdge`, - `@prependEdge`, `@deleteEdge` or `@deleteRecord`, naming the connection - through a `connections` variable the view fills from the connection's - `connectionID`. The optimistic response is applied at once and reverted - if the server fails. + [action](../terminology.md#generated). Call it with one argument per + variable, and an optional typed `OptimisticResponse`. When the write + adds to or removes from a list, the payload field carries an + [edge directive](../terminology.md#lists): `@appendEdge`, + `@prependEdge`, `@deleteEdge`, or `@deleteRecord`. The directive names + the connection through a `connections` variable, which the view fills + from the connection's `connectionID`. The optimistic response is applied + at once, and reverted if the server fails. - **The fields a view cannot do without** carry `@required(action: NONE | - LOG | THROW)`; the fields whose errors the view shows carry `@catch` + LOG | THROW)`. The fields whose errors the view shows carry `@catch` ([Required, Catch](../terminology.md#documents)). Otherwise nullability is what the schema says. - **The phase decides what the screen shows.** A query's handle has a - [phase](../terminology.md#generated), loading, ready or failed, and a - `retry`; render each case. -- **The environment is injected once,** at the root of the app: in Swift - `.environment(\.baton, environment)`, in Kotlin + [phase](../terminology.md#generated): loading, ready, or failed, and a + `retry`. Render each case. +- **The environment is injected once,** at the root of the app. Swift + uses `.environment(\.baton, environment)`, and Kotlin uses `CompositionLocalProvider(LocalBaton provides environment)`. Where Swift and Kotlin differ: @@ -96,77 +96,79 @@ Where Swift and Kotlin differ: The two screens in [`examples/GitHubTriage/IssueScreen.swift`](../../examples/GitHubTriage/IssueScreen.swift) and [`kotlin/samples/github/src/jvmMain/kotlin/baton/github/IssueScreen.kt`](../../kotlin/samples/github/src/jvmMain/kotlin/baton/github/IssueScreen.kt) -are the same screen, a lookup, a connection and a mutation with +are the same screen: a lookup, a connection, and a mutation with `@appendEdge`, written once per platform. ## What never to write - **A model layer, or a view model that copies fields.** The lens is the - model; the store is the only copy. A struct or data class that mirrors a - fragment's fields goes stale the moment the store changes and defeats the - per-field re-render. A Kotlin screen with no composition at all holds the - handle, as [`views.md`](views.md) shows; it does not hold a copy. + model, and the store is the only copy. A struct or data class that + mirrors a fragment's fields goes stale the moment the store changes, + and it defeats the per-field re-render. A Kotlin screen with no + composition at all holds the handle, as [`views.md`](views.md) shows, + and does not hold a copy. - **A query file for a screen.** The screen's operation is assembled from - the fragments spread into it. Write a fragment beside each view and a - query that spreads them; never a hand-merged query with every field. - A `.graphql` file is for a host that is not a view, such as a UIKit - controller ([`uikit.md`](uikit.md)). + the fragments spread into it. Write a fragment beside each view, and a + query that spreads them. Never write a hand-merged query with every + field. A `.graphql` file is for a host that is not a view, such as a + UIKit controller ([`uikit.md`](uikit.md)). - **A cache updater.** There is no function that edits the store after a - mutation. What a mutation changes is what its payload selects; what it - adds to or removes from a list is an edge directive; what it shows + mutation. What a mutation changes is what its payload selects, what it + adds to or removes from a list is an edge directive, and what it shows before the server answers is its optimistic response. - **A runtime policy object.** Identity is schema configuration in - `baton.json`; behaviour is a directive in the document or a value on a - handle. Nothing is configured by an object at run time. + `baton.json`, and behaviour is a directive in the document or a value + on a handle. Nothing is configured by an object at run time. - **A GraphQL string built at run time,** or a parse of one. Every operation is known at build time. -- **A field a view did not declare.** A view reads its own fragment; it - does not read through a parent's lens into a child's fields, or add a - field to a parent because a child needs it. +- **A field a view did not declare.** A view reads its own fragment: it + does not read through a parent's lens into a child's fields, and it + does not add a field to a parent because a child needs it. ## Diagnostics and what to run -The compiler prints each problem as `path:line:column: severity: message`, -where the position is inside the GraphQL text in the host file, the Swift -or Kotlin source that carries the marker, so the IDE shows it at the line. -Read the position, open the document there, and fix the GraphQL; the -message names the field, type or argument it could not resolve against the -schema. A warning does not fail the build. +The compiler prints each problem as +`path:line:column: severity: message`. The position is inside the GraphQL +text in the host file, the Swift or Kotlin source that carries the +marker, so the IDE shows it at the line. Read the position, open the +document there, and fix the GraphQL. The message names the field, type, +or argument it could not resolve against the schema. A warning does not +fail the build. Run, in this order: 1. `batonc validate --config baton.json `, for the quickest answer on every document of a target ([`batonc.md`](batonc.md#validate)). -2. The build: the SwiftPM plugin, the Gradle task or the Bazel rule runs +2. The build. The SwiftPM plugin, the Gradle task, or the Bazel rule runs the same compilation, so a diagnostic there is the same diagnostic. -3. The app's tests over `RecordedTransport`, or `ScriptedTransport` to - hold a mutation and observe its optimistic response +3. The app's tests over `RecordedTransport`, or `ScriptedTransport` when + you need to hold a mutation and observe its optimistic response ([Recorded transport](../terminology.md#runtime), - [`testing.md`](testing.md)). In Swift they are the product - `BatonTesting`; in Kotlin the module `baton-testing`. Answer them with + [`testing.md`](testing.md)). In Swift the product is `BatonTesting`. + In Kotlin the module is `baton-testing`. Answer the tests with responses recorded from the app's server, not with hand-written data. ## Review checklist - [ ] Each screen has one `@Query`, and every view that reads server data reads it through its own `@Fragment` and lens. -- [ ] Each fragment sits beside the view that reads it and selects only the - fields that view reads. -- [ ] Each child receives its lens through the parent's spread accessor; - no view reads a field another view declared. -- [ ] Each list is a `@connection` on a `@refetchable` fragment and pages +- [ ] Each fragment sits beside the view that reads it, and selects only + the fields that view reads. +- [ ] Each child receives its lens through the parent's spread accessor. + No view reads a field another view declared. +- [ ] Each list is a `@connection` on a `@refetchable` fragment, and pages with `loadNext`. -- [ ] Each write is an action; a write that the user should see at once has - a typed optimistic response; a write that changes a list names the +- [ ] Each write is an action. A write the user should see at once has a + typed optimistic response. A write that changes a list names the connection with an edge directive. - [ ] `@required` and `@catch` stand on the fields whose absence or error the view handles, and nowhere else. -- [ ] Every phase is rendered: loading, ready and failed with a retry. +- [ ] Every phase is rendered: loading, ready, and failed with a retry. - [ ] No model copies, no screen query written by hand, no cache updater, no policy object, no GraphQL built at run time. - [ ] `batonc validate` and the build are clean, and the tests pass over recorded responses. -- [ ] The screens that matter were measured before and after, and the - numbers are in the report back, with what stood in the way filed as - an [issue](https://github.com/shergin/baton/issues). +- [ ] The screens that matter were measured before and after. The numbers + are in the report back, and what stood in the way is filed as an + [issue](https://github.com/shergin/baton/issues). diff --git a/docs/recipes/batonc.md b/docs/recipes/batonc.md index e0d84dda..3ae030a5 100644 --- a/docs/recipes/batonc.md +++ b/docs/recipes/batonc.md @@ -1,16 +1,16 @@ # `batonc`: the command's contract -`batonc` is the compiler the SwiftPM plugin runs. It is one binary per -platform, in the artifact bundle each release publishes: a universal macOS -binary and a static Linux binary for each of `x86_64` and `aarch64`, each +`batonc` is the compiler the SwiftPM plugin runs. Each release publishes +one binary per platform, in the artifact bundle: a universal macOS binary, +and a static Linux binary for each of `x86_64` and `aarch64`. Each is listed in the bundle's `info.json` under the triples a host reports, so -SwiftPM on either platform and a Bazel toolchain select theirs from the one -file. `scripts/build-compiler.sh` builds the host's from `compiler/` in a -checkout. Its command line is the contract a build outside SwiftPM uses: -Bazel, a committed-output step, an editor's check. What -follows is that contract. Its output is deterministic: the same schema, -configuration and sources produce the same bytes, which the goldens under -`compiler/src/tests` fix. +SwiftPM on either platform, and a Bazel toolchain, select theirs from the +one file. `scripts/build-compiler.sh` builds the host's binary from +`compiler/` in a checkout. Its command line is the contract a build +outside SwiftPM uses: Bazel, a committed-output step, an editor's check. +What follows is that contract. The output is deterministic: the same +schema, configuration, and sources produce the same bytes, and the +goldens under `compiler/src/tests` fix those bytes. ## Commands @@ -20,101 +20,106 @@ batonc validate --config [--language swift|kotlin] batonc print --config [--language swift|kotlin] ``` -`--schema ` may replace `--config` when there is no configuration, and -beside it overrides the configuration's `schema`, for a schema that is a -build's own output; the configuration's `schema` is otherwise the schema, -relative to the configuration's directory, as are its `schemaExtensions` -and, unless `--persisted` names it, the persisted documents file. +`--schema ` may replace `--config` when there is no configuration. +Beside a configuration, it overrides that configuration's `schema`, for a +schema that is a build's own output. Otherwise the configuration's +`schema` is the schema, relative to the configuration's directory, as are +its `schemaExtensions` and, unless `--persisted` names it, the persisted +documents file. `` are the target's host sources, Swift or Kotlin, and its `.graphql` or `.gql` documents. The compiler reads the GraphQL out of the markers (`@Fragment`, `@Query`, `@Mutation`, `@Subscription`, bare or qualified as `@Baton.Query` in Swift and `@baton.Query` in Kotlin) and -compiles every document of the target together, since a fragment spread in -one file is declared in another. +compiles every document of the target together, since a fragment spread +in one file is declared in another. ### The language -A run writes one language. `--language` names it, `swift` or `kotlin`; without -it, the first host file's extension does, `.swift` or `.kt`, and a run of -`.graphql` files alone writes Swift. A host file of the other language is an -error at that file: run `batonc` once per language. The Kotlin target writes -the operation values with their plans and the shared file; a fragment's lens, -the fields of an operation's `Data` and a mutation's optimistic builder come -with the Kotlin runtime's readers. +A run writes one language, which `--language` names as `swift` or +`kotlin`. Without that flag, the first host file's extension does: +`.swift` or `.kt`. A run of `.graphql` files alone writes Swift, and a +host file of the other language is an error at that file. Run `batonc` +once per language. +The Kotlin target writes the operation values with their plans, and the +shared file. A fragment's lens, the fields of an operation's `Data`, and +a mutation's optimistic builder come with the Kotlin runtime's readers. A Kotlin marker takes one string literal, labelled `document =` or not, in any of Kotlin's forms: `"…"` with its escapes, a raw `"""…"""`, or either -after a run of dollars, `$$"""…"""`, in which a run of fewer dollars than the -prefix is text. A GraphQL variable is a template in a string without enough -dollars, so a document with a variable is written in a `$$` string, and a -template is an error at the marker. A raw string is read dedented: its -common indentation removed and a blank first and last line dropped, so it -equals the `.graphql` file an author would write. A bare `@Query` in a file -that imports another library's `Query`, Room's or Retrofit's, is that -library's. +after a run of dollars, `$$"""…"""`. In that last form, a run of fewer +dollars than the prefix is text. A GraphQL variable is a template in a +string without enough dollars, so write a document with a variable in a +`$$` string. A template is an error at the marker. A raw string is read +dedented: its common indentation is removed, and a blank first and last +line are dropped, so it equals the `.graphql` file an author would write. +A bare `@Query` in a file that imports another library's `Query`, Room's +or Retrofit's, is that library's. The Kotlin target reads two entries of `baton.json`: - `"kotlin": {"package": ""}`: the package of the shared file and - of the code written for a `.graphql` source. A `.kt` host's code takes the - host's own `package`. The entry is required when the run writes the shared - file or compiles a `.graphql` source. -- A mapped scalar's `kotlin` entry under `customScalarTypes`, the Kotlin type - it reads as and the `object` implementing the runtime's `ScalarConverter` - for it: `"Decimal": {"swift": "Foundation.Decimal", "kotlin": {"type": - "java.math.BigDecimal", "converter": "app.Decimals"}}`. A mapped scalar - without one is an error at the configuration in a Kotlin run, as one - without a Swift type is in a Swift run. + of the code written for a `.graphql` source. A `.kt` host's code takes + the host's own `package`. The entry is required when the run writes the + shared file or compiles a `.graphql` source. +- A mapped scalar's `kotlin` entry under `customScalarTypes`. That entry + is the Kotlin type the scalar reads as, and the `object` implementing + the runtime's `ScalarConverter` for it: `"Decimal": {"swift": + "Foundation.Decimal", "kotlin": {"type": "java.math.BigDecimal", + "converter": "app.Decimals"}}`. A mapped scalar without one is an error + at the configuration in a Kotlin run, as one without a Swift type is in + a Swift run. ### `generate` -Writes, and writes nothing when a document has an error: +`generate` writes, and it writes nothing when a document has an error: - **One output per source that declares GraphQL**, named under `--out` - by the source's path relative to the working directory with each separator - an underscore and `.baton.swift` in place of `.swift` - (`Sources/App/Screen.swift` writes `Sources_App_Screen.baton.swift`), or - `.baton.kt` in place of `.kt` in a Kotlin run, or exactly where - `--emit =` says. A source an `--emit` names that holds no - document gets a header only, whether or not it names a marker, so a build - system declares one output per source without reading the source. Under `--out`, an output of the run's language in - the directory that this run did not write is removed, so a renamed source - leaves nothing behind. -- **The shared file**, `Baton.baton.swift`, or `Baton.baton.kt` in a Kotlin - run, under `--out` or `--shared `: the types every output names, the - schema's digest, the format marker. -- **The persisted documents file**, under `persistConfig`: where + by the source's path relative to the working directory. Each separator + becomes an underscore, and `.baton.swift` stands in place of `.swift` + (`Sources/App/Screen.swift` writes `Sources_App_Screen.baton.swift`). + In a Kotlin run, `.baton.kt` stands in place of `.kt`, or the file is + written exactly where `--emit =` says. A source an + `--emit` names that holds no document gets a header only, whether or + not it names a marker, so a build system declares one output per source + without reading the source. Under `--out`, an output of the run's + language in the directory that this run did not write is removed, so a + renamed source leaves nothing behind. +- **The shared file**, `Baton.baton.swift`, or `Baton.baton.kt` in a + Kotlin run, under `--out` or `--shared `, holds the types every + output names, the schema's digest, and the format marker. +- **The persisted documents file**, under `persistConfig`, goes where `--persisted ` says, since a build system declares its outputs - before it reads the configuration; else under `--out` when given; else - beside the configuration. `--persisted` without `persistConfig` is an - error. + before it reads the configuration. Otherwise it goes under `--out` when + given, and otherwise beside the configuration. `--persisted` without + `persistConfig` is an error. - **The report**, `--report `: what the target compiled, as JSON, - each lens with its accessors' names, keys and shapes. See + each lens with its accessors' names, keys, and shapes. See [Report](../terminology.md#compiler). -`--check` writes nothing: every output is computed and compared with the -file at its path, each stale or missing one is named on stderr, and the -exit code is 1 when any is. A team that commits its generated code runs +`--check` writes nothing. Every output is computed and compared with the +file at its path, and each stale or missing one is named on stderr, with +exit code 1 when any is. A team that commits its generated code runs `generate` as a developer step and `generate --check` in CI. ### `validate` -The same compilation with no output: diagnostics and an exit code, for an -editor or a pre-commit hook. +`validate` runs the same compilation and writes no output. You get +diagnostics and an exit code, for an editor or a pre-commit hook. ### `print` -The same compilation, printing one operation's text, the exact text the app -sends, compact on one line as the compiler prints it, preceded by -`# documentId: ` under `persistConfig`, for pasting into a server's -tool, which formats it. +`print` runs the same compilation, printing one operation's text. That +text is the exact text the app sends, compact on one line as the compiler +prints it, and preceded by `# documentId: ` under `persistConfig`. +Paste it into a server's tool, which formats it. ## Diagnostics and exit codes -Diagnostics are printed to stderr as `path:line:column: severity: message`, -with the position inside the GraphQL text in the host file, so Xcode, -Gradle and Bazel show them at the line. A warning does not fail the command. +Diagnostics are printed to stderr as +`path:line:column: severity: message`, with the position inside the +GraphQL text in the host file. Xcode, Gradle, and Bazel show them at the +line. A warning does not fail the command. | Exit | Meaning | |---|---| @@ -124,12 +129,12 @@ Gradle and Bazel show them at the line. A warning does not fail the command. ## Under the SwiftPM plugin -The plugin runs one `generate` per target with `--out` in its own output +The plugin runs one `generate` per target, with `--out` in its own output directory, `--shared`, `--report`, and an `--emit` for each Swift source -that names a marker; the generated Swift joins the target's sources, and the -report and the persisted documents file sit beside them, undeclared, so -SwiftPM does not bundle them as resources. `baton.json` is read from the -target's directory, then the package root. +that names a marker. The generated Swift joins the target's sources, +while the report and the persisted documents file sit beside them, +undeclared, so SwiftPM does not bundle them as resources. `baton.json` +is read from the target's directory, then the package root. ## Outside SwiftPM: committed output @@ -137,8 +142,8 @@ target's directory, then the package root. batonc generate --config Sources/App/baton.json --out Sources/App/Generated Sources/App/*.swift ``` -Commit `Sources/App/Generated`, add it to the target's sources, and gate with -`--check`: +Commit `Sources/App/Generated`, add it to the target's sources, and gate +it with `--check`: ```bash batonc generate --config Sources/App/baton.json --out Sources/App/Generated --check Sources/App/*.swift @@ -147,7 +152,7 @@ batonc generate --config Sources/App/baton.json --out Sources/App/Generated --ch ## Outside SwiftPM: Bazel `rules_baton`, under `bazel/` in this repository, is this command as a -Bazel rule and the release's bundle as a toolchain: every input declared, -one output per source, the report and the persisted documents file as -output groups, and a test over `--check` for committed output. -[Bazel](bazel.md) is its page. +Bazel rule, and the release's bundle as a toolchain. Every input is +declared, there is one output per source, and the report and the +persisted documents file are output groups. A test runs over `--check` +for committed output. [Bazel](bazel.md) is its page. diff --git a/docs/recipes/bazel.md b/docs/recipes/bazel.md index f2d935bb..b5b9d650 100644 --- a/docs/recipes/bazel.md +++ b/docs/recipes/bazel.md @@ -2,13 +2,14 @@ `rules_baton` is [the command's contract](batonc.md) spelled in Starlark: a toolchain the module fetches from the release's artifact bundle, and -`baton_generate`, which runs `batonc generate` as one sandboxed action with -every input and output declared and yields the generated sources for the -library rule the adopter already has. It lives under `bazel/` in this -repository and is versioned with Baton, so `rules_baton` 0.11.0 fetches -the bundle 0.11.0 published and writes the format that runtime reads. It -holds no logic of its own: which source writes which output, the header -for a source without GraphQL, staleness and diagnostics are the compiler's +`baton_generate`, which runs `batonc generate` as one sandboxed action. +Every input and output is declared, and the action yields the generated +sources for the library rule you already have. The module lives under +`bazel/` in this repository and is versioned with Baton, so `rules_baton` +0.11.0 fetches the bundle 0.11.0 published and writes the format the +runtime reads. It holds no logic of its own: which source writes which +output, the header for a source without GraphQL, staleness, and +diagnostics are the compiler's ([the decision](../decisions/a-build-integration-holds-no-logic.md)). ## The runtime @@ -16,10 +17,10 @@ for a source without GraphQL, staleness and diagnostics are the compiler's The runtime arrives as any Swift package does under `rules_swift_package_manager`, resolved from `Package.swift`: `@swiftpkg_baton//:Baton`, with `BatonTesting` and `BatonInspector` beside -it and the macros as a compiler plugin target. The SwiftPM build-tool +it, and the macros as a compiler plugin target. The SwiftPM build-tool plugin and the compiler's binary target are skipped by design, since -`rules_swift_package_manager` runs no plugin; this module is what runs the -compiler instead. On the Kotlin side, `language = "kotlin"` writes +`rules_swift_package_manager` runs no plugin, and this module is what +runs the compiler instead. On the Kotlin side, `language = "kotlin"` writes `.baton.kt` for a `kt_jvm_library` or a `kt_android_library` over the runtime's Maven artifact, `com.shergin.baton:baton` ([the Gradle recipe](gradle.md) names them). @@ -30,8 +31,8 @@ runtime's Maven artifact, `com.shergin.baton:baton` bazel_dep(name = "rules_baton", version = "0.11.0") ``` -Until the module is in the Bazel Central Registry, it is taken from the -release's tag, where it is the `bazel/` directory of the archive: +Until the module is in the Bazel Central Registry, take it from the +release's tag. There it is the `bazel/` directory of the archive: ```python archive_override( @@ -43,12 +44,12 @@ archive_override( The module registers its toolchains itself. Its extension downloads `batonc.artifactbundle.zip` of the release it is versioned with, the one -file SwiftPM downloads, checks it against the checksum the release wrote -into `bazel/baton/release.bzl`, reads the bundle's `info.json` and -registers a toolchain per triple it lists, so the execution platform, -macOS or Linux, x86_64 or aarch64, selects its binary. Nothing is pinned -by URL. A checkout that builds its own compiler names it instead, as -`Package.swift` takes `BATON_COMPILER`: +file SwiftPM downloads. It checks that zip against the checksum the +release wrote into `bazel/baton/release.bzl`, reads the bundle's +`info.json`, and registers a toolchain per triple it lists. The execution +platform selects its binary: macOS or Linux, x86_64 or aarch64. Nothing +is pinned by URL. A checkout that builds its own compiler names it +instead, as `Package.swift` takes `BATON_COMPILER`: ```bash bazel build --repo_env=BATON_COMPILER=/path/to/batonc //... @@ -80,25 +81,25 @@ swift_library( ``` - **One action, every input declared:** the sources, the configuration, - the schema, the extensions and the compiler, so remote execution and + the schema, the extensions, and the compiler, so remote execution and caching work. `--schema` is passed explicitly, so the schema may be another rule's output. The configuration's `schemaExtensions` resolve relative to the configuration, as they do under the SwiftPM plugin, - since the sandbox holds every declared input at its path: list the files - in `schema_extensions` and leave the configuration as it is. -- **One output per source,** named as the SwiftPM plugin names it: the - source's path relative to the rule's package, each separator an - underscore, `.baton.swift` in place of `.swift` and after any other - extension; a source from another repository is named relative to that - repository's root. A source without GraphQL writes a header-only file, - so the set of outputs follows from `srcs` and no file is read at - analysis time. They are written under `bazel-bin///`, - beside the shared `Baton.baton.swift`. + since the sandbox holds every declared input at its path. List the + files in `schema_extensions` and leave the configuration as it is. +- **One output per source,** named as the SwiftPM plugin names it. Take + the source's path relative to the rule's package, turn each separator + into an underscore, and put `.baton.swift` in place of `.swift` and + after any other extension. A source from another repository is named + relative to that repository's root. A source without GraphQL writes a + header-only file, so the set of outputs follows from `srcs` and no file + is read at analysis time. The files are written under + `bazel-bin///`, beside the shared `Baton.baton.swift`. - **The default output is the generated sources,** to list in the `srcs` - of the adopter's `swift_library`, `kt_jvm_library` or in-house macro, - with the runtime and the macro plugin in that rule's own attributes. - The report is the output group `report`, and under `persistConfig` the - persisted documents file is the output group `persisted`, named by the + of your `swift_library`, `kt_jvm_library`, or in-house macro, with the + runtime and the macro plugin in that rule's own attributes. The report + is the output group `report`, and under `persistConfig` the persisted + documents file is the output group `persisted`, named by the `persisted` attribute, since a rule declares its outputs before it can read the configuration: @@ -122,9 +123,9 @@ swift_library( ## `baton_check_test` -For a team that commits its generated code: the same inputs as a -`baton_generate` and the committed files, and a test that runs -`batonc generate --check` over them, failing with each stale or missing +For a team that commits its generated code, use the same inputs as a +`baton_generate`, plus the committed files. The test runs +`batonc generate --check` over them and fails with each stale or missing output named on stderr. ```python @@ -140,30 +141,32 @@ baton_check_test( ) ``` -The committed `Generated/` is the `baton_generate` target's output copied -from `bazel-bin`, or `batonc generate --out Generated` run from the -package's directory, which names the outputs by the same rule. +The committed `Generated/` is the `baton_generate` target's output, copied +from `bazel-bin`, or the result of `batonc generate --out Generated`, run +from the package's directory, which names the outputs by the same rule. ## The example -`bazel/examples/rickandmorty` generates the Rick and Morty sample's screens -from `examples/RickAndMorty` and `spec/rickandmorty/schema.graphql`, read -in place through `new_local_repository`, against the module at `../..`. -`scripts/check-bazel.sh` builds it in the sandbox over the compiler built -in the checkout, copies the output into `Generated/`, runs the check test, -edits one file and runs it again; CI's `bazel` job is that script. The -example compiles nothing: the generated Swift is what the SwiftPM plugin -writes for the same sources, which the package's own build compiles. +`bazel/examples/rickandmorty` generates the Rick and Morty sample's +screens from `examples/RickAndMorty` and +`spec/rickandmorty/schema.graphql`. Those are read in place through +`new_local_repository`, against the module at `../..`. +`scripts/check-bazel.sh` builds that example in the sandbox, over the +compiler built in the checkout. The script copies the output into +`Generated/` and runs the check test. Then it edits one file and runs +the test again. CI's `bazel` job is that script. The example compiles +nothing: the generated Swift is what the SwiftPM plugin writes for the +same sources, and the package's own build compiles it. ## What the module does not do - Wrap `swift_library`. Every attribute rules_swift adds would be one the - wrapper must learn, and a monorepo already wraps `swift_library` once; - the rule yields sources and the adopter's rule compiles them. If two - adopters' macros around `baton_generate` turn out the same, that macro - has earned a home here. -- Take a schema extension or a root directory as a flag. An extension is a - source beside the configuration, which the sandbox holds; a rule that + wrapper must learn, and a monorepo already wraps `swift_library` once. + The rule yields sources, and your rule compiles them. If two adopters' + macros around `baton_generate` turn out the same, that macro has earned + a home here. +- Take a schema extension or a root directory as a flag. An extension is + a source beside the configuration, which the sandbox holds. A rule that names its outputs needs no root. The report's sources are relative to the execroot, which is the workspace, so two checkouts' reports diff clean. diff --git a/docs/recipes/derived-state.md b/docs/recipes/derived-state.md index dc5cdb28..ce6a1998 100644 --- a/docs/recipes/derived-state.md +++ b/docs/recipes/derived-state.md @@ -1,11 +1,11 @@ # Derived state outside views -A model that is not a view sometimes wants a value computed from the store: -a badge count over a connection, a sort of rows by a field, a flag over -several lenses. Baton adds no API for it. A lens's fields and a handle's -phase are observable, and `Observations`, on the 26 floor, turns what a -closure reads into a sequence that yields when any of it changes; the model -reads through that and keeps the derived value itself. +A model that is not a view sometimes wants a value computed from the +store: a badge count over a connection, a sort of rows by a field, a flag +over several lenses. Baton adds no API for it. A lens's fields and a +handle's phase are observable, and `Observations`, on the 26 floor, turns +what a closure reads into a sequence that yields when any of it changes. +The model reads through that sequence and keeps the derived value itself. ## The shape @@ -38,19 +38,20 @@ final class TriageBadge { } ``` -`count()` reads the phase, the search's rows and each row's type; the +`count()` reads the phase, the search's rows, and each row's type. The sequence yields when any of them changes, once per commit, after the -commit's notifications, and not for a commit that touched nothing it read. -Spell the element type in the generic parameters, as above: Swift 6.3's -compiler crashes on a closure that states `@MainActor () -> Int` itself. The retention keeps the operation's data alive for the model's life, -as a view's storage would. +commit's notifications, and it does not yield for a commit that touched +nothing it read. Spell the element type in the generic parameters, as +above, because Swift 6.3's compiler crashes on a closure that states +`@MainActor () -> Int` itself. The retention keeps the operation's data +alive for the model's life, as a view's storage would. ## The same in Kotlin -Compose's snapshot state records what a block reads as Observation does, -and `snapshotFlow` turns the block into a flow that emits when any of it -changes; the model reads the handle inside the block and keeps the value. -The shape: +Compose's snapshot state records what a block reads, as Observation does. +`snapshotFlow` turns the block into a flow that emits when any of it +changes. The model reads the handle inside the block and keeps the value, +in this shape: ```kotlin class TriageBadge(environment: Environment) { @@ -67,20 +68,22 @@ class TriageBadge(environment: Environment) { } ``` -The flow is collected on the main dispatcher, where the store is read, and -the hold is released with the model. [Views and view models](views.md) is +Collect the flow on the main dispatcher, where the store is read, and +release the hold with the model. [Views and view models](views.md) is this page's Kotlin twin, with the model compiled and proven. ## What it is not -- Not a commit signal. Caton asked twice for one; a model that observes - what it reads does not need to know that a commit happened, only that a - value it derives from changed, and `Observations` says exactly that. +- Not a commit signal. Caton asked twice for one. A model that observes + what it reads does not need to know that a commit happened. It needs to + know that a value it derives from changed, and `Observations` says + exactly that. - Not a second read API. The closure reads the same lenses a view reads, synchronously, on the main actor or on the store's thread. - Not a cache in the store. The derived value belongs to the model that - derives it; the store holds what the server said. + derives it, and the store holds what the server said. See [UIKit and AppKit](uikit.md) for the same pattern driving a controller -and a cell, and [the decision](../decisions/the-store-numbers-what-it-renders.md) +and a cell, and +[the decision](../decisions/the-store-numbers-what-it-renders.md) for why a read registers per field. diff --git a/docs/recipes/discover-once.md b/docs/recipes/discover-once.md index 464f3399..9fc206ed 100644 --- a/docs/recipes/discover-once.md +++ b/docs/recipes/discover-once.md @@ -1,15 +1,15 @@ # Discover once, refresh through `nodes(ids:)` -An app whose subjects arrive by natural key, a repository's owner and name -and an issue's number from a notification, cannot ask for them in one -operation: operations are fixed at build time, and the count of aliases -cannot vary. The pattern every such app settles on has two halves, and the -GitHub samples, Swift and Kotlin, show both. +Some subjects arrive by a natural key, such as a repository's owner and +name, or an issue's number from a notification. You cannot ask for a +changing count of them in one operation: operations are fixed at build +time, and the count of aliases cannot vary. The pattern these apps settle +on has two halves, and the GitHub samples, Swift and Kotlin, show both. ## Discover once, by natural key -The first time a subject is seen, one operation looks it up by the key the -app has, and the response carries the node's `id`: +The first time you see a subject, one operation looks it up by the key the +app has. The response carries the node's `id`: ```graphql query IssueQuery($owner: String!, $name: String!, $number: Int!) { @@ -19,14 +19,14 @@ query IssueQuery($owner: String!, $name: String!, $number: Int!) { } ``` -The record is keyed by that `id` from then on, whatever path reached it, so -every later read, by any operation, finds the same record. +The record is keyed by that `id` from then on, whatever path reached it, +so every later read, by any operation, finds the same record. ## Refresh through `nodes(ids:)` -Once the ids are known, one operation refreshes any number of them: GitHub's -`nodes(ids: [ID!]!)` returns the records in the order asked, and the sample -spreads the same row fragments its lists render: +Once the ids are known, one operation refreshes any number of them. +GitHub's `nodes(ids: [ID!]!)` returns the records in the order you asked, +and the sample spreads the same row fragments its lists render: ```graphql query RefreshRowsQuery($ids: [ID!]!) { @@ -42,26 +42,27 @@ let ids = rows.compactMap { $0.asIssue?.id ?? $0.asPullRequest?.id } try await environment.fetch(RefreshRowsQuery(ids: Array(ids.prefix(50)))) ``` -In Kotlin the same two lines, `fetch` a `suspend` function; the Kotlin -sample hosts the query on the function that sends it, `Environment.refreshRows` -in its `TriageScreen.kt`, since no composable resolves it: +In Kotlin those are the same two lines, and `fetch` is a `suspend` +function. The Kotlin sample hosts the query on the function that sends it, +`Environment.refreshRows` in its `TriageScreen.kt`, since no composable +resolves it: ```kotlin val ids = rows.mapNotNull { it.asIssue?.id ?: it.asPullRequest?.id } environment.fetch(RefreshRowsQuery(ids = ids.take(50))) ``` -The response commits into the records the lists already show, by identity, -so every row re-renders with its new fields and nothing else changes; the -list's own query is not refetched, and its connection's edges stay where -they were. Fifty ids a request is GitHub's comfortable batch; Caton, the -app this pattern comes from, refreshed five hundred subjects in ten requests -where discovery had cost five hundred. +The response commits into the records the lists already show, by +identity. Every row re-renders with its new fields, and nothing else +changes. The list's own query is not refetched, and its connection's +edges stay where they were. Fifty ids a request is GitHub's comfortable +batch. Caton, the app this pattern comes from, refreshed five hundred +subjects in ten requests. Discovery had cost five hundred. ## Why not a variable-length batch of aliases A compiler feature that expands `issue(number:)` into as many aliases as -the app has keys would be a concept for one app's cold start, and the -discovery cost is paid once per subject, not per launch: after it, the id -is in the store and the image. `nodes(ids:)` is the server's batch, and -Baton's identity makes the two halves meet in one record. +the app has keys would be a concept for one app's cold start. The +discovery cost is paid once per subject, not per launch, and after that +the id is in the store and the image. `nodes(ids:)` is the server's +batch, and Baton's identity makes the two halves meet in one record. diff --git a/docs/recipes/exchange.md b/docs/recipes/exchange.md index 3fbc1618..212e2fd7 100644 --- a/docs/recipes/exchange.md +++ b/docs/recipes/exchange.md @@ -1,22 +1,22 @@ # The exchange: a challenge, a retry and a deadline over one verb -Baton's transport has one verb, `send`: a request yields a stream of +Baton's transport has one verb, `send`, and a request yields a stream of payloads. The built-in transports read credentials per attempt from a function, so a rotated token reaches the next request without a new environment. What a production endpoint needs beyond that is a composition -of that verb, and compositions ship as examples, not as library types: a -transport that wraps a transport is written once, around one method, and -the app owns it and its numbers. Two copies are compiled in this -repository, one per runtime, and this page walks through them: -[`examples/Exchange/Exchange.swift`](../../examples/Exchange/Exchange.swift), -which the Swift GitHub sample sends through, and -[`kotlin/samples/exchange/src/commonMain/kotlin/baton/exchange/Exchange.kt`](../../kotlin/samples/exchange/src/commonMain/kotlin/baton/exchange/Exchange.kt), -which the Kotlin one sends through. Copy the one for your runtime, and -change the numbers to the endpoint's. +of that verb. Compositions ship as examples, not as library types. You +write a transport that wraps a transport once, around one method. The app +owns that wrapper and the numbers. Two copies are compiled in this +repository, one per runtime, and this page walks through them. +[`examples/Exchange/Exchange.swift`](../../examples/Exchange/Exchange.swift) +is the one the Swift GitHub sample sends through. +[`kotlin/samples/exchange/src/commonMain/kotlin/baton/exchange/Exchange.kt`](../../kotlin/samples/exchange/src/commonMain/kotlin/baton/exchange/Exchange.kt) +is the one the Kotlin sample sends through. Copy the one for your +runtime, and change the numbers to the endpoint's. ## What it does, in order -1. **Credentials per attempt** are the base transport's: its `credentials` +1. **Credentials per attempt** are the base transport's. Its `credentials` function is read on every attempt, so a token renewed between two attempts reaches the second. The exchange adds no header of its own. 2. **One replay of a challenge.** A 401 calls `challenged()` once per @@ -24,32 +24,32 @@ change the numbers to the endpoint's. second 401 fails the request. A 401 was refused before execution, so the replay applies to a mutation as well. 3. **A mutation is never sent twice** otherwise. The server may have - received it; a second send would apply it again. + received it, and a second send would apply it again. 4. **A bounded retry with backoff** over the outcomes a retry can mend: a - 5xx status, or a connection that was lost or timed out. The wait doubles - from the first step and is jittered. A 4xx other than 401, a request - error (the server answered with errors) and a malformed response are not - retried: they would repeat. In Swift a lost connection is the platform's - `URLError`; in Kotlin the built-in transports report it as a - `TransportError` of status 0 whose `cause` is the connection's own - exception, and a status 0 with no cause, a request the transport could - not make, is not retried either. -5. **A deadline spanning every attempt**, the waits included: a retry whose - wait would pass it fails with the last error instead. The time one - attempt may take is the session's own timeout. -6. **A stream that delivered is not sent again.** Once a payload reached the - caller, a later failure ends the stream: a deferred response's remaining - parts are not fetched twice, and a subscription reconnects by its - handle's own rule, not here. + 5xx status, or a connection that was lost or timed out. The wait + doubles from the first step and is jittered. A 4xx other than 401, a + request error (the server answered with errors), and a malformed + response are not retried, because they would repeat. In Swift a lost + connection is the platform's `URLError`. In Kotlin the built-in + transports report it as a `TransportError` of status 0 whose `cause` + is the connection's own exception. A status 0 with no cause, a request + the transport could not make, is not retried either. +5. **A deadline spanning every attempt**, the waits included. A retry + whose wait would pass it fails with the last error instead. The time + one attempt may take is the session's own timeout. +6. **A stream that delivered is not sent again.** Once a payload reached + the caller, a later failure ends the stream, so a deferred response's + remaining parts are not fetched twice. A subscription reconnects by + its handle's own rule, not here. ## Credentials stay with their host Credentials belong to one endpoint. Keep them in the base transport whose -`url` is that endpoint, never in a wrapper that could be pointed elsewhere: -an `Exchange` over a `URLSessionTransport(url: api, credentials: token)`, -or over an `HttpTransport(api, credentials = token)`, sends the token to -`api` and nowhere else, because nothing but that transport reads the -function. +`url` is that endpoint, never in a wrapper that could be pointed +elsewhere. An `Exchange` over a `URLSessionTransport(url: api, +credentials: token)`, or over an `HttpTransport(api, credentials = +token)`, sends the token to `api` and nowhere else. Nothing but that +transport reads the function. ## Wiring @@ -71,7 +71,7 @@ let environment = Environment( ) ``` -In Kotlin, the same shape over the runtime's `HttpTransport`; the GitHub +In Kotlin the shape is the same, over the runtime's `HttpTransport`. The GitHub sample's `Main.kt` wires `Environment(Exchange(HttpTransport(url, credentials = { mapOf("Authorization" to "Bearer $token") })), store = ...)` with the defaults: @@ -91,11 +91,11 @@ val environment = Environment( ``` A subscription sent through the exchange gets its retry too, until its -first event; after that the handle's reconnection takes over. A socket -transport renews its own connection, so it usually stands alone, as above. -In Kotlin the socket transport takes the client it opens sockets with: on -the JVM `GraphQLTransportWebSocket(url, credentials = ...)` picks the -JVM's own, and on Android, whose platform has none, +first event. After that, the handle's reconnection takes over. A socket +transport renews its own connection, so it usually stands alone, as +above. In Kotlin the socket transport takes the client it opens sockets +with. On the JVM, `GraphQLTransportWebSocket(url, credentials = ...)` +picks the JVM's own. On Android, whose platform has none, use `GraphQLTransportWebSocket(url, credentials = ..., client = OkHttpWebSocketClient(okHttp))` from `baton-okhttp`, beside an `OkHttpTransport(okHttp, url)` for the queries, which the exchange wraps @@ -104,9 +104,9 @@ the same way. ## The loop, in Kotlin The Kotlin exchange is one `flow` around the base transport's `send`. The -flow is cold, so each collection is one exchange, and a consumer that goes -away cancels the attempt under it; the deadline is read off the monotonic -clock, and the waits are `delay`s. This is the compiled code: +flow is cold, so each collection is one exchange, and a consumer that +goes away cancels the attempt under it. The deadline is read off the +monotonic clock, and the waits are `delay`s. This is the compiled code: ```kotlin override fun send(request: Request): Flow = flow { @@ -149,8 +149,8 @@ override fun send(request: Request): Flow = flow { } ``` -Whether a failure is one a retry can mend is one function, which an app -with another transport, or another judgement, edits: +Whether a failure is one a retry can mend is one function. An app with +another transport, or another judgement, edits that function: ```kotlin /** @@ -173,20 +173,21 @@ The Swift exchange is the same loop over an `AsyncThrowingStream`, with ## What the tests prove The Swift package's tests (`ExchangeTests` under `swift/Tests/BatonTests`) -run the sample over a scripted transport: a 401 then a success is sent -twice and renews the token between; a 503 then a 200 is sent twice; a -deadline that expires during the backoff fails with the 503 and no wait; a -mutation refused with a 503 is sent once and fails; a query whose stream -delivered a payload before failing is not sent again; the credentials the -base transport reads change between two attempts; and a held mutation's -optimistic layer stays applied while a query beside it is retried. +run the sample over a scripted transport. A 401 then a success is sent +twice and renews the token between, and a 503 then a 200 is sent twice. +A deadline that expires during the backoff fails with the 503 and no +wait. A mutation refused with a 503 is sent once and fails, and a query +whose stream delivered a payload before failing is not sent again. The +credentials the base transport reads change between two attempts, and a +held mutation's optimistic layer stays applied while a query beside it is +retried. The Kotlin runtime's JVM tests (`ExchangeTests` under `kotlin/baton/src/jvmTest`) prove the same of the Kotlin exchange over -`baton-testing`'s `ScriptedTransport` and a loopback server: the replayed -challenge carries the renewed token; a second challenge fails; a 503 then -a 200 is sent twice; the attempts bound the sends; a deadline that expires -during the backoff fails at once; a mutation refused with a 503 is sent -once; a cancelled consumer ends the held attempt; an environment over an -exchange reads ready after a retry; and a held mutation's optimistic layer -stands through a retry beside it. +`baton-testing`'s `ScriptedTransport` and a loopback server. The replayed +challenge carries the renewed token, and a second challenge fails. A 503 +then a 200 is sent twice, and the attempts bound the sends. A deadline +that expires during the backoff fails at once, a mutation refused with a +503 is sent once, and a cancelled consumer ends the held attempt. An +environment over an exchange reads ready after a retry, and a held +mutation's optimistic layer stands through a retry beside it. diff --git a/docs/recipes/gradle.md b/docs/recipes/gradle.md index 79b91e2c..82ccf742 100644 --- a/docs/recipes/gradle.md +++ b/docs/recipes/gradle.md @@ -1,22 +1,23 @@ # Gradle: the runtime from Maven Central, and the compiler as a task `com.shergin.baton` is [the command's contract](batonc.md) spelled in -Gradle: a task that runs `batonc generate` with every input and output -declared and yields the generated Kotlin for the source set the adopter -already has, over a compiler the plugin fetches from the release's artifact -bundle. It lives under `kotlin/baton-gradle` in this repository and is -versioned with Baton, so the plugin 0.16.0 fetches the bundle 0.16.0 -published and writes the format the runtime 0.16.0 reads; the version the -snippets below quote is the latest release, as `CHANGELOG.md` names it. It holds no logic -of its own: which source writes which output, the header for a source -without GraphQL, the shared file, staleness and diagnostics are the -compiler's ([the decision](../decisions/a-build-integration-holds-no-logic.md)). +Gradle. A task runs `batonc generate` with every input and output +declared, and yields the generated Kotlin for the source set you already +have. The plugin fetches the compiler from the release's artifact +bundle. The plugin lives under `kotlin/baton-gradle` in this repository +and is versioned with Baton, so the plugin 0.16.0 fetches the bundle +0.16.0 published and writes the format the runtime 0.16.0 reads. The +version the snippets below quote is the latest release, as `CHANGELOG.md` +names it. It holds no logic of its own: which source writes which output, +the header for a source without GraphQL, the shared file, staleness, and +diagnostics are the compiler's +([the decision](../decisions/a-build-integration-holds-no-logic.md)). ## The runtime -The Kotlin runtime is on Maven Central from 0.13.0, as Kotlin Multiplatform -artifacts with a JVM and an Android target, so one coordinate resolves to -the variant a module compiles against: +The Kotlin runtime is on Maven Central from 0.13.0, as Kotlin +Multiplatform artifacts with a JVM and an Android target. One coordinate +resolves to the variant a module compiles against: | Coordinate | What | Targets | |---|---|---| @@ -26,25 +27,25 @@ the variant a module compiles against: | `com.shergin.baton:baton-inspector` | `StoreInspector`, a live Compose view of a store for a debug pane, and `StoreExport` | JVM, Android | The runtime depends on the Compose runtime, which generated code names -(`@Stable`), kotlinx-coroutines and the AndroidX SQLite driver API, with -the bundled driver on the JVM; Compose UI is the app's own. On Android it -starts at API 23, and it needs no R8 or ProGuard rules and ships none: the -runtime, its modules and the generated Kotlin use no reflection, no -serialization and no lookup of a class or a member by name, so a minified -build keeps what the code calls. The AndroidX pieces -are on Google's Maven repository, which Gradle does not search unless the -build names it, so the settings name `google()` beside `mavenCentral()` for -dependencies, as the snippet below does. The runtime is built with Kotlin -2.4.20 and carries Java 21 class files, so a consumer runs on a JVM of 21 -or newer and compiles with Kotlin 2.4 or newer; the floors and the policy -behind them are +(`@Stable`), on kotlinx-coroutines, and on the AndroidX SQLite driver +API, with the bundled driver on the JVM. Compose UI is the app's own. On +Android the runtime starts at API 23 and needs no R8 or ProGuard rules, +and it ships none. The runtime, its modules, and the generated Kotlin use no +reflection, no serialization, and no lookup of a class or a member by +name, so a minified build keeps what the code calls. The AndroidX pieces +are on Google's Maven repository. Gradle does not search that repository +unless the build names it, so the settings name `google()` beside +`mavenCentral()` for dependencies, as the snippet below does. The runtime +is built with Kotlin 2.4.20 and carries Java 21 class files, so a consumer +runs on a JVM of 21 or newer and compiles with Kotlin 2.4 or newer. The +floors, and the policy behind them, are [the decision](../decisions/kotlin-floors.md). ## The plugin -The plugin is on Maven Central beside the runtime, not on the Gradle plugin -portal, so the settings name that repository for plugins once, and Google's -beside it for the runtime's dependencies: +The plugin is on Maven Central beside the runtime, not on the Gradle +plugin portal. Name that repository for plugins once in the settings, and +name Google's beside it for the runtime's dependencies: ```kotlin // settings.gradle.kts @@ -63,11 +64,11 @@ dependencyResolutionManagement { } ``` -The module that holds the screens applies it beside the Kotlin plugin, -names its hosts, and adds the task's output to the source set that compiles -them; a Multiplatform module here, where a JVM-only module applies -`kotlin("jvm")`, adds the directory to `sourceSets.main` and declares the -dependency at the top level: +The module that holds the screens applies the plugin beside the Kotlin +plugin, names its hosts, and adds the task's output to the source set +that compiles them. The snippet is a Multiplatform module. A JVM-only +module instead applies `kotlin("jvm")`, adds the directory to +`sourceSets.main`, and declares the dependency at the top level: ```kotlin // build.gradle.kts @@ -109,34 +110,34 @@ androidComponents { } ``` -`baton.json` is read from the module's directory by convention, with the -schema, the identity, the lookups and the scalars as +`baton.json` is read from the module's directory by convention. It holds +the schema, the identity, the lookups, and the scalars, as [the vocabulary](../terminology.md) defines them and -[the compiler's recipe](batonc.md) reads them, and +[the compiler's recipe](batonc.md) reads them, plus `"kotlin": {"package": ""}` for the shared file. -The compiler is the release's: on first use the plugin downloads +The compiler is the release's. On first use the plugin downloads `batonc.artifactbundle.zip` of the release it is versioned with, the one -file SwiftPM and Bazel download, checks it against the checksum the release -wrote into `kotlin/release.properties`, unpacks it whole into +file SwiftPM and Bazel download. It checks that zip against the checksum +the release wrote into `kotlin/release.properties`, unpacks it whole into `caches/baton//batonc.artifactbundle/` under the Gradle user -home, once per home, and runs the variant -its `info.json` lists for the host, macOS or Linux, x86_64 or aarch64. A checkout that -builds its own compiler names it instead, as `Package.swift` and the Bazel -extension take `BATON_COMPILER`, a `batonc` binary or a bundle directory -holding an `info.json`: +home, once per home, and then runs the variant its `info.json` lists for +the host: macOS or Linux, x86_64 or aarch64. A checkout that builds +its own compiler names it instead, as `Package.swift` and the Bazel +extension take `BATON_COMPILER`. That value is a `batonc` binary, or a +bundle directory holding an `info.json`: ```bash BATON_COMPILER=/path/to/batonc gradle build ``` -or, for every task of a project, `baton { compiler.set(file("...")) }`. An -offline build with no bundle cached fails and says so. +Or, for every task of a project, `baton { compiler.set(file("...")) }`. +An offline build with no bundle cached fails, and it says so. ## `BatonGenerate` -The plugin registers one, `generateBaton`; a module with documents against -two schemas registers a second with the task type. +The plugin registers one task, `generateBaton`. A module with documents +against two schemas registers a second task of the same type. | Property | What | Convention | |---|---|---| @@ -149,17 +150,19 @@ two schemas registers a second with the task type. | `persisted` | the persisted documents file under `persistConfig`, as `--persisted` places it | unset, so it goes under the output directory | - **Every input declared:** the hosts, the configuration, and the schema - and the `schemaExtensions` the configuration names, which the plugin reads - from its text through a provider Gradle tracks, so an edit to the schema - regenerates and the adopter names nothing twice; the compiler binary when - `BATON_COMPILER` names one, and the release's version and checksum when it - does not. The task is cacheable and runs under the configuration cache. -- **One output per source,** named as the SwiftPM plugin and the Bazel rule - name it: the host's path relative to the working directory, each separator - an underscore, `.baton.kt` in place of `.kt` and after any other extension + and the `schemaExtensions` the configuration names. The plugin reads + those from the configuration's text, through a provider Gradle tracks, + so an edit to the schema regenerates and you name nothing twice. The + compiler binary is an input when `BATON_COMPILER` names one, and the + release's version and checksum are inputs when it does not. The task is + cacheable and runs under the configuration cache. +- **One output per source,** named as the SwiftPM plugin and the Bazel + rule name it. Take the host's path relative to the working directory, + turn each separator into an underscore, and put `.baton.kt` in place of + `.kt` and after any other extension (`src/commonMain/kotlin/app/Screen.kt` writes - `src_commonMain_kotlin_app_Screen.baton.kt`); a renamed source leaves - nothing behind, since the compiler removes what it did not write. -- **Diagnostics** are `path:line:column: error: message`, pointing into the - GraphQL text in the host file, which Gradle and the IDE show at the line; - a document with an error fails the task with nothing written. + `src_commonMain_kotlin_app_Screen.baton.kt`). A renamed source leaves + nothing behind, since the compiler removes an output it did not write. +- **Diagnostics** are `path:line:column: error: message`, pointing into + the GraphQL text in the host file, so Gradle and the IDE show them at + the line. A document with an error fails the task with nothing written. diff --git a/docs/recipes/outside-views.md b/docs/recipes/outside-views.md index 1553d0bb..dcb60a66 100644 --- a/docs/recipes/outside-views.md +++ b/docs/recipes/outside-views.md @@ -4,21 +4,21 @@ A view's `@Query` or `rememberQuery` asks the environment for a handle and keeps it while the view lives. Any other code can make the same calls: a service, a background refresh, a notification handler, a widget's timeline. Nothing here is a second API. The environment has a call to -commit data, a handle to read it, and a retention to keep it, and a view's +commit data, a handle to read it, and a retention to keep it. A view's storage is made of the same three. -What does not change outside a view: an operation is compiled, never -parsed at run time, and reads run on the main actor in Swift and on the -store's thread in Kotlin. +What does not change outside a view is that an operation is compiled, +never parsed at run time, and that reads run on the main actor in Swift +and on the store's thread in Kotlin. ## Where the GraphQL lives `@Query("…")` in Swift is a view's storage, so code that is not a view keeps its operations in a `.graphql` file in the target. The build plugin compiles them with the target's Swift sources into the same operation -types. In Kotlin, `@Query` is a marker the compiler reads the text from, -so it can sit on the function that sends the operation, as the GitHub -sample's `Environment.refreshRows` does, or the operation goes in a +types. In Kotlin, `@Query` is a marker the compiler reads the text from. +It can sit on the function that sends the operation, as the GitHub +sample's `Environment.refreshRows` does, or the operation can go in a `.graphql` document the Gradle task reads. ## Which call @@ -32,11 +32,11 @@ sample's `Environment.refreshRows` does, or the operation goes in a ## Read once -`fetch` always asks the network, ingests the response off the main actor, -commits it, and returns nothing. The commit makes the operation's root, and -a root that nothing retains waits in the store's release buffer, ten roots -deep by default. A `storeOnly` handle then reads that root without sending -a request: +`fetch` always asks the network. It ingests the response off the main +actor, commits it, and returns nothing. The commit makes the operation's +root, and a root that nothing retains waits in the store's release +buffer, ten roots deep by default. A `storeOnly` handle then reads that +root without sending a request: ```swift @MainActor @@ -59,25 +59,26 @@ suspend fun Environment.characterName(id: String): String? { Read the values out when the call returns. A lens is a view onto records, not a copy of them, and the records stay only while some root reaches -them. Once enough other roots are released after this one, the buffer lets -it go, the collector may reclaim its records, and a lens held past that -point reads only what other roots keep. Code that holds data longer -retains it, as in the next section. +them. Once enough other roots are released after this one, the buffer +lets it go and the collector may reclaim its records. A lens held past +that point reads only what other roots keep, so code that holds data +longer retains it, as the next section shows. -The same `storeOnly` handle with nothing fetched first is an offline read. -When the store cannot answer, its phase is `.failed(MissingDataError)` and -no request is sent. +The same `storeOnly` handle, with nothing fetched first, is an offline +read. When the store cannot answer, its phase is `.failed(MissingDataError)` +and no request is sent. ## Keep and follow A service that lives longer than one call holds the handle and its retention in properties, as a view's storage does. The default policy, -`storeOrNetwork`, sends a request only when the store cannot answer or the -data is stale. The data stays while the retention lives. The service reads -the phase where it needs it, or observes what it reads: through -`Observations` in Swift, over a lens's fields and the handle's phase, both -`@Observable`, and through `snapshotFlow` in Kotlin, over the same values -as snapshot state. The compiled shapes are in three other recipes: +`storeOrNetwork`, sends a request only when the store cannot answer or +the data is stale, and the data stays while the retention lives. The +service reads the phase where it needs it, or observes what it reads: in +Swift through `Observations`, over a lens's fields and the handle's +phase, both `@Observable`, and in Kotlin through `snapshotFlow`, over the +same values as snapshot state. The compiled shapes are in three other +recipes: - [derived state outside views](derived-state.md): a model that is not a view, in Swift and Kotlin; @@ -99,20 +100,20 @@ shows in the turn of the call, wherever the caller is. ## Why no `fetchQuery` returns data -Relay's `fetchQuery` does not retain what it fetched either: its +Relay's `fetchQuery` does not retain what it fetched either. Its documentation sends code that wants the data kept to `environment.retain()` -on the query. Baton makes the same split. `fetch` commits, a handle reads, and a -retention keeps. If `fetch` returned a lens, the caller would hold a value -whose records nothing keeps alive. +on the query. Baton makes the same split: `fetch` commits, a handle +reads, and a retention keeps. If `fetch` returned a lens, the caller +would hold a value whose records nothing keeps alive. ## Off the main actor A service running elsewhere hops to read. In Swift the environment and its handles are `@MainActor`, so a caller off the main actor awaits each call there. In Kotlin, the environment's `suspend` calls run on its main -dispatcher from whatever thread calls them, but a phase or a lens is read +dispatcher from whatever thread calls them, and a phase or a lens is read on that dispatcher. The request and the ingest already run off the main -actor or the store's thread; only the commit and the read run on it. +actor or the store's thread, and only the commit and the read run on it. `OutsideViewsTests`, under `swift/Tests/BatonTests` and `kotlin/baton/src/jvmTest`, prove what this page says on both runtimes. diff --git a/docs/recipes/porting-from-relay.md b/docs/recipes/porting-from-relay.md index 80f0499d..a88c1053 100644 --- a/docs/recipes/porting-from-relay.md +++ b/docs/recipes/porting-from-relay.md @@ -1,11 +1,11 @@ # Porting from Relay -Baton keeps Relay's words and directives, so a Relay app ports by spelling, -not by rethinking: a fragment stays a fragment, a connection a connection, -`@required` and `@catch` mean what they mean. What differs is what Swift, -Kotlin and a native runtime make better, and what Baton leaves out on -purpose. The table gives each word in Swift and in Kotlin; each "not this" -below links its reason. +Baton keeps Relay's words and directives, so a Relay app ports by +spelling, not by rethinking. A fragment stays a fragment, a connection +stays a connection, and `@required` and `@catch` mean what they mean. +What differs is what Swift, Kotlin, and a native runtime make better, and +what Baton leaves out on purpose. The table gives each word in Swift and +in Kotlin, and each "not this" below links its reason. ## The same words @@ -35,28 +35,30 @@ below links its reason. ## Different on purpose -- **Missing data heals itself.** Relay suspends on missing data; Baton's - lens reads nil, records the miss, logs it and refetches the operation that - owns the record. Nothing in a view waits. -- **Identity is configured, not assumed.** `id` keys a record by default; - types keyed otherwise, including composite keys and interfaces, are named - in `baton.json`, and the compiler asks the server for the key fields. +- **Missing data heals itself.** Relay suspends on missing data. Baton's + lens reads nil, records the miss, logs it, and refetches the operation + that owns the record. Nothing in a view waits. +- **Identity is configured, not assumed.** `id` keys a record by default. + Types keyed otherwise, including composite keys and interfaces, are + named in `baton.json`, and the compiler asks the server for the key + fields. - **Custom scalars read as the app's types.** `customScalarTypes` maps a - scalar to `Decimal`, `Date`, `URL`, `UUID` or the app's own - `MappedScalar`, as a string or under `swift` in an object by language, - and under `kotlin` to a Kotlin type with the `ScalarConverter` object - for it; the conversion happens at the read, and a value the type cannot - hold is a field error. + scalar to `Decimal`, `Date`, `URL`, `UUID`, or the app's own + `MappedScalar`, as a string or under `swift` in an object by language. + Under `kotlin`, the mapping is a Kotlin type with the `ScalarConverter` + object for it. The conversion happens at the read, and a value the type + cannot hold is a field error. - **Enums are generated**, with a case for a value the build did not know: Swift's `unknown`, Kotlin's `Undeclared`. - **Input objects are generated values**, a struct in Swift and a data class in Kotlin, typed from the schema. -- **The session is the environment.** Signing out is `await environment.end()`, - a `suspend` call in Kotlin, and a new environment; there is no - `replaceStore`. The order of a sign-out and the account in the image's - identity are [the accounts recipe](accounts.md). +- **The session is the environment.** Signing out is + `await environment.end()`, a `suspend` call in Kotlin, and a new + environment. There is no `replaceStore`. The order of a sign-out, and + the account in the image's identity, are [the accounts recipe](accounts.md). - **Client fields are described and committed**, through `schemaExtensions` - and `commitPayload`; there are no resolvers and no `commitLocalUpdate`. + and `commitPayload`, and there are no resolvers and no + `commitLocalUpdate`. ## Not ported, and why @@ -71,5 +73,5 @@ below links its reason. | Imperative store updaters | Directives and payloads have expressed every write so far | A real write neither can express | | Automatic persisted queries | They need text and id at run time; the working group dropped them | none | -Relay's docs stay the reference for the directives' meaning. Baton's +Relay's docs stay the reference for what the directives mean, and Baton's [terminology](../terminology.md) is the contract for every word above. diff --git a/docs/recipes/testing.md b/docs/recipes/testing.md index 1326f9a1..c8f306d8 100644 --- a/docs/recipes/testing.md +++ b/docs/recipes/testing.md @@ -1,25 +1,26 @@ # Previews and tests: a store without a server -Baton's store is filled from a payload as well as from the network, and a -transport is a protocol with one verb, so a preview, a test and a benchmark -each get a store the way an app does and never a mock of Baton's types. -This page is the catalogue, each piece in Swift and then in Kotlin; every +Baton's store is filled from a payload as well as from the network. A +transport is a protocol with one verb. A preview, a test, and a benchmark +each get a store the way an app does, and never a mock of Baton's types. +This page is the catalogue, each piece in Swift and then in Kotlin. Every piece lives in `BatonTesting`, a product of the package nothing in the app links, or in Kotlin in the module `baton-testing`, a test dependency on -the JVM and on Android, except `commitPayload`, which is the environment's -own. +the JVM and on Android. The exception is `commitPayload`, which is the +environment's own. A Kotlin test outside a composition gives the environment a test -dispatcher as both its main dispatcher and its ingest dispatcher and runs -under `runTest`, as the runtime's own tests do: +dispatcher as both its main dispatcher and its ingest dispatcher, and +runs under `runTest`, as the runtime's own tests do: ```kotlin val dispatcher = StandardTestDispatcher(testScheduler) val environment = Environment(transport, store = Store(), mainDispatcher = dispatcher, ingestDispatcher = dispatcher) ``` -A Compose test provides it through `LocalBaton` on the rule's thread and -waits with `rule.waitUntil`, as the GitHub sample's `ScreenTests` does. +A Compose test provides the environment through `LocalBaton` on the +rule's thread, and waits with `rule.waitUntil`, as the GitHub sample's +`ScreenTests` does. ## A preview: a fixture committed as a response @@ -29,15 +30,15 @@ try await environment.commitPayload(CharacterQuery(id: "1"), Payload(fixture)) ``` `commitPayload` runs the operation's plan over a payload, bytes in a -response's shape, and commits them as a fetch's response is committed, so the preview draws -from the fixture through the same lenses, and `SilentTransport` never -answers, so nothing behind the preview waits on a network. The payload may -carry part of what the operation selects; what it leaves out reads as -missing, as a partial response would. +response's shape. The call commits them as a fetch's response is +committed, so the preview draws from the fixture through the same lenses. +`SilentTransport` never answers, so nothing behind the preview waits on a +network. The payload may carry part of what the operation selects, and +what it leaves out reads as missing, as a partial response would. -In Kotlin, `commitPayload` is a suspend function, run on the environment's -main dispatcher whatever thread calls it, and `Payload` wraps a -`ByteArray` or a JSON `String`: +In Kotlin, `commitPayload` is a suspend function that runs on the +environment's main dispatcher, whatever thread calls it. `Payload` wraps +a `ByteArray` or a JSON `String`: ```kotlin val environment = Environment(SilentTransport()) @@ -52,13 +53,13 @@ let environment = Environment(transport: transport) ``` `RecordedTransport` answers each operation by name, or through a responder -that sees the whole request, and keeps the requests it was sent -(`requests`, `requestCount`), so a test asserts what the app asked for. An -operation with nothing recorded fails with a `TransportError` of status 0 -that says so. +that sees the whole request. It keeps the requests it was sent +(`requests`, `requestCount`), so a test asserts what the app asked for. +An operation with nothing recorded fails with a `TransportError` of +status 0 that says so. -In Kotlin, by name through a map, or through a responder given as the -constructor's lambda; an operation's name is its companion's `name`: +In Kotlin, answer by name through a map, or through a responder given as +the constructor's lambda. An operation's name is its companion's `name`: ```kotlin val transport = RecordedTransport(mapOf(CharacterQuery.name to fixture("character-1"))) @@ -76,17 +77,17 @@ await wait(until: { transport.held.count == 1 }) transport.held[0].respond(fixture("set-favorite-1")) ``` -`ScriptedTransport` holds a mutation, or any operation the test names, until -the test replies or refuses it, so the window between an optimistic apply -and the server's answer is observable; it drives a subscription's events by -hand (`driven[i].send`, `complete`, `fail`); and it lists its requests by -kind. `wait(until:)` waits on the main actor for a handle or a store to -settle, with a timeout, yielding between checks. +`ScriptedTransport` holds a mutation, or any operation the test names, +until the test replies or refuses it. The window between an optimistic +apply and the server's answer is observable. The transport drives a +subscription's events by hand (`driven[i].send`, `complete`, `fail`) and +lists its requests by kind. `wait(until:)` waits on the main actor for a +handle or a store to settle, with a timeout, yielding between checks. -In Kotlin, the mutation runs in a coroutine the test launches, `held` is a -list whose entries `respond` or `refuse`, `driven` a list whose entries -`send`, `complete` or `fail`, `requests(kind)` lists one kind, and `wait` -is a suspend function that yields between checks: +In Kotlin, the mutation runs in a coroutine the test launches. `held` is +a list whose entries `respond` or `refuse`, and `driven` is a list whose +entries `send`, `complete`, or `fail`. `requests(kind)` lists one kind, +and `wait` is a suspend function that yields between checks: ```kotlin val transport = ScriptedTransport(mapOf(CharacterQuery.name to fixture("character-1"))) @@ -107,10 +108,10 @@ func isSeriesRegular(_ character: CharacterValue_character) -> Bool { #expect(!isSeriesRegular(CharacterValue_character(id: "1", name: "Rick", status: "Dead", origin: nil, episode: []))) ``` -A rule outside a view takes the value an `@inline` fragment compiles to, -so its test builds one with the value's initializer, field by field, and -needs no store at all; the app hands it the value the spread's accessor -read. The same rule over a lens would need a store to read from. +A rule outside a view takes the value an `@inline` fragment compiles to. +The test builds one with the value's initializer, field by field, and +needs no store at all. The app hands the rule the value the spread's +accessor read. The same rule over a lens would need a store to read from. In Kotlin the value is a `data class` whose primary constructor takes the fields: @@ -125,9 +126,9 @@ assertFalse(isSeriesRegular(CharacterValue_character(id = "1", name = "Rick", st ## The log in tests A debug build prints a missing field, a value a reader's type cannot hold, -an ambiguous id and a null `@required(action: LOG)` field through the -environment's `log`. A test silences them with `environment.log = nil`, or -collects them: +an ambiguous id, and a null `@required(action: LOG)` field, through the +environment's `log`. A test silences them with `environment.log = nil`, +or collects them: ```swift let seen = Events() @@ -138,8 +139,8 @@ The events are value-free, names and counts, so a test asserts `.missing(type: "Character", field: "name")` and never a record. In Kotlin the environment prints the missing-data events only when it is -made with `debug = true`, since common Kotlin has no build configuration -of its own to read, so a test leaves it unset and collects the events +made with `debug = true`. Common Kotlin has no build configuration of its +own to read, so a test leaves `debug` unset and collects the events instead: ```kotlin @@ -147,20 +148,20 @@ val seen = mutableListOf() environment.log = { event -> seen.add(event) } ``` -and asserts `LogEvent.Missing("Character", "name") in seen`. +The assertion is `LogEvent.Missing("Character", "name") in seen`. ## A bug report that becomes a fixture -`StoreExport.text(of: environment.store)`, from `BatonInspector`, writes the -store in the dump format the fixtures under `spec/` use: every record by -key, sorted, one a line. A dump from a debug menu or the inspector's share -button 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(of: environment.store)`, from `BatonInspector`, writes +the store in the dump format the fixtures under `spec/` use: every record +by key, sorted, one a line. A dump from a debug menu, or the inspector's +share button, 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`. ## What these are not -Not mocks. Each is a transport like any other, and nothing behind it can -tell: the environment, the store, the handles and the lenses run the code -the app runs. A test of the app's own layer over Baton therefore tests that -layer, not a stand-in for the library. +These are not mocks. Each is a transport like any other, and nothing +behind it can tell. The environment, the store, the handles, and the +lenses run the code the app runs, so a test of the app's own layer over +Baton tests that layer, not a stand-in for the library. diff --git a/docs/recipes/uikit.md b/docs/recipes/uikit.md index 1f374e79..df1a314d 100644 --- a/docs/recipes/uikit.md +++ b/docs/recipes/uikit.md @@ -1,25 +1,25 @@ # UIKit and AppKit: a handle held by a controller -Baton's reads are synchronous on the main actor and its handles are -`@Observable` classes, so a `UIViewController` or an `NSViewController` -uses them without SwiftUI: it asks the environment for the handle, holds -the retention that keeps the handle's data alive, renders what it reads, -and renders again when that changes. Nothing here is a second API; it is the +Baton's reads are synchronous on the main actor, and its handles are +`@Observable` classes. A `UIViewController` or an `NSViewController` uses +them without SwiftUI. You ask the environment for the handle, hold the +retention that keeps the handle's data alive, render what you read, and +render again when that changes. Nothing here is a second API: it is the same handle the `@Query` wrapper resolves. The code below is compiled in this repository, as the `Controllers` target -under [`examples/Controllers`](../../examples/Controllers): what both -platforms share in `Content.swift`, and a list, a detail, a cell and the -app's lifecycle for each under `UIKit/` and `AppKit/`. CI builds the UIKit -half for the iOS Simulator and the AppKit half for macOS, and +under [`examples/Controllers`](../../examples/Controllers). What both +platforms share is in `Content.swift`. A list, a detail, a cell, and the +app's lifecycle for each live under `UIKit/` and `AppKit/`. CI builds the +UIKit half for the iOS Simulator and the AppKit half for macOS. `ControllersTests` proves on macOS what this page says of them. ## Where the GraphQL lives -`@Query` on a property is a view's storage: it resolves the handle from -SwiftUI's environment, which a controller does not have. A controller's -operations go in a `.graphql` file in the target, which the build plugin -compiles with the target's Swift sources: +`@Query` on a property is a view's storage that resolves the handle from +SwiftUI's environment, which a controller does not have. Put a +controller's operations in a `.graphql` file in the target. The build +plugin compiles them with the target's Swift sources: ```graphql query CharactersQuery($page: Int) { @@ -48,9 +48,9 @@ query CharacterQuery($id: ID!) { ``` The fragment is here because two cells read it, the UIKit one and the -AppKit one. An app with one cell can keep it beside the cell instead: -`@Fragment` is only a marker the compiler reads the text from, so it works -on a property of any type, not only a view's. +AppKit one. An app with one cell can keep the fragment beside the cell +instead. `@Fragment` is only a marker the compiler reads the text from, so +it works on a property of any type, not only a view's. ## The handle and its retention @@ -73,23 +73,24 @@ public final class CharacterViewController: UIViewController { ``` `handle(for:)` returns the one handle every holder of an equal operation -value shares, and attaches with the fetch policy given (`storeOrNetwork` -by default). `retain()`, called when the view loads, returns a `Retention`: -while the controller holds it, the operation's root is the store's and its -records stay; when the controller goes, so does the retention, and the -collector may reclaim what nothing else keeps. Hold it in a property, never -in a local. The AppKit controller is the same, over `NSViewController`. +value shares. It attaches with the fetch policy you give, `storeOrNetwork` +by default. Call `retain()` when the view loads; it returns a +`Retention`. While the controller holds that retention, the operation's +root is the store's and its records stay. When the controller goes, so +does the retention, and the collector may reclaim what nothing else +keeps. Hold the retention in a property, never in a local. The AppKit +controller is the same, over `NSViewController`. ## Rendering on change -The handle's `phase` is `.loading`, `.ready(data)` or `.failed(error)`, and -a lens's fields are observable. `Observations`, on the 26 floor, turns what -a closure reads into a sequence that yields when any of it changes. What it -watches is what the closure reads, and nothing else: the phase reads -whether the data is there, not the fields in it. So the closure computes -what the controller shows, reading every field it displays, and the loop -only applies it. The computation is plain Swift over the lens, the same on -both platforms: +The handle's `phase` is `.loading`, `.ready(data)`, or `.failed(error)`, +and a lens's fields are observable. `Observations`, on the 26 floor, turns +what a closure reads into a sequence that yields when any of it changes. +What it watches is what the closure reads, and nothing else. The phase +reads whether the data is there, not the fields in it. So the closure +computes what the controller shows, reading every field it displays, and +the loop only applies that value. The computation is plain Swift over the +lens, the same on both platforms: ```swift public enum CharacterContent: Sendable, Equatable { @@ -119,7 +120,7 @@ public enum CharacterContent: Sendable, Equatable { } ``` -and the controller observes it: +The controller observes that value: ```swift public override func viewDidLoad() { @@ -134,28 +135,28 @@ public override func viewDidLoad() { } ``` -A commit that renames the character yields once; a commit that changes a -field the controller does not show yields nothing. A loop that observed -`handle.phase` alone and read the fields in its body would render the -first response and never the rename. +A commit that renames the character yields once, and a commit that +changes a field the controller does not show yields nothing. A loop that +observed `handle.phase` alone, and read the fields in its body, would +render the first response and never the rename. -The first `render` call is not redundant. `Observations` is a sequence and -delivers its first value after a suspension, so a controller that waited -for it would show one blank frame even when the store already holds the -data; the synchronous read draws that frame from the store. The sequence's -first value then renders the same thing again. +The first `render` call is not redundant. `Observations` is a sequence, +and it delivers its first value after a suspension. A controller that +waited for it would show one blank frame, even when the store already +holds the data. The synchronous read draws that frame from the store, and +the sequence's first value then renders the same thing again. `retry()` on the handle after a failed phase, `refetch()` behind a refresh -control, and `isStale` to decide whether to show one are the handle's, as -they are a view's. +control, and `isStale` for whether to show one, are the handle's, as they +are a view's. ## A cell -A cell takes a lens, not a model: the fragment's generated struct, handed -down by the controller as a parent view hands it to a child. The list's -content reads the rows' ids and lenses, not their fields, so a commit that -renames one character renders that character's cell and does not reload -the table. +A cell takes a lens, not a model. The lens is the fragment's generated +struct, handed down by the controller the way a parent view hands one to +a child. The list's content reads the rows' ids and lenses, not their +fields. A commit that renames one character renders that character's cell, +and does not reload the table. ```swift @MainActor @@ -187,23 +188,23 @@ public final class CharacterCell: UITableViewCell { } ``` -The lens is a value over the store; it reads synchronously and costs -nothing to hold, so a bound cell renders in the call. The cell cancels its -observation on reuse so a recycled cell does not render the row it left. -The AppKit cell is an `NSTableCellView` with the same `bind` and +The lens is a value over the store that reads synchronously and costs +nothing to hold, so a bound cell renders in the call. The cell cancels +its observation on reuse, so a recycled cell does not render the row it +left. The AppKit cell is an `NSTableCellView` with the same `bind` and `prepareForReuse`, rendering into two labels. ## The app's lifecycle -`environment.isActive` parks the retained subscriptions while it is false -and resumes them when it is true again; `environment.revalidate()` refetches -the retained operations that went stale or failed. Each platform says when -from its own notifications, and the app holds one `Activation` for as long -as the environment lives. +`environment.isActive` parks the retained subscriptions while it is false, +and resumes them when it is true again. `environment.revalidate()` +refetches the retained operations that went stale or failed. Each +platform says when, from its own notifications. The app holds one +`Activation` for as long as the environment lives. -On iOS the application's notifications, not a scene's: the environment is -the app's, and one scene leaving the screen while another stays does not -make the app inactive. +On iOS, use the application's notifications, not a scene's. The +environment is the app's, so one scene leaving the screen while another +stays does not make the app inactive. ```swift observers = [ @@ -220,7 +221,8 @@ observers = [ ``` On macOS a window stays on screen while another app is frontmost, so -resigning active parks nothing: hiding does, and returning revalidates. +resigning active parks nothing. Hiding parks the subscriptions, and +returning revalidates. ```swift observers = [ @@ -236,11 +238,11 @@ observers = [ ] ``` -Writes are the environment's whatever hosts them: +Writes are the environment's, whatever hosts them. Call `try await environment.mutate(SetFavorite(id: id, favorite: true), optimistic: ...)` from an action, and the optimistic layer shows in the turn of the call. -Nothing in the runtime imports UIKit or AppKit; `Observations` and the +Nothing in the runtime imports UIKit or AppKit: `Observations` and the handles are Foundation and Observation. The Kotlin wiring, an Android app's process lifecycle told to the diff --git a/docs/recipes/views.md b/docs/recipes/views.md index b1100e39..78d6cec4 100644 --- a/docs/recipes/views.md +++ b/docs/recipes/views.md @@ -1,17 +1,18 @@ # Views and view models: a handle without a composition -Baton's Kotlin reads are synchronous on the store's thread and its handles -are snapshot state, so a screen with no composition, a `ViewModel` behind -Android Views, a desktop window, a service, uses them without Compose UI: -it asks the environment for the handle, holds the retention that keeps -the handle's data alive, and reads what it shows through `snapshotFlow`, -which emits again when a field it read changed. Nothing here is a second -API; it is the same handle `rememberQuery` resolves. This is the Kotlin -twin of [derived state outside views](derived-state.md), where Swift -reads through `Observations`. +Baton's Kotlin reads are synchronous on the store's thread, and its +handles are snapshot state. A screen with no composition uses them +without Compose UI: a `ViewModel` behind Android Views, a desktop window, +a service. You ask the environment for the handle, hold the retention that +keeps the handle's data alive, and read what the screen shows through +`snapshotFlow`. The flow emits again when a field it read changed. Nothing +here is a second API: it is the same handle `rememberQuery` resolves. +This is the Kotlin twin of [derived state outside views](derived-state.md), +where Swift reads through `Observations`. The code below is compiled in this repository, as `ViewsTests` under -`kotlin/baton/src/jvmTest`, which proves what this page says of it. +`kotlin/baton/src/jvmTest`, and that test proves what this page says of +it. ## The model @@ -55,8 +56,8 @@ class CharactersModel(environment: Environment) { } ``` -The flow is collected on the main dispatcher, where the store is read, and -each screen is rendered as it comes: +Collect the flow on the main dispatcher, where the store is read, and +render each screen as it comes: ```kotlin scope.launch(Dispatchers.Main.immediate) { @@ -71,39 +72,40 @@ scope.launch(Dispatchers.Main.immediate) { ``` A lens read inside the block registers the fields it reads, as a read in -a composition does, so the flow emits when one of them changes and stays -quiet when nothing it read did: a response that changes a field no row +a composition does. The flow emits when one of them changes, and stays +quiet when nothing it read did. A response that changes a field no row shows emits nothing. ## What sends the change Compose UI's frame clock sends the snapshot system's apply notifications, -which is what `snapshotFlow` listens for. Without a composition nothing -would, so the environment sends them itself: it observes the writes the -runtime makes, a commit's cells, a handle's fetch, a root's date, and -posts one send behind each burst of them on its main dispatcher. With -Compose UI present the frame clock sends the same notifications, and -whichever comes second finds nothing left to send. +which is what `snapshotFlow` listens for. Without a composition, nothing +would, so the environment sends them itself. It observes the writes the +runtime makes (a commit's cells, a handle's fetch, a root's date) and +posts one send behind each burst of them, on its main dispatcher. With +Compose UI present, the frame clock sends the same notifications. +Whichever comes second finds nothing left to send. ## Release -The hold is the retention: the handle's data stays in the store while the -model holds it, and is collected after the model releases it, as the -composable's leaving releases `rememberQuery`'s. A `ViewModel` releases it -in `onCleared`; a window when it closes. +The hold is the retention. The handle's data stays in the store while the +model holds it, and is collected after the model releases it, as leaving +the composable releases `rememberQuery`'s. A `ViewModel` releases the +hold in `onCleared`, and a window releases it when the window closes. ## The app's lifecycle -`environment.isActive` parks the retained subscriptions while it is false -and resumes them when it is true again; `environment.revalidate()` refetches -the retained operations that went stale or failed. The app says when, from -its own lifecycle, and holds the observer for as long as the environment -lives. On Android the lifecycle is the process's, `ProcessLifecycleOwner` -from `androidx.lifecycle:lifecycle-process`, not an activity's: the -environment is the app's, and an activity recreated for a rotation does -not make the app inactive. The code below is `Activation.kt` in +`environment.isActive` parks the retained subscriptions while it is false, +and resumes them when it is true again. `environment.revalidate()` +refetches the retained operations that went stale or failed. The app says +when, from its own lifecycle, and holds the observer for as long as the +environment lives. On Android the lifecycle is the process's, +`ProcessLifecycleOwner` from `androidx.lifecycle:lifecycle-process`, not +an activity's. The environment is the app's, so an activity recreated +for a rotation does not make the app inactive. The code below is +`Activation.kt` in [`kotlin/samples/android`](../../kotlin/samples/android/src/main/kotlin/baton/sample/android/Activation.kt), -compiled with the sample; its view model makes one beside the environment +compiled with the sample. Its view model makes one beside the environment and closes it in `onCleared`, before the environment ends. ```kotlin @@ -132,15 +134,16 @@ class Activation( ``` The observer is called on the main thread, where the store lives, so the -writes are made where the environment's synchronous calls are. `ON_STOP` +writes land where the environment's synchronous calls do. `ON_STOP` arrives once the last activity has stopped, after the short delay the -lifecycle library keeps so a configuration change sends nothing, and -`ON_START` with the first activity; adding the observer to a started -lifecycle delivers `ON_START` at once, and a revalidation with nothing -retained does nothing. The runtime depends on no lifecycle library; the -wiring is the app's, as `examples/Controllers` is on iOS and macOS +lifecycle library keeps so a configuration change sends nothing. +`ON_START` arrives with the first activity, and adding the observer to a +started lifecycle delivers `ON_START` at once. A revalidation with nothing +retained does nothing. The runtime depends on no lifecycle library, and +the wiring is the app's, as `examples/Controllers` is on iOS and macOS ([`uikit.md`](uikit.md#the-apps-lifecycle)). -On the desktop a window stays on screen while another app is frontmost, so -the desktop samples park nothing; an app that wants a revalidation when a -window regains focus calls `revalidate()` from the window's focus listener. +On the desktop, a window stays on screen while another app is frontmost, +so the desktop samples park nothing. An app that wants a revalidation +when a window regains focus calls `revalidate()` from the window's focus +listener. diff --git a/docs/vision.md b/docs/vision.md index b3a01fd4..0c58de28 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -1,69 +1,71 @@ # Vision -Baton is Relay for native apps. A view declares the fragment it reads beside -its body; the compiler aggregates a screen's fragments into one operation and -checks every field against the schema before the app runs; the runtime -normalizes responses into records that the UI framework itself observes. -A screen fetches once, renders cached data in the first frame, and re-renders -only the views whose fields changed. - -The goal is not the biggest feature list; it is the smallest set of concepts -that makes the data layer disappear from product code. Better a few strict -rules than lots of features: that is how a screen stays one request, a view -stays one fragment, and a change stays one re-render. +Baton is Relay for native apps. You declare the fields a view reads next to +its body, and that piece of GraphQL is a fragment. The compiler gathers a +screen's fragments into one operation and checks every field against the +schema before the app runs. The runtime turns responses into records that +the UI framework itself observes, so a screen fetches once, renders cached +data in the first frame, and re-renders only the views whose fields changed. + +The goal is not the biggest feature list. It is the smallest set of concepts +that makes the data layer disappear from product code. A few strict rules +beat lots of features. That is how a screen stays one request, a view stays +one fragment, and a change stays one re-render. That means three commitments: -- **The view never sees the data layer.** No query files to keep in sync, no - cache-update code, no view model copying fields around. A fragment next to +- **The view never sees the data layer.** No query files to keep in sync. No + cache-update code. No view model copying fields around. A fragment next to a body, and the data is there. -- **Every claim can be checked.** Reading a field through a lens equals - reading the raw response at the same path; every advertised number comes - from a bench recorded with device, OS and date; a view that re-rendered did - so because a field it read changed, and a test can count it. +- **Every claim can be checked.** A lens is the typed view a fragment + compiles to. Reading a field through a lens equals reading the raw + response at the same path. Every advertised number comes from a bench + recorded with device, OS and date. A view that re-rendered did so because + a field it read changed, and a test can count it. - **Relay's language, native's mechanisms.** Directives, conventions and the compiler's front end are Relay's, so web and native teams speak one language. The parts of Relay that exist because React could not see who read what are replaced by what SwiftUI and Compose can see. Relay built a runtime to answer one question: which component read which -field, so that a change re-renders exactly those. SwiftUI's Observation and -Compose's snapshot state answer it natively. Baton therefore keeps Relay's -compiler and the write side of its store, and deletes the read side rather -than porting it. - -- The compiler finds the GraphQL in source, validates it against the schema, - aggregates fragments into operations, applies fragment arguments, computes - every storage key, hashes persisted ids, and emits a typed lens per - fragment and a normalization plan per operation. Errors point at the - GraphQL text inside the Swift file. -- The transport can send a persisted id and variables, and streams bytes +field, so a change re-renders exactly those. SwiftUI's Observation and +Compose's snapshot state answer that question already. Baton therefore +keeps Relay's compiler and the write side of its store, and deletes the +read side rather than porting it. + +- The compiler finds the GraphQL in source and validates it against the + schema. It aggregates fragments into operations, applies fragment + arguments, computes every storage key, hashes persisted ids, and emits a + typed lens per fragment and a normalization plan per operation. Errors + point at the GraphQL text inside the Swift file. +- The transport can send a persisted id and variables, and it streams bytes back. - The ingest decodes a response's bytes straight into record slots, off the main actor, with no intermediate model. - The store commits a change set on the main actor: writes for the slots - that changed, notifications for the fields that observed views read. -- A lens reads a record's slots synchronously and registers each read with - the framework; a view body is invalidated by the fields it read and by + that changed, and notifications for the fields that observed views read. +- A lens reads a record's slots synchronously, and registers each read with + the framework. A view body is invalidated by the fields it read, and by nothing else. -- Persistence is a write-behind image hydrated before the first body; an +- Persistence is a write-behind image, hydrated before the first body. An entry that cannot be read is a miss, never a migration. -Adoption may follow; it is never chased. No parity race with Apollo, no +Adoption may follow. It is never chased: no parity race with Apollo, no feature bazaar, no runtime knobs. ## Who it is for Teams that already run Relay on the web and want the same model on iOS and -Android first: the schema conventions, the directives and the vocabulary -carry over, and the native screens gain what the web ones have. +Android come first. The schema conventions, the directives and the +vocabulary carry over, and the native screens gain what the web ones have. -Native teams on GraphQL who are tired of loading flashes, whole-screen -re-renders, hand-maintained queries and caches that grow without bound -second. Baton's answers to those complaints are mechanisms, not settings. +Native teams on GraphQL come second. They are tired of loading flashes, +whole-screen re-renders, hand-maintained queries and caches that grow +without bound. Baton's answers to those complaints are mechanisms, not +settings. Swift first, Kotlin later. The compiler, the artifact format, the vocabulary -and the conformance fixtures are shared; each runtime is written for its +and the conformance fixtures are shared. Each runtime is written for its platform and feels like it. ## The rules @@ -71,41 +73,42 @@ platform and feels like it. Five rules, one axis each: what a fragment means, what the UI reads, what is true, who decides, and what the words are. -1. **A fragment is a lens.** A fragment compiles to a typed, read-only view - over one record: a reference and one accessor per declared field. Nothing - is decoded into a model to hand it to a view; a parent passes a reference - and a context. - Masking is not enforced, it is structural: the lens has no accessor for a - field the fragment did not declare. +1. **A fragment is a lens.** A fragment compiles to a lens, a typed + read-only view over one record: a reference and one accessor per field + you declared. Nothing is decoded into a model to hand to a view. A + parent passes a reference and a context. Masking is structural rather + than enforced, because the lens has no accessor for a field the fragment + did not declare. 2. **The store is the UI's state.** Records are observable objects. A body - that read a field is invalidated when that field of that record changes - and at no other time. Reads are synchronous on the main actor; commits are - atomic batches on it, and the availability check, which reads the image - when memory lacks a record, collection, and the normalization of an - optimistic response, whose layer shows in the turn of the call, run - there too; decoding and normalizing a response and the image's writes - run off it. There is no asynchronous read path for views. -3. **The response is the oracle.** Whatever path a value takes through - ingest, interned slots, optimistic overlays and persisted images, reading - it through a lens equals reading the raw response at the same path. + that read a field is invalidated when that field of that record changes, + and at no other time. Reads are synchronous on the main actor, and + commits are atomic batches there. The availability check runs there too, + and reads the image when memory lacks a record. Collection runs there, + and so does the normalization of an optimistic response, whose layer + shows in the turn of the call. Decoding a response and writing the image + run off the main actor. There is no asynchronous read path for views. +3. **The response is the oracle.** Whatever path a value takes, through + ingest, interned slots, optimistic overlays and persisted images, + reading it through a lens equals reading the raw response at the same + path. Anything faster has to reproduce the oracle exactly. Missing data is reported and healed, never invented silently. -4. **The compiler decides; the runtime executes.** Every operation is static. - Identity, aggregation, masking, storage keys, persisted ids and - diagnostics are settled at build time. The runtime interprets plans; it - never parses GraphQL or consults a policy object, and no path a view or - a commit takes hashes a field name: the compiler emits a constant per - storage key and the process numbers it once +4. **The compiler decides; the runtime executes.** Every operation is + static. Identity, aggregation, masking, storage keys, persisted ids and + diagnostics are settled at build time. The runtime interprets plans and + never parses GraphQL or consults a policy object. No path a view or a + commit takes hashes a field name. The compiler emits a constant per + storage key, and the process numbers it once ([decision](decisions/slots-are-numbered-by-the-process.md)). -5. **Relay's words, a closed set.** The vocabulary is Relay's and the GraphQL - specification's wherever they have a word, and the concept inventory is - closed: a feature is a composition of existing concepts or a directive the - compiler understands, or it does not ship. +5. **Relay's words, a closed set.** The vocabulary is Relay's and the + GraphQL specification's wherever they have a word. The concept inventory + is closed. A feature is a composition of existing concepts, or a + directive the compiler understands, or it does not ship. ## Principles -Constraints the vision names without arguing. One file per principle; the -type names in each "Spelled today" section may rot, the rest must not. +Constraints the vision names without arguing. One file per principle. The +type names in each "Spelled today" section may rot. The rest must not. - [A fragment is a lens](principles/fragment-is-a-lens.md) - [The store is the UI's state](principles/store-is-the-ui-state.md) @@ -118,9 +121,9 @@ type names in each "Spelled today" section may rot, the rest must not. ## Decisions -Choices made among real alternatives, with their evidence and the condition -that would reopen them, live in [decisions/](decisions/). They may be -superseded; principles may not. +Choices made among real alternatives live in [decisions/](decisions/). Each +one has its evidence, and the condition that would reopen it. Decisions may +be superseded. Principles may not. ## Openings diff --git a/docs/why-graphql.md b/docs/why-graphql.md index d4163ebd..197ceceb 100644 --- a/docs/why-graphql.md +++ b/docs/why-graphql.md @@ -1,21 +1,22 @@ # Why GraphQL -For iOS and Android engineers who build on REST and view models. The words -are defined in [`terminology.md`](terminology.md). +You build iOS or Android screens on REST and view models. This is what +GraphQL changes, what it still leaves you, and what it costs. The words are +defined in [`terminology.md`](terminology.md). ## The problem -Take an issue screen: the title, the author's name and avatar, and the -comments, each with its author. On REST it takes: +Take an issue screen. You want the title, the author's name and avatar, and +the comments, each with its author. On REST that takes: 1. `GET /issues/42`, then, from its answer, `GET /users/7` and - `GET /issues/42/comments`, then the users of the comments. Round trips - in sequence, each waiting on the last, and each response carrying fields - the screen never shows. -2. Model structs for each response, decoders, and a view model that calls - the endpoints, merges the answers and copies the fields each view needs - into its own properties. -3. A cache keyed by URL, so the issue the list screen fetched and the issue + `GET /issues/42/comments`, then the users of the comments. The round + trips run in sequence. Each waits on the last. Each response carries + fields the screen never shows. +2. Model structs for each response, decoders, and a view model. The view + model calls the endpoints, merges the answers, and copies the fields + each view needs into its own properties. +3. A cache keyed by URL. The issue the list screen fetched and the issue the detail screen fetched are two copies. Star it on one and the other is wrong until someone writes the code that updates it. 4. A contract the compiler never sees. A field the server renamed is a @@ -25,9 +26,9 @@ None of this is the screen. It is plumbing, and it is most of the code. ## What GraphQL solves -The server publishes a **schema**: every type, every field, which are -nullable. The client sends one document naming the fields it wants, nested -as deep as it goes, and gets exactly that shape back: +The server publishes a **schema**: every type, every field, and which of +them are nullable. You send one document naming the fields you want, nested +as deep as the screen goes. You get exactly that shape back: ```graphql query IssueQuery($id: ID!) { @@ -41,40 +42,40 @@ query IssueQuery($id: ID!) { } ``` -One round trip, nothing extra in it (problem 1). The document is checked -against the schema at build time, so a misspelled or removed field is a -build error (problem 4). Objects carry an `id`, so a client can store each -object once, by type and id, rather than once per request. +One round trip, and nothing extra in it (problem 1). The document is checked +against the schema at build time. A misspelled or removed field is a build +error (problem 4). Objects carry an `id`, so a client can store each object +once, by type and id, rather than once per request. -GraphQL is the API layer, where REST is today; it is not a database -language. The server resolves each field however it likes, and the request -is still HTTP and JSON. +GraphQL is the API layer, where REST is today. It is not a database +language. The server resolves each field however it likes. The request is +still HTTP and JSON. ## What GraphQL alone does not solve -A query per screen moves the plumbing, it does not remove it. The screen's -query lists the fields of every view on it, so a row that starts showing a -date means editing a query in another file. The response is decoded into -a model tree, a view model still copies from it, and the cache still needs +A query per screen moves the plumbing. It does not remove it. The screen's +query lists the fields of every view on it. A row that starts showing a +date means editing a query in another file. The response is decoded into a +model tree. A view model still copies from it. The cache still needs someone to keep the list and the detail in agreement (problems 2 and 3). -That is how most native GraphQL clients work, and why GraphQL on mobile +That is how most native GraphQL clients work. It is why GraphQL on mobile often feels like more work than REST. ## What Relay solves -Relay puts the data a view reads beside the view: +Relay puts the data a view reads beside the view. -- **A fragment per view.** Each view declares the fields it reads, next to - its body. The compiler assembles the screen's one query from the +- **A fragment per view.** A fragment is the fields one view reads, written + next to its body. The compiler assembles the screen's one query from the fragments of its views. A row that shows a date adds `createdAt` to its - own fragment, and nothing else changes. + own fragment. Nothing else changes. - **A view reads only what it declared.** The parent hands the child a - reference, not data; the child reads its own fields through it. + reference, not data. The child reads its own fields through it. - **One store.** Every response is normalized into records by type and id. The list and the detail read the same record, so a change shows in both. -- **Declarative writes.** A mutation selects what it changed, names the - list it adds to with a directive, and may carry an optimistic response - that shows at once and reverts if the server refuses it. No code edits +- **Declarative writes.** A mutation selects what it changed and names the + list it adds to with a directive. It may carry an optimistic response, + which shows at once and reverts if the server refuses. No code edits the cache. The view model's data work, the decoding and the cache updates are gone @@ -83,7 +84,7 @@ The view model's data work, the decoding and the cache updates are gone ## What Baton adds Relay is for React. Baton brings it to SwiftUI and Compose, with the same -compiler front end and the same directives: +compiler front end and the same directives. ```swift struct CommentRow: View { @@ -102,20 +103,21 @@ struct CommentRow: View { } ``` -The fragment compiles to a typed [lens](terminology.md#generated) over -the store's records. SwiftUI's Observation and Compose's snapshot state +The fragment compiles to a typed [lens](terminology.md#generated) over the +store's records, a window onto one record with one accessor per field the +fragment declared. SwiftUI's Observation and Compose's snapshot state observe the records directly, so a view re-renders when a field it read -changes and at no other time, and a screen whose data is already in the -store renders it in the first frame. No GraphQL is parsed on the device. -The [README](../README.md) has the Compose spelling and the numbers. +changes and at no other time. A screen whose data is already in the store +renders it in the first frame, and no GraphQL is parsed on the device. The +[README](../README.md) has the Compose spelling and the numbers. ## What stays yours Navigation, view controllers, presentation and lifecycle are the -platform's, as before; a UIKit or AppKit controller holds the same handle a +platform's, as before. A UIKit or AppKit controller holds the same handle a view does ([UIKit and AppKit](recipes/uikit.md)). State that belongs to the -user's interaction, a draft, a selection, an open sheet, stays in the view -or the app's own model. Authentication and retries wrap the one +user's interaction stays in the view, or in the app's own model: a draft, a +selection, an open sheet. Authentication and retries wrap the one [transport](terminology.md#runtime) function ([the exchange](recipes/exchange.md)). @@ -124,12 +126,12 @@ or the app's own model. Authentication and retries wrap the one - **A GraphQL server.** If the backend is REST, a GraphQL layer stands in front of it, and someone owns it. Any server that follows the specification works ([your server](../README.md#works-with-your-server)). -- **A schema in the build.** The compiler reads the schema's SDL; a change +- **A schema in the build.** The compiler reads the schema's SDL. A change to it is a change the build sees. - **Server discipline.** Clients choose the shape, so the server guards against expensive selections. Persisted operations narrow that to the documents the apps were built with ([persisted id](terminology.md#compiler)). -- **New habits.** A fragment per view and no copied state. +- **New habits.** A fragment per view, and no copied state. [`recipes/agents.md`](recipes/agents.md) states the shape of a screen in one page. diff --git a/site/content/graphql.md b/site/content/graphql.md index 77bff5ce..3bd2601d 100644 --- a/site/content/graphql.md +++ b/site/content/graphql.md @@ -1,87 +1,87 @@ # GraphQL for mobile engineers -You have built screens on REST: an endpoint per resource, a model struct per response, a view model that merges three calls into what the screen shows. This page is GraphQL explained from there, with a real API, a real response and the real records a store made of it. Every word that matters is in [the terminology](../terminology/); this page is the tour. +You have built screens on REST: an endpoint per resource, a model struct per response, a view model that merges three calls into what the screen shows. This page is GraphQL explained from there. It uses a real API, a real response, and the real records a store made of it. Every word that matters is in [the terminology](../terminology/). This page is the tour. ## The menu -A GraphQL server publishes a **schema**: every type, every field, and whether each field can be null. Think of it as the whole menu, written down once. Here is the whole schema of the public Rick and Morty API, which this site uses because it fits on one screen: +A GraphQL server publishes a **schema**: every type, every field, and whether each field can be null. Think of it as the whole menu, written down once. Here is the whole schema of the public Rick and Morty API. This site uses it because it fits on one screen. -{{source spec/rickandmorty/schema.graphql | The schema, as checked into the repository. `Query` is the root: the fields you can ask for first. `Character`, `Location` and `Episode` are the things; `[Episode]!` is a list that is never null, of episodes that may be.}} +{{source spec/rickandmorty/schema.graphql | The schema, as checked into the repository. `Query` is the root, the fields you can ask for first. `Character`, `Location`, and `Episode` are the things. `[Episode]!` is a list that is never null, of episodes that may be.}} -Read it the way you read a REST API's documentation, except that it is machine-checkable: a tool can tell you, before your app runs, that `Character` has no field called `nmae`. +Read it the way you read a REST API's documentation. The difference is that this one is machine-checkable. A tool can tell you, before your app runs, that `Character` has no field called `nmae`. ## The order -A **document** is what you send: a selection of fields, nested as deep as you like. The server answers with exactly that shape and nothing else, in one round trip. This is a **query**, the read kind of **operation**, with one **variable**: +A **document** is what you send: a selection of fields, nested as deep as you like. The server answers with exactly that shape and nothing else, in one round trip. This one is a **query**, the read kind of **operation**, with one **variable**, a value you pass in when you send it. {{source spec/sources/TestHeaderQuery.graphql | A query with a variable, `$id`, and a fragment spread, `...TestHeader_character`. The fragment is below.}} -{{source spec/sources/TestHeader_character.graphql | A fragment: a named piece of a selection on one type. Think of it as the fields one view needs, with a name. The query above spreads it, and the text the server receives carries the fragment after the operation, as the compiler prints it.}} +{{source spec/sources/TestHeader_character.graphql | A fragment is a named piece of a selection on one type. Think of it as the fields one view needs, with a name. The query above spreads it. The text the server receives carries the fragment after the operation, as the compiler prints it.}} -The response is the order, filled. Its keys are the document's field names, its nesting is the document's nesting, and there is nothing in it you did not ask for: +The response is the order, filled. Its keys are the document's field names. Its nesting is the document's nesting. There is nothing in it you did not ask for. -{{json spec/rickandmorty/character-header-9.json | The response, recorded from the API for `id: "9"`. The `id` fields, on the character and under `origin`, are there because the compiler asked for them; the next section says why.}} +{{json spec/rickandmorty/character-header-9.json | The response, recorded from the API for `id: "9"`. The `id` fields, on the character and under `origin`, are there because the compiler asked for them. The next section says why.}} Three things to notice, because they change how you build: -- **No over-fetching.** The REST endpoint for a character returns every field the character has, a dozen; this response has the five the view reads. A screen fetches what its views render. -- **No waterfalls.** Ask for the origin's dimension as well and it comes in the same round trip as the character. On REST the character embeds only its origin's name and link, and the dimension is a second call after the first answered. -- **The contract is in the build.** The document is checked against the schema when the app is compiled. A field the server renames is a build error at the character in the text, not a decoding failure in production. +- **No over-fetching.** The REST endpoint for a character returns every field the character has, a dozen. This response has the five the view reads. A screen fetches what its views render. +- **No waterfalls.** Ask for the origin's dimension as well, and it comes in the same round trip as the character. On REST the character embeds only its origin's name and link. The dimension is a second call, after the first answered. +- **The contract is in the build.** The document is checked against the schema when the app is compiled. A field the server renames is a build error at the character in the text. It is not a decoding failure in production. ## Null is a word in the schema -Look at the schema again: `name: String` may be null, `episode: [Episode]!` may not. That is a promise the server makes per field, and a client honours it in the types: a nullable field is an optional, a non-null field is not. You stop writing `?? ""` where the schema says a value is always there, and you stop pretending a value is always there where it is not. The Rick and Morty API marks nearly everything nullable, which is why the row views on this site read `character.name ?? "Unknown"`. GitHub's marks `title` and `number` non-null, so a row over GitHub reads them straight. +Look at the schema again. `name: String` may be null, and `episode: [Episode]!` may not. That is a promise the server makes per field, and a client honours it in the types: a nullable field is an optional, and a non-null field is not. You stop writing `?? ""` where the schema says a value is always there, and you stop pretending a value is always there where it is not. The Rick and Morty API marks nearly everything nullable, which is why the row views on this site read `character.name ?? "Unknown"`. GitHub marks `title` and `number` non-null, so a row over GitHub reads them straight. -Relay adds two directives for the cases the types cannot express on their own, and Baton keeps both: `@required` says a view cannot do without a field, and moves the null up to where the product wants to handle it; `@catch` turns a field's error into a value the view can show. [Honest data](../guide/honest-data/) is the chapter. +Relay adds two directives for the cases the types cannot express on their own, and Baton keeps both. `@required` says a view cannot do without a field, and moves the null up to where the product wants to handle it. `@catch` turns a field's error into a value the view can show. [Honest data](../guide/honest-data/) is the chapter. ## The id, and why one store -Every object in a good schema carries an `id`. It is the reason a client can keep one copy of each thing: the Rick who appears in the characters list, in the cast of an episode and among the residents of a location is one `Character:1`, whatever path reached him. A client that stores responses by URL keeps three copies and shows a stale star on two of them; a client that stores records by type and id keeps one. +Every object in a good schema carries an `id`, which is why a client can keep one copy of each thing. The Rick who appears in the characters list, in the cast of an episode, and among the residents of a location is one `Character:1`, whatever path reached him. A client that stores responses by URL keeps three copies and shows a stale star on two of them. A client that stores records by type and id keeps one. -This is **normalization**, and it is the single idea that makes the data layer disappear. Hover a field in the response below and watch where it went: +This is **normalization**: one record per id, instead of one copy per response. That single idea is what makes the data layer disappear. Hover a field in the response below and watch where it went. -{{normalize spec/rickandmorty/character-header-9 | The response on the left became the records on the right: `Character:9`, `Location:20`, and the root record that holds the link `character(id:"9")`. A nested object with an id became its own record, referenced by a link; a field without arguments is stored under its name, a field with arguments under its name and its arguments.}} +{{normalize spec/rickandmorty/character-header-9 | The response on the left became the records on the right: `Character:9`, `Location:20`, and the root record that holds the link `character(id:"9")`. A nested object with an id became its own record, referenced by a link. A field without arguments is stored under its name. A field with arguments is stored under its name and its arguments.}} -Nothing here was typed by hand: the records are the dump the runtime wrote under the specification's fixtures, and the walk that links a path to its slot follows the links in the dump. A response that arrives later for the same character, from any screen, lands in the same `Character:9`, and every view reading it sees the change. +Nothing here was typed by hand. The records are the dump the runtime wrote under the specification's fixtures, and the walk that links a path to its slot follows the links in the dump. A slot is one field of one record. A response that arrives later for the same character, from any screen, lands in the same `Character:9`, and every view reading it sees the change. ## Lists that page -A list that grows as you scroll is a **connection**, a convention from Relay that most schemas follow: `edges` holding `node`s with a `cursor` each, `pageInfo` with `hasNextPage` and `endCursor`, and arguments `first` and `after` that ask for the next page from a cursor. It looks heavier than an array, and it is, because it carries exactly what paging needs: +A list that grows as you scroll is a **connection**, a convention from Relay that most schemas follow: `edges` holding `node`s with a `cursor` each, `pageInfo` with `hasNextPage` and `endCursor`, and arguments `first` and `after` that ask for the next page from a cursor. It looks heavier than an array, and it is, because it carries exactly what paging needs. -{{source spec/sources/TestNotes_character.graphql | A fragment that owns a connection. `@connection(key:)` names the list in the store; `@refetchable` gives the fragment a query of its own to fetch the next page with; `@argumentDefinitions` declares the page size and the cursor as the fragment's own arguments.}} +{{source spec/sources/TestNotes_character.graphql | A fragment that owns a connection. `@connection(key:)` names the list in the store. `@refetchable` gives the fragment a query of its own to fetch the next page with. `@argumentDefinitions` declares the page size and the cursor as the fragment's own arguments.}} -The payoff is in [Lists](../guide/lists/): the store merges each page into one list, and a view reads `nodes`, `hasNext` and calls `loadNext`. The Rick and Morty API itself pages by number, which is un-Relay-like and the reason the GitHub samples carry the connection examples. +The payoff is in [Lists](../guide/lists/). The store merges each page into one list. A view reads `nodes` and `hasNext`, and calls `loadNext`. The Rick and Morty API itself pages by number. That is un-Relay-like, and it is why the GitHub samples carry the connection examples. ## Writes -A **mutation** is an operation that changes something and then selects what it changed, like a `POST` whose response you get to shape: +A **mutation** is an operation that changes something and then selects what it changed, like a `POST` whose response you get to shape. -{{source spec/sources/TestAddNote.graphql | A mutation with its variables, selecting the new edge from the payload. What a mutation changes is what its payload selects; the store merges it like any response.}} +{{source spec/sources/TestAddNote.graphql | A mutation with its variables, selecting the new edge from the payload. What a mutation changes is what its payload selects. The store merges it like any response.}} -Because the payload is a selection like any other, the records it names are updated in place and every view reading them re-renders. Adding to a list is a directive on the payload, `@appendEdge`, rather than code that edits a cache. And a mutation can carry an **optimistic response**, a typed value shown at once and replaced by the server's answer or reverted if the server refuses. [Writes](../guide/writes/) steps through one. +The payload is a selection like any other. The records it names are updated in place, and every view reading them re-renders. Adding to a list is a directive on the payload, `@appendEdge`, rather than code that edits a cache. A mutation can also carry an **optimistic response**, a typed value shown at once. The server's answer replaces it, or a refusal reverts it. [Writes](../guide/writes/) steps through one. ## Subscriptions -A **subscription** is the third kind of operation: a stream of events from the server, each one a selection in the same shape as a query's response. In Baton each event is committed to the store like any payload, so a view that reads the record it names updates, and a directive on the event can append it to a connection. [Live data](../guide/live/) covers the stream, reconnecting, and what happens while the app is in the background. +A **subscription** is the third kind of operation: a stream of events from the server, each one a selection in the same shape as a query's response. Baton commits each event to the store like any other payload, so a view that reads the record it names updates, and a directive on the event can append it to a connection. [Live data](../guide/live/) covers the stream, reconnecting, and what happens while the app is in the background. ## Errors are data too -A GraphQL response can carry both `data` and `errors`, and an error has a `path` that says which field failed. A client that swallows that into a `null` shows an empty label and tells no one. Baton stores a field's error beside the field, so a cached read sees the same error the network read saw, and `@catch` lets a view decide what to show. A response with errors and no data at all is a failure of the whole fetch: with nothing in the store the phase reads failed, with a retry; with data already there, the data stays and the fetch carries the failure. +A GraphQL response can carry both `data` and `errors`. An error has a `path` that says which field failed. A client that swallows that into a `null` shows an empty label and tells no one. Baton stores a field's error beside the field. A cached read sees the same error the network read saw. `@catch` lets a view decide what to show. A response with errors and no data at all is a failure of the whole fetch. With nothing in the store, the phase reads failed, with a retry. With data already there, the data stays and the fetch carries the failure. ## What Relay adds, and what Baton keeps -GraphQL alone moves the plumbing; it does not remove it. A query per screen still has to be written and kept in sync with every view on it, and the response still has to be decoded and copied into view models. Relay's answer, which Baton brings to SwiftUI and Compose, is: +GraphQL alone moves the plumbing. It does not remove it. A query per screen still has to be written, and kept in sync with every view on it. The response still has to be decoded and copied into view models. Relay's answer, which Baton brings to SwiftUI and Compose, is this. -- **A fragment per view, beside the view.** Each view declares the fields it reads, next to its body. The compiler assembles the screen's one query from the fragments of its views. A row that starts showing a date adds `createdAt` to its own fragment, and nothing else changes. -- **A view reads only what it declared.** The parent hands the child a lens, not data. The child reads its own fields through it and cannot reach the parent's. -- **One store.** Every response is normalized into records by type and id, and the UI framework observes the records directly: a view re-renders when a field it read changes, and at no other time. +- **A fragment per view, beside the view.** Each view declares the fields it reads, next to its body. The compiler assembles the screen's one query from the fragments of its views. A row that starts showing a date adds `createdAt` to its own fragment. Nothing else changes. +- **A view reads only what it declared.** The parent hands the child a lens, not data. A lens is a typed window onto one record. The child reads its own fields through it and cannot reach the parent's. +- **One store.** Every response is normalized into records by type and id. The UI framework observes the records directly. A view re-renders when a field it read changes, and at no other time. - **Declarative writes.** A mutation selects what it changed, names the list it adds to with a directive, and may carry an optimistic response. No code edits the cache. -[Why GraphQL](../why-graphql/) is the same argument in the repository's words, with the view model's fate spelled out, and [One screen, end to end](../walkthrough/) follows a screen through all of it. +[Why GraphQL](../why-graphql/) is the same argument in the repository's words, with the view model's fate spelled out. [One screen, end to end](../walkthrough/) follows a screen through all of it. ## What it costs - **A GraphQL server.** If the backend is REST, a GraphQL layer stands in front of it, and someone owns it. Any server that follows the specification works, with no plugin for Baton. -- **A schema in the build.** The compiler reads the schema's SDL from a file you check in; a change to it is a change the build sees. +- **A schema in the build.** The compiler reads the schema's SDL, the schema written out as text, from a file you check in. A change to the schema is a change the build sees. - **Server discipline.** Clients choose the shape, so the server guards against expensive selections. Persisted operations narrow the requests to the documents the apps were built with. - **New habits.** A fragment per view, and no copied state. [Getting started](../start/) builds the first screen that way. diff --git a/site/content/guide/compiler.md b/site/content/guide/compiler.md index 533cff2c..e6a990c3 100644 --- a/site/content/guide/compiler.md +++ b/site/content/guide/compiler.md @@ -1,10 +1,10 @@ # The compiler -`batonc` finds the GraphQL in Swift and Kotlin sources and `.graphql` files, validates every document against the schema, assembles the operations, and writes the lenses and the plans. It decides at build time everything a runtime client decides at run time: identity, aggregation, masking, storage keys, persisted ids, nullability, diagnostics. The runtime interprets what it wrote and never parses GraphQL. +`batonc` finds the GraphQL in Swift and Kotlin sources, and in `.graphql` files. It validates every document against the schema, assembles the operations, and writes the lenses and the plans. At build time it decides everything a runtime client decides at run time: identity, aggregation, masking, storage keys, persisted ids, nullability, diagnostics. The runtime interprets what `batonc` wrote and never parses GraphQL. ## What it reads -A target's configuration is `baton.json`, beside the target or at the package root: +A target's configuration is `baton.json`, beside the target or at the package root. | key | what | |---|---| @@ -18,43 +18,43 @@ A target's configuration is `baton.json`, beside the target or at the package ro | `onError` | the error behaviour sent with every operation | | `kotlin.package` | the package of the Kotlin shared file | -The hosts are every Swift or Kotlin file of the target and every `.graphql` beside them, compiled together, since a fragment spread in one file is declared in another. +The hosts are every Swift or Kotlin file of the target, and every `.graphql` file beside them. They are compiled together, since a fragment spread in one file is declared in another. ## What it checks -Everything Relay's compiler checks, since the front end is Relay's, pinned to a release: unknown fields, wrong arguments, missing variables, a spread of an undefined fragment, a directive where it cannot go. The diagnostic names the file, the line and the column inside the GraphQL text in the host file, so the editor shows it on the line, and a document with an error writes nothing. +It checks everything Relay's compiler checks, because the front end is Relay's, pinned to a release: unknown fields, wrong arguments, missing variables, a spread of an undefined fragment, a directive where it cannot go. The diagnostic names the file, the line, and the column inside the GraphQL text in the host file, so the editor shows it on the line. A document with an error writes nothing. -{{diagnostics typo | A misspelled field, with the schema's suggestion.}} +{{diagnostics typo | The diagnostic is a misspelled field, with the schema's suggestion.}} {{diagnostics unknown-fragment | A query that spreads a fragment nobody declared.}} -Baton adds its own checks on top: a name generated code reserves, a fragment both a lens and a value, an operation of client fields alone, a Kotlin document with a template in a plain string. A fragment spread by no operation is a warning, since nothing could ever read it. +Baton adds its own checks on top: a name that generated code reserves, a fragment that is both a lens and a value, an operation made of client fields alone, and a Kotlin document with a template in a plain string. A fragment spread by no operation is a warning, since nothing could ever read it. ## What it writes -One output per source that declares GraphQL, named by the source's path, and one shared file per target with the types and the storage keys every output names. The generated code is one line per field plus the data tables the runtime walks a response by: no model, no decoder, no cache policy, no reflection. +There is one output per source that declares GraphQL, named by the source's path, and one shared file per target. The shared file holds the types and the storage keys every output names. The generated code is one line per field, plus the data tables the runtime walks a response by. There is no model, no decoder, no cache policy, and no reflection. -{{generated issues IssueRow | The lens of one fragment: a struct in Swift and a class in Kotlin, holding its anchor and one accessor per field. A linked field is a nested lens; an abstract field reads its slot on the concrete type.}} +{{generated issues IssueRow | The lens of one fragment is a struct in Swift and a class in Kotlin, holding its anchor and one accessor per field. A linked field is a nested lens. An abstract field reads its slot on the concrete type.}} -{{generated issues shared | The shared file: the schema's digest, the format the runtime must declare, and every type and storage key the target's documents name, interned once per process.}} +{{generated issues shared | The shared file holds the schema's digest, the format the runtime must declare, and every type and storage key the target's documents name, interned once per process.}} -The format is a number the shared file names and the runtime declares; generated code of another format fails to compile at that one line, with a message, so a runtime and a compiler of different releases never meet silently. +The format is a number: the shared file names it, and the runtime declares it. Generated code of another format fails to compile at that one line, with a message, so a runtime and a compiler from different releases never meet silently. ## The report -`--report` writes what the target compiled as JSON: each operation with its kind, its variables, the fragments it reaches and its text; each fragment with its type and the operations that spread it; and each lens with its accessors, what each reads and as what. The report is what a reviewer reads for a contract change, what a dependent target's compilation would read, and what every lens plate on this site is drawn from. +`--report` writes what the target compiled, as JSON. Each operation has its kind, its variables, the fragments it reaches, and its text. A fragment has its type and the operations that spread it, and a lens has its accessors, what each reads, and as what. A reviewer reads the report for a contract change. A dependent target's compilation would read it too, and every lens plate on this site is drawn from it. -{{lens comments IssueComments_issue | A fragment's lens from the report: the response key each accessor reads under, whether it may read absent, and the nested lenses.}} +{{lens comments IssueComments_issue | A fragment's lens from the report shows the response key each accessor reads under, whether it may read absent, and the nested lenses.}} ## Persisted operations -Under `persistConfig` the compiler hashes each operation's text, writes Relay's map from id to text for the server to register, and the artifact carries the id and no text. The request then sends `documentId`, and the server runs nothing it has not seen. Without `persistConfig` the artifact carries the text and no id; the build decides, and no transport has a mode or a fallback. +Under `persistConfig`, the compiler hashes each operation's text and writes Relay's map from id to text, for the server to register. The artifact carries the id and no text, so the request sends `documentId` and the server runs nothing it has not seen. Without `persistConfig`, the artifact carries the text and no id. The build decides, and no transport has a mode or a fallback. -{{operation issues IssuesQuery | An operation under `persistConfig`: the id it is sent as, and the text the server registers it under. `batonc print` prints both.}} +{{operation issues IssuesQuery | An operation under `persistConfig`. The id it is sent as, and the text the server registers it under. `batonc print` prints both.}} ## How the build runs it -- **SwiftPM** runs it through `BatonPlugin`, a build-tool plugin over the release's artifact bundle, one `generate` per target. Nothing to install; the bundle is prebuilt for macOS and Linux. +- **SwiftPM** runs it through `BatonPlugin`, a build-tool plugin over the release's artifact bundle, one `generate` per target. Nothing to install. The bundle is prebuilt for macOS and Linux. - **Gradle** runs it through `com.shergin.baton`, a task with every input and output declared, over the same bundle. [Gradle](../../recipes/gradle/) has the setup. - **Bazel** runs it through `rules_baton`, the command as one sandboxed action. [Bazel](../../recipes/bazel/) has the rule. - **Anything else** runs the command itself. [The command line](../../recipes/batonc/) is the contract: `generate`, `validate` for an editor or a hook, `print` for one operation's text, and `--check` for a team that commits its generated code. @@ -64,8 +64,8 @@ batonc validate --config baton.json Sources/App/*.swift batonc generate --config baton.json --out Sources/App/Generated --check Sources/App/*.swift ``` -The output is deterministic: the same schema, configuration and sources produce the same bytes, which goldens in the repository fix for both languages. In a checkout, `scripts/build-compiler.sh` builds the binary, and `BATON_COMPILER` points any of the integrations at it. +The output is deterministic: the same schema, the same configuration, and the same sources produce the same bytes. Goldens in the repository fix those bytes for both languages. In a checkout, `scripts/build-compiler.sh` builds the binary, and `BATON_COMPILER` points any of the integrations at it. ## Why it is this way -[The compiler decides](../../principles/compiler-decides/) is the principle, and [Relay's front end, pinned, behind our driver](../../decisions/relay-front-end/) the decision that keeps the vocabulary Relay's while everything after the plan is ours. +[The compiler decides](../../principles/compiler-decides/) is the principle. [Relay's front end, pinned, behind our driver](../../decisions/relay-front-end/) is the decision that keeps the vocabulary Relay's, while everything after the plan is ours. diff --git a/site/content/guide/concepts.md b/site/content/guide/concepts.md index 1f54f9f6..243155b0 100644 --- a/site/content/guide/concepts.md +++ b/site/content/guide/concepts.md @@ -1,79 +1,79 @@ # The twelve words -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](../../terminology/), where the Swift and Kotlin spellings are, and one plate here to make it concrete. +Baton's vocabulary is twelve words, and the list is closed. A feature is those words put together, or a Relay directive the compiler understands, or it does not ship. That is why a screen stays one request, a view stays one fragment, a change stays one re-render, and the whole thing fits on a page. Each word below has its contract in [the terminology](../../terminology/), where the Swift and Kotlin spellings are, and one plate here so you can see it. ## 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.](../../terminology/#term-schema) +The schema is the server's menu: every type, every field, and whether each can be null. It is a checked-in SDL file the compiler reads at build time, named in `baton.json`, with identity configured beside it. The runtime never introspects, so what it knows of the schema, it knows from the compiler. There is one schema family per process. [In the terminology.](../../terminology/#term-schema) -{{source spec/rickandmorty/schema.graphql lines=7-32 | The root type and one object type of the Rick and Morty schema. `episode: [Episode]!` is a non-null list of nullable episodes, and the lens types follow that exactly.}} +{{source spec/rickandmorty/schema.graphql lines=7-32 | The root type and one object type of the Rick and Morty schema. `episode: [Episode]!` is a non-null list of episodes that may be null, 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.](../../terminology/#term-document) +A document is a query, a mutation, a subscription, or a fragment, carried by a marker in your source or held in a `.graphql` file. A fragment is a view's needs, with a name. An operation is what the compiler assembles from the fragments spread into it, one per screen, and it compiles to a variables type, a root lens, a plan, its text, and a persisted id. [In the terminology.](../../terminology/#term-document) -{{host issues | A fragment and a query, each in the marker that carries it. The Kotlin marker stands on the composable; the Swift marker on the stored property.}} +{{host issues | A fragment and a query sit in the markers that carry them. The Kotlin marker stands on the composable, and the Swift marker stands on the stored property.}} ## Directive -The only way behaviour attaches to data. The set is Relay's: `@connection`, `@refetchable`, `@argumentDefinitions`, `@arguments`, `@alias`, `@required`, `@catch`, `@throwOnFieldError`, `@defer`, `@stream`, `@stream_connection`, `@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.](../../terminology/#term-directive) +A directive is the only way behaviour attaches to data. The set is Relay's: `@connection`, `@refetchable`, `@argumentDefinitions`, `@arguments`, `@alias`, `@required`, `@catch`, `@throwOnFieldError`, `@defer`, `@stream`, `@stream_connection`, `@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.](../../terminology/#term-directive) -{{host comments | Four directives in one figure: a refetchable connection with its argument definitions, and a mutation whose payload edge is appended to that connection.}} +{{host comments | Four directives appear in one figure: a refetchable connection with its argument definitions, and a mutation whose payload edge is appended to that connection.}} ## 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.](../../terminology/#term-lens) +A lens is Baton's word for what Relay calls a fragment reference and fragment data. Here they are one value. A lens is a typed, read-only window onto one record, with one accessor per field you asked for and none for anything else. 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, and it is equal by its record, its owner, and its origin. [In the terminology.](../../terminology/#term-lens) -{{lens issue-list IssueList_repository | A lens over a connection: `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.}} +{{lens issue-list IssueList_repository | A lens over a connection. `issues` reads a nested lens whose `edges` are a list, and each edge's `node` is a lens that spreads the row's fragment. `cursor` and `pageInfo` are there because the compiler added them for the connection, and 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.](../../terminology/#term-record) +A record is an observable object identified by typename plus key, `Character:9`, and it holds interned slots and per-field errors. Each slot is an invalidation channel, so a body that read it is invalidated when the slot changes, and at no other time. An object without a key gets a client id from its path, as in Relay. [In the terminology.](../../terminology/#term-record) -{{records spec/rickandmorty/character-header-9.store.json | The three records one response made. A link is a slot holding another record's key.}} +{{records spec/rickandmorty/character-header-9.store.json | One response made these three records. A link is a slot holding another record's key.}} ## 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.](../../terminology/#term-store) +The store is every record, the retained roots, and the lifetime state. The main thread owns it. Reads are synchronous, and writes are 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.](../../terminology/#term-store) -{{script notifications steps=2,3 | Two commits: one that changes nothing and notifies nothing, one that changes a name and notifies the one slot.}} +{{script notifications steps=2,3 | Two commits: one changes nothing and notifies nothing, and one changes a name and notifies the one slot.}} ## 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.](../../terminology/#term-plan) +A plan is 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, keying each object by the type's key fields and writing slots by their numbers. It knows no field by name. Both runtimes interpret the same plans. [In the terminology.](../../terminology/#term-plan) -{{generated issues IssuesScreen | The plan is the data table in an operation's generated code, before its data lens: one selection per distinct selection set, each field with its storage key and kind.}} +{{generated issues IssuesScreen | The plan is the data table in an operation's generated code, before its data lens, with one selection per distinct selection set. Each field has its storage key and its kind.}} ## 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.](../../terminology/#term-operation-value) +An operation value is an operation's variables, compared and hashed by them. A parent constructs it, `IssuesQuery(owner:name:)`, and a navigation path carries it. 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.](../../terminology/#term-operation-value) -{{operation issues IssuesQuery | What one operation value stands for on the wire: the text, its two variables, and the id it is registered under.}} +{{operation issues IssuesQuery | What one operation value stands for on the wire is the text, its two variables, and the id it is registered under.}} ## 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.](../../terminology/#term-connection) +A connection is 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, and the lens reads `nodes`, `hasNext`, and `isLoadingNext`. You page with `loadNext`, and `connectionID` names the list for an edge directive. [In the terminology.](../../terminology/#term-connection) -{{script connections steps=2 | The second page committed: the connection record under the key now links both pages' edges, and the lens reads four notes in order.}} +{{script connections steps=2 | Once the second page is committed, the connection record under the key now links both pages' edges, and the lens reads four notes in order.}} ## 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.](../../terminology/#term-environment) +The environment is the session: one store, one transport, and the door everything from outside comes through. It fetches and commits, and it applies optimistic layers. Hydration from the image, logging, and ending are the rest of what it does. Signing out is ending it and making another. You inject it once at the root of the app. [In the terminology.](../../terminology/#term-environment) -{{script end steps=4 | The environment ends: every handle reads as gone, the records are cleared, and nothing is logged.}} +{{script end steps=4 | The environment ends. Every handle reads as gone, the records are cleared, and nothing is logged.}} ## Transport -A protocol with one verb, `send`, which takes a request and yields a stream of payloads: one for a query, several for an incremental 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.](../../terminology/#term-transport) +A transport is a protocol with one verb, `send`. It takes a request and yields a stream of payloads: one for a query, several for an incremental response, many for a subscription. The built-in transports are the platform's HTTP and sockets. A retry, a challenge, or a deadline is a transport that wraps a transport, and the app owns that wrapper. [In the terminology.](../../terminology/#term-transport) -{{script transport steps=1 | What the transport is handed: the standard encoding of one request, the operation's text and its variables, compared byte for byte.}} +{{script transport steps=1 | What the transport is handed is the standard encoding of one request, the operation's text and its variables, compared byte for byte.}} ## 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.](../../terminology/#term-phase) +The phase is the state of a resolved operation, named after `AsyncImagePhase`: loading, ready, or failed. You can always read it synchronously, and `isRefreshing` and `isStale` sit beside it. Previous data stays visible while a refresh runs, and a fetch that changed nothing wakes no body. The phase is derived from the root's verdict and the fetch, and it is stored nowhere. [In the terminology.](../../terminology/#term-phase) -{{script phase steps=1,2,12,13,14 | A handle loading, then ready; a handle whose fetch fails reads as failed; a retry reads as loading; a malformed answer fails it again.}} +{{script phase steps=1,2,12,13,14 | A handle reads as loading, then ready. A handle whose fetch fails reads as failed, a retry reads as loading, and a malformed answer fails it again.}} ## 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](../../decisions/). [What earns a concept](../../principles/what-earns-a-concept/) is the test a thirteenth word would have to pass. +A view model, a cache policy, a type policy, a merge function, a query file for a screen, a cache updater, a resolver, or a runtime GraphQL parser: some screen asked for each of these. Each is answered by the twelve words above, put together, or refused with a reason in [the decisions](../../decisions/). [What earns a concept](../../principles/what-earns-a-concept/) is the test a thirteenth word would have to pass. diff --git a/site/content/guide/environment.md b/site/content/guide/environment.md index e66d7ee8..0df1a11f 100644 --- a/site/content/guide/environment.md +++ b/site/content/guide/environment.md @@ -1,6 +1,6 @@ # The environment -One environment per session: the store, the transport that fills it, the image on disk, the log. It is the one door everything from outside comes through, a server's response, an optimistic layer, a payload committed by hand, and the one thing an app ends at sign-out. This chapter is what it is made of and what it does. +One environment per session. It holds the store, the transport that fills it, the image on disk, and the log. That is the one door everything from outside comes through: a server's response, an optimistic layer, a payload committed by hand. An app ends the environment at sign-out. This chapter is what the environment is made of, and what it does. ## Making one @@ -18,25 +18,25 @@ val environment = Environment( ) ``` -Injected once at the root, `.environment(\.baton, environment)` in SwiftUI and `CompositionLocalProvider(LocalBaton provides environment)` in Compose, and every `@Query`, `@Mutation` and `@Subscription` below finds it. A view outside every environment reads as failed with an error that says so; nothing stands in for a missing environment. +You inject it once, at the root. That is `.environment(\.baton, environment)` in SwiftUI, and `CompositionLocalProvider(LocalBaton provides environment)` in Compose. Every `@Query`, `@Mutation`, and `@Subscription` below finds it. A view outside every environment reads as failed, with an error that says so. Nothing stands in for a missing environment. ## The transport has one verb -A transport takes a request and yields a stream of payloads: one for a query or a mutation, the parts of an incremental response, the events of a subscription. A request says its kind, so a wrapper never retries a mutation a server may have received, and carries the operation's document, text or id. One function, the encoding, turns a request into the JSON a server receives; a server with another convention replaces the function and keeps the transport. +A transport takes a request and yields a stream of payloads: one payload for a query or a mutation, the parts of an incremental response, the events of a subscription. A request says its kind, so a wrapper never retries a mutation a server may have received. The request carries the operation's document, as text or as an id. One function, the encoding, turns a request into the JSON a server receives. If the server uses another convention, you replace that function and keep the transport. -The built-in transports are the platform's: `URLSessionTransport` and `GraphQLTransportWebSocket` in Swift; `HttpTransport` over `java.net` and `GraphQLTransportWebSocket` over the JVM's socket in Kotlin, with `OkHttpTransport` and `OkHttpWebSocketClient` in the `baton-okhttp` edge for an app that has an OkHttp client. All read credentials per attempt from a function, so a rotated token reaches the next request without a new environment. +The built-in transports are the platform's. Swift has `URLSessionTransport` and `GraphQLTransportWebSocket`. Kotlin has `HttpTransport` over `java.net`, and `GraphQLTransportWebSocket` over the JVM's socket. `OkHttpTransport` and `OkHttpWebSocketClient` live in the `baton-okhttp` edge, for an app that has an OkHttp client. All of them read credentials per attempt, from a function, so a rotated token reaches the next request without a new environment. -{{script transport steps=1,2,4,6 | What a transport is handed, compared byte for byte: the standard encoding of a request, with `operationName`, `variables` and the text, or `documentId` under `persistConfig`. An enum variable is sent as its text, an input object as its fields, a nullable variable left unset is absent.}} +{{script transport steps=1,2,4,6 | What a transport is handed, compared byte for byte, is the standard encoding of a request, with `operationName`, `variables`, and the text, or `documentId` under `persistConfig`. An enum variable is sent as its text, an input object is sent as its fields, and a nullable variable left unset is absent.}} -What a production endpoint needs beyond that, one replay of an authorization challenge, a bounded retry with backoff over the failures a retry can mend, a deadline across the attempts, is a transport that wraps a transport, written once around the one verb, and owned by the app. [The exchange](../../recipes/exchange/) walks through the one the GitHub samples send through, in both languages. +Anything a production endpoint needs beyond that is a transport that wraps a transport, written once around the one verb, and owned by the app. Examples are one replay of an authorization challenge, a bounded retry with backoff over the failures a retry can mend, and a deadline across the attempts. [The exchange](../../recipes/exchange/) walks through the one the GitHub samples send through, in both languages. ## The session -An environment pairs a store with the transport that fills it and replaces neither. A sign-in makes one; a sign-out ends it; which one is current is the app's state. Ending it cancels every fetch and stream it started, drops the roots, clears every record and closes the image, giving its file back. A response that lands later reaches neither memory nor the image; a handle still held reads as gone; every later call fails with the same error. +An environment pairs a store with the transport that fills it, and it replaces neither. A sign-in makes one, and a sign-out ends it. Which one is current is the app's state. Ending it cancels every fetch and stream it started, drops the roots, clears every record, and closes the image, giving its file back. A response that lands later reaches neither memory nor the image. A handle still held reads as gone, and every later call fails with the same error. -{{script end | The specification's end script: handles attached, a layer applied, the environment ended, and everything after it refused.}} +{{script end | The specification's end script shows handles attached, a layer applied, the environment ended, and everything after it refused.}} -The order of a sign-out is the app's: end the environment, remove the image, forget the credential last. +The order of a sign-out is the app's. End the environment, remove the image, and forget the credential last. ```swift tab await environment.end() @@ -52,23 +52,23 @@ token = null ## Activity -The environment has an active flag the app sets from its lifecycle: false when the app goes to the background, true on its return, with `revalidate()` beside it to refetch what went stale meanwhile. While inactive, every retained subscription is parked and resumed on the return. In Swift it follows the scene phase; on Android the sample's `Activation` observes the process lifecycle. +The environment has an active flag, and the app sets it from its lifecycle. The flag is false when the app goes to the background, and true on its return, with `revalidate()` beside it to refetch what went stale meanwhile. While the environment is inactive, every retained subscription is parked and resumed on the return. In Swift the flag follows the scene phase, and on Android the sample's `Activation` observes the process lifecycle. ## The log -The environment calls its log with each event of its work: a fetch started, completed with its duration or failed with its failure's kind; a commit with its kind and the count of slots it changed; a field error no `@catch` handled, by operation and path; the image opened, written or failed; and the missing-data cases, each by type and field name. Every event is value-free, a set of names and counts, never a record, a value or a response body, so an app routes it to its logging and metrics without logging anything a user typed. +The environment calls its log with each event of its work: a fetch that started, completed with its duration, or failed with its failure's kind, and a commit with its kind and the count of slots it changed. A field error that no `@catch` handled is logged by operation and path. The image is logged when it is opened, written, or failed, and the missing-data cases are logged by type and field name. Every event is value-free: names and counts, never a record, a value, or a response body. An app can route the log to its logging and metrics without logging anything a user typed. -{{script events steps=1,2,3,4,7,8 | What the specification requires the log to say for a commit, a fetch that fails by the transport and by a request error, a response whose field error no `@catch` handles, and an optimistic layer applied and resolved.}} +{{script events steps=1,2,3,4,7,8 | The specification requires the log to say these things: a commit, a fetch that fails by the transport and one that fails by a request error, a response whose field error no `@catch` handles, and an optimistic layer applied and resolved.}} -In Swift a debug build prints the missing-data events until a log is set; in Kotlin an environment made with `debug = true` does. A test sets none, or collects them and asserts on a name. +In Swift, a debug build prints the missing-data events until a log is set. In Kotlin, an environment made with `debug = true` does. A test sets no log, or it collects the events and asserts on a name. ## Failures say their kind -A fetch or a stream that did not deliver fails in one of a closed set of kinds: the transport's, carrying what the transport threw unchanged; a request error, the server's errors with no data; a malformed response; the environment's, when nothing could send the request, because it is gone or has no subscription transport. A view branches on the kind, and an app's retry policy lives in its exchange, not in the view. +A fetch or a stream that did not deliver fails as one of a closed set of kinds. The transport's kind carries what the transport threw, unchanged. A request error is the server's errors with no data. A malformed response is its own kind. The environment's kind is when nothing could send the request, because the environment is gone or has no subscription transport. A view branches on the kind. An app's retry policy lives in its exchange, not in the view. ## A store without a server -A transport is a protocol with one verb, so a preview, a test and a benchmark each get a real store the way an app does, and never a mock of Baton's types: `SilentTransport` never answers, for a first body with no network behind it; `RecordedTransport` answers by operation name from recorded responses and keeps the requests it was sent; `ScriptedTransport` holds a mutation until the test replies or refuses, so the window between an optimistic apply and the server's answer is observable, and drives a subscription's events by hand. +A transport is a protocol with one verb, so a preview, a test, and a benchmark each get a real store, the way an app does. There is never a mock of Baton's types. `SilentTransport` never answers, for a first body with no network behind it. `RecordedTransport` answers by operation name, from recorded responses, and keeps the requests it was sent. `ScriptedTransport` holds a mutation until the test replies or refuses, so the window between an optimistic apply and the server's answer is observable. It also drives a subscription's events by hand. ```swift tab let transport = ScriptedTransport([CharacterQuery.name: fixture("character-1")]) @@ -86,8 +86,8 @@ wait(until = { transport.held.size == 1 }) transport.held.single().respond(fixture("set-favorite-1")) ``` -They live in `BatonTesting` and `baton-testing`, which an app's tests import and its shipping binary does not. [Previews and tests](../../recipes/testing/) is the catalogue, and `commitPayload` is the way a preview's fixture becomes a store. +They live in `BatonTesting` and `baton-testing`. An app's tests import them. Its shipping binary does not. [Previews and tests](../../recipes/testing/) is the catalogue, and `commitPayload` is how a preview's fixture becomes a store. ## Outside a view -A UIKit or AppKit controller, a Kotlin view model, a service: each asks the environment for a handle, holds the retention that keeps the handle's data alive, and reads what it shows. `environment.handle(for:)` in Swift and `environment.handle(operation)` in Kotlin resolve an operation value outside any view; `environment.fetch`, `mutate`, `preload` and `commitPayload` are the calls that need no view at all. [UIKit and AppKit](../../recipes/uikit/), [Views and view models](../../recipes/views/) and [Derived state](../../recipes/derived-state/) are the three recipes. +A UIKit or AppKit controller, a Kotlin view model, a service: each asks the environment for a handle, holds the retention that keeps the handle's data alive, and reads what the handle shows. `environment.handle(for:)` in Swift and `environment.handle(operation)` in Kotlin resolve an operation value outside any view. `environment.fetch`, `mutate`, `preload`, and `commitPayload` are the calls that need no view at all. [UIKit and AppKit](../../recipes/uikit/), [Views and view models](../../recipes/views/), and [Derived state](../../recipes/derived-state/) are the three recipes. diff --git a/site/content/guide/honest-data.md b/site/content/guide/honest-data.md index 5c4a4752..ba519428 100644 --- a/site/content/guide/honest-data.md +++ b/site/content/guide/honest-data.md @@ -4,22 +4,22 @@ Nullability is what the schema says. Missing data is reported and healed, never ## Types follow the schema -A non-null field is a non-optional property; a nullable one is optional. That is the whole rule, and it means a view stops guarding what the server promises and stops trusting what it does not. The lens plate on any page shows it: `title` on GitHub's `Issue` is `String!` in the schema and a `String` in Swift; `name` on Rick and Morty's `Character` is `String` and reads as an optional. +A non-null field is a non-optional property, and a nullable one is optional. That is the whole rule. A view stops guarding what the server promises, and stops trusting what the server does not promise. The lens plate on any page shows it. `title` on GitHub's `Issue` is `String!` in the schema and a `String` in Swift. `name` on Rick and Morty's `Character` is `String`, and it reads as an optional. -{{lens catch RepositoryQuery | A lens with the three shapes this chapter is about: `repository` reads a `Result` under `@catch`; the row's spread reads as optional because the row requires an author; `description` is optional because the schema says so.}} +{{lens catch RepositoryQuery | A lens with the three shapes this chapter is about. `repository` reads a `Result` under `@catch`. The row's spread reads as optional because the row requires an author. `description` is optional because the schema says so.}} ## `@required`: a field the view cannot do without -Some nulls the schema allows are nulls the product has no use for: a row for an issue whose author is gone has nothing to show. `@required(action:)` moves the null up to where the product wants to handle it: +Some nulls the schema allows are nulls the product has no use for. A row for an issue whose author is gone has nothing to show. `@required(action:)` moves the null up to where the product wants to handle it. -- `NONE` and `LOG` make the field non-optional and bubble the null to the lens boundary: the accessor that produces the lens produces nil instead, `LOG` logs the path through the environment's log, and a null that bubbles all the way to an operation's root fails the operation with the path of the first required field that was null. +- `NONE` and `LOG` make the field non-optional and bubble the null to the lens boundary. The accessor that produces the lens produces nil instead, and `LOG` logs the path through the environment's log. A null that bubbles all the way to an operation's root fails the operation, with the path of the first required field that was null. - `THROW` makes the field's accessor throw a required-field error, at the read. -{{host catch RepositoryScreen | The row requires its author; an issue without one reads as no row, and the screen's `if let row` skips it. The environment logs where.}} +{{host catch RepositoryScreen | The row requires its author. An issue without one reads as no row, and the screen's `if let row` skips it. The environment logs where.}} ## `@catch`: an error the view shows -A field error, a server's failure to resolve one field, is stored beside the field in the record, with its message, its path and its `extensions`, so a cached read sees the same error the network read saw. A plain read sees null; `@catch` reads a `Result`, success with the value or failure with the errors, so the view can say what went wrong: +A field error is a server's failure to resolve one field. It is stored beside the field in the record, with its message, its path, and its `extensions`, so a cached read sees the same error the network read saw. A plain read sees null. `@catch` reads a `Result`, success with the value or failure with the errors, so the view can say what went wrong. ```swift tab switch data.repository { @@ -39,23 +39,23 @@ when { } ``` -`@catch(to: NULL)` keeps the optional type and reads errors as null, for a field the view would rather blank. `@throwOnFieldError` on a fragment or an operation is the strict policy: an uncaught error inside fails the read, or the operation, with the errors, and fields the schema marks `@semanticNonNull` read non-optional under it. An app that wants a server's `onError` behaviour states it once in `baton.json`; the compiler writes it into every operation, and the runtime sends it. +`@catch(to: NULL)` keeps the optional type and reads errors as null, for a field the view would rather blank. `@throwOnFieldError` on a fragment or an operation is the strict policy. An uncaught error inside fails the read, or the operation, with the errors. Fields the schema marks `@semanticNonNull` read non-optional under it. An app that wants a server's `onError` behaviour states it once in `baton.json`. The compiler writes it into every operation, and the runtime sends it. -{{script notifications steps=5,6 | Field errors are data: a response that carries errors for three fields notifies those three slots, and a response that answers them notifies them again. A view reading through `@catch` sees the error, then the value.}} +{{script notifications steps=5,6 | Field errors are data. A response that carries errors for three fields notifies those three slots, and a response that answers them notifies them again. A view reading through `@catch` sees the error, then the value.}} -A response with errors and no data at all is not a field error but a request error: the whole fetch fails. With nothing in the store the phase reads failed with the server's errors and a retry is one call away; with data already there, the data stays and the fetch carries the failure. +A response with errors and no data at all is not a field error. It is a request error, and the whole fetch fails. With nothing in the store, the phase reads failed, with the server's errors, and a retry is one call away. With data already there, the data stays and the fetch carries the failure. ## Missing data heals -Missing data is the one case the types cannot express. It arises when a link is retargeted by another operation to a record fetched with fewer fields: a list fetched a location's name, a detail fetched its dimension, and a later response points the character's `origin` at a location the store knows only by name. Then a read of `dimension` finds no slot. Baton does not invent a value: the lens reads nil, the event is logged with the type and the field, the owning operation is marked stale and refetched once, and the view shows a blank for a moment and then the truth. +Missing data is the one case the types cannot express. It arises when another operation retargets a link to a record fetched with fewer fields. A list fetched a location's name, and a detail fetched its dimension. A later response points the character's `origin` at a location the store knows only by name, so a read of `dimension` finds no slot. Baton does not invent a value. The lens reads nil, the event is logged with the type and the field, and the owning operation is marked stale and refetched once. The view shows a blank for a moment, and then the truth. -{{script heal steps=1,2,3,4 | A read that finds `Location.dimension` missing logs it and refetches the owner; the refetch restores the value; a second miss after the heal's own refetch is logged as unexpected and healed no further.}} +{{script heal steps=1,2,3,4 | A read that finds `Location.dimension` missing logs it and refetches the owner. The refetch restores the value. A second miss, after the heal's own refetch, is logged as unexpected and healed no further.}} -A value the generated type cannot hold, a null in a non-null field or a string where an integer was promised, is logged as unexpected and reads as nil, or as the zero value where the type has no nil; a mapped scalar that does not convert never reads as a zero at all. +A value the generated type cannot hold is logged as unexpected. That is a null in a non-null field, or a string where an integer was promised, and it reads as nil, or as the zero value where the type has no nil. A mapped scalar that does not convert never reads as a zero at all. ## Enums -A schema enum compiles to an enum of its own, with a case per value the build knows and one more carrying the text of a value it does not, so a value the schema gained after the build reads as itself rather than failing the read. Swift's case is `unknown(String)`; Kotlin's is `Undeclared(scalarText)`. +A schema enum compiles to an enum of its own, with a case per value the build knows and one more case carrying the text of a value it does not. A value the schema gained after the build reads as itself, rather than failing the read. Swift's case is `unknown(String)`, and Kotlin's is `Undeclared(scalarText)`. ```swift tab extension IssueState { @@ -80,7 +80,7 @@ val IssueState.word: String ## Custom scalars -A custom scalar is stored as its text, exactly as the server wrote it, and reads as a string; or `customScalarTypes` in `baton.json` maps it to a type of the app's, which in Swift `Decimal`, `Date`, `URL` and `UUID` already are and in Kotlin takes a converter object, and the text is converted at the read. Because a conversion can fail, the accessor is optional wherever the schema puts the field, unless `@required` or `@throwOnFieldError` says what a failure does; a text that does not convert is reported as unexpected, never read as a zero. +A custom scalar is stored as its text, exactly as the server wrote it, and it reads as a string, or `customScalarTypes` in `baton.json` maps it to a type of the app's. In Swift, `Decimal`, `Date`, `URL`, and `UUID` already are such types, and in Kotlin the mapping takes a converter object. The text is converted at the read. A conversion can fail, so the accessor is optional wherever the schema puts the field, unless `@required` or `@throwOnFieldError` says what a failure does. A text that does not convert is reported as unexpected, never read as a zero. ```json { @@ -92,7 +92,7 @@ A custom scalar is stored as its text, exactly as the server wrote it, and reads ## Input objects -An input object compiles to a value type with a field per field, typed as the schema types it, so a misspelled input field is a compile error where it was a server's error before. A field left unset is absent from the request, as GraphQL distinguishes absent from null. +An input object compiles to a value type with a field per field, typed as the schema types it. A misspelled input field is a compile error, where before it was a server's error. A field left unset is absent from the request, and GraphQL distinguishes absent from null. ```swift tab try await addComment(input: AddCommentInput(body: body, subjectId: subjectID), connections: [connectionID]) @@ -104,8 +104,8 @@ addComment(input = AddCommentInput(body = body, subjectId = subjectID), connecti ## Staleness is a phase -A handle reads `isStale` beside its phase: true when the data is older than the operation's expiration or was marked stale by an invalidation, and the age survives a launch because the image keeps it. Stale data stays visible; a `storeOrNetwork` attach refetches it, and a revalidation on the app's return refetches whatever went stale meanwhile. [Reads](../reads/) has the policies. +A handle reads `isStale` beside its phase, true when the data is older than the operation's expiration or was marked stale by an invalidation. The age survives a launch, because the image keeps it. Stale data stays visible. A `storeOrNetwork` attach refetches it, and a revalidation on the app's return refetches whatever went stale meanwhile. [Reads](../reads/) has the policies. ## The principle behind it -[Honest data](../../principles/honest-data/) is the principle: why every client lies in small ways that compound, and the mechanisms, fixtures and events that keep this one from doing so. +[Honest data](../../principles/honest-data/) is the principle: why every client lies in small ways that compound, and the mechanisms, fixtures, and events that keep this one from doing so. diff --git a/site/content/guide/kotlin.md b/site/content/guide/kotlin.md index fd395247..8aec5322 100644 --- a/site/content/guide/kotlin.md +++ b/site/content/guide/kotlin.md @@ -1,14 +1,14 @@ # Compose -The Kotlin runtime is the same machine written for its platform: the same compiler, the same plans, the same fixtures under `spec/`, in Kotlin's words. The figures on this site show the Compose spelling beside the SwiftUI one; this page is what differs, and the setup. +The Kotlin runtime is the same machine, written for its platform: the same compiler, the same plans, the same fixtures under `spec/`, in Kotlin's words. The figures on this site show the Compose spelling beside the SwiftUI one. This page is what differs, and the setup. ## Where the marker stands -In Swift the marker is a macro on a stored property; in Kotlin it is an annotation on the composable that renders, holds or acts. A fragment's composable takes the lens as a parameter; a query's composable resolves the operation value with `rememberQuery`; a mutation's takes its action from `rememberMutation`. The markers repeat, so one composable may carry a fragment and two mutations. +In Swift the marker is a macro on a stored property. In Kotlin it is an annotation on the composable that renders, holds, or acts. A fragment's composable takes the lens as a parameter, a query's composable resolves the operation value with `rememberQuery`, and a mutation's composable takes its action from `rememberMutation`. The markers repeat, so one composable may carry a fragment and two mutations. -{{host star StarButton.kt | One composable hosting the fragment it renders and the two mutations it calls.}} +{{host star StarButton.kt | One composable hosts the fragment it renders and the two mutations it calls.}} -A document with a variable is written in a `$$"""…"""` raw string, because `$` starts a template in a plain one and GraphQL's variables need it; a document without a variable may be a plain string. The compiler reads the string dedented, so it equals the `.graphql` file an author would write. +A document with a variable is written in a raw string delimited by `$$"""` and `"""`. `$` starts a template in a plain string, and GraphQL's variables need `$`. A document without a variable may be a plain string. The compiler reads the string dedented, so it equals the `.graphql` file an author would write. ## The same words, Kotlin's shapes @@ -31,7 +31,7 @@ A document with a variable is written in a `$$"""…"""` raw string, because `$` Everything else is the same by construction: the same operation text, the same records, the same notifications, the same phases, proven by the same fixtures run through code the compiler generates for Kotlin. -{{host issues IssuesScreen.kt | The README's screen in Compose. `rememberQuery` holds the retention while the composable stays and releases it when the composable leaves.}} +{{host issues IssuesScreen.kt | The README's screen in Compose, where `rememberQuery` holds the retention while the composable stays, and releases it when the composable leaves.}} ## The environment in a composition @@ -47,20 +47,20 @@ val environment = remember(token) { CompositionLocalProvider(LocalBaton provides environment) { Triage() } ``` -The store belongs to the thread that made it and its entry points check the caller's; in a composition that is the composition's dispatcher. A test outside a composition passes one test dispatcher as both the main and the ingest dispatcher and runs under `runTest`. +The store belongs to the thread that made it, and its entry points check the caller's thread. In a composition, that thread is the composition's dispatcher. A test outside a composition passes one test dispatcher as both the main dispatcher and the ingest dispatcher, and runs under `runTest`. ## Android -- The runtime starts at API 23 and needs no R8 or ProGuard rules: no reflection, no serialization, no lookup by name. -- The runtime holds no `Context`, so an image named is placed by the app: `Persistence.named(name, directory = cacheDir.path)`. -- The platform has no WebSocket client, so a subscription over a socket goes through `baton-okhttp`'s `OkHttpWebSocketClient` or an app's own client. -- The process lifecycle tells the environment its activity: the sample's `Activation` observes `ProcessLifecycleOwner`, sets `isActive` and calls `revalidate()` on the return. -- An Android application module adds the generated directory through the variant API; [Gradle](../../recipes/gradle/) has the lines. +- The runtime starts at API 23 and needs no R8 or ProGuard rules. There is no reflection, no serialization, and no lookup by name. +- The runtime holds no `Context`, so an image you name is placed by the app: `Persistence.named(name, directory = cacheDir.path)`. +- The platform has no WebSocket client, so a subscription over a socket goes through `baton-okhttp`'s `OkHttpWebSocketClient`, or through an app's own client. +- The process lifecycle tells the environment whether it is active. The sample's `Activation` observes `ProcessLifecycleOwner`, sets `isActive`, and calls `revalidate()` on the return. +- An Android application module adds the generated directory through the variant API. [Gradle](../../recipes/gradle/) has the lines. ## The build -The runtime is `com.shergin.baton:baton` on Maven Central, with `baton-testing`, `baton-okhttp` and `baton-inspector` beside it, and the Gradle plugin `com.shergin.baton` runs the compiler as a task with every input and output declared, fetching the release's prebuilt binary on first use. [Gradle](../../recipes/gradle/) is the page; [the Kotlin runtime](../../kotlin/) is the tree's own README, with the layout, the dependencies and how it is built and published. +The runtime is `com.shergin.baton:baton` on Maven Central, with `baton-testing`, `baton-okhttp`, and `baton-inspector` beside it. The Gradle plugin `com.shergin.baton` runs the compiler as a task, with every input and output declared, and it fetches the release's prebuilt binary on first use. [Gradle](../../recipes/gradle/) is the page, and [The Kotlin runtime](../../kotlin/) is the tree's own README, with the layout, the dependencies, and how the runtime is built and published. ## Without Compose UI -A view model behind Android Views, a desktop window, a service: each asks the environment for the handle, holds the retention, and reads what it shows through `snapshotFlow`, which emits again when a field it read changed. [Views and view models](../../recipes/views/) is the recipe, with the code the runtime's tests compile. +A view model behind Android Views, a desktop window, or a service: each one asks the environment for the handle, holds the retention, and reads what the handle shows through `snapshotFlow`. That flow emits again when a field it read has changed. [Views and view models](../../recipes/views/) is the recipe, with the code the runtime's tests compile. diff --git a/site/content/guide/lists.md b/site/content/guide/lists.md index 4aa3a061..ef597e5d 100644 --- a/site/content/guide/lists.md +++ b/site/content/guide/lists.md @@ -1,45 +1,45 @@ # Lists -A list that pages is a Relay connection in a fragment of its own. The fragment owns the pagination, the store owns the merged pages, a mutation adds or removes an edge with a directive, and the view reads the list like any other field. This chapter is that fragment, what the store holds after each page, and the edge directives. +A list that pages is a Relay connection, in a fragment of its own. The fragment owns the pagination. The store owns the merged pages. A mutation adds or removes an edge with a directive, and the view reads the list like any other field. This chapter is that fragment, what the store holds after each page, and the edge directives. ## The fragment that owns the list -Three directives make a connection: `@connection(key:)` names the list in the store; `@refetchable(queryName:)` gives the fragment a query of its own, which the next page is fetched with; `@argumentDefinitions` declares the page size and the cursor as the fragment's arguments, so the screen's query need not know them. +Three directives make a connection. `@connection(key:)` names the list in the store. `@refetchable(queryName:)` gives the fragment a query of its own, which is how the next page is fetched. `@argumentDefinitions` declares the page size and the cursor as the fragment's arguments, so the screen's query need not know them. {{host issue-list IssueList | The connection fragment, the row it spreads, and the screen that spreads it. The screen's query never mentions `first` or `after`.}} -The lens of a connection reads `nodes`, the records in order across every page; `hasNext` and `isLoadingNext`; `pageInfo` with its cursors; `connectionID`, the key an edge directive names the list by; and `loadNext(count)`, which fetches the next page with the fragment's query and its arguments. `refetch()` on the fragment's own lens fetches the first page again and replaces the list. +The lens of a connection reads `nodes`, the records in order across every page, and also `hasNext`, `isLoadingNext`, and `pageInfo` with its cursors. `connectionID` is the key an edge directive names the list by. `loadNext(count)` fetches the next page with the fragment's query and its arguments. `refetch()` on the fragment's own lens fetches the first page again and replaces the list. {{lens issue-list IssueList_repository | The connection as the compiler reported it. The paging surface is generated beside these accessors.}} ## What the store holds -The connection lives under its key in the store, apart from the pages: a page is a slot of its own under the field's storage key, `notes(first:2)`, and the connection record under the key, `__TestNotes_notes_connection`, links the edges of every page in order. A refetch replaces the merged edges with the first page; a page fetched after a cursor that is no longer the connection's end is ignored. +The connection lives under its key in the store, apart from the pages. A page is a slot of its own under the field's storage key, `notes(first:2)`. The connection record under the key, `__TestNotes_notes_connection`, links the edges of every page in order. A refetch replaces the merged edges with the first page. A page fetched after a cursor that is no longer the connection's end is ignored. -{{script connections | The specification's connection script, step by step: the first page, the second merged, a refetch that replaces, an edge appended by a mutation, and a page fetched after a cursor the connection has moved past, which the merge ignores and which notifies nothing.}} +{{script connections | The specification's connection script goes step by step: the first page, the second merged, a refetch that replaces, and an edge appended by a mutation. A page fetched after a cursor the connection has moved past is ignored by the merge, and it notifies nothing.}} ## Edge directives -A mutation that adds to or removes from a list says so on its payload, and the store does the edit in the same batch as the payload's own records: +A mutation that adds to or removes from a list says so on its payload. The store does the edit in the same batch as the payload's own records. -- `@appendEdge(connections:)` and `@prependEdge(connections:)` on a payload field that is an edge; -- `@appendNode(connections:, edgeTypeName:)` and `@prependNode(connections:, edgeTypeName:)` on a payload field that is a node; -- `@deleteEdge(connections:)` on a payload field holding the id of the node to remove from the lists; -- `@deleteRecord` on a payload field holding the id of a record to delete outright, which every list skips and every link reads as null. +- `@appendEdge(connections:)` and `@prependEdge(connections:)` go on a payload field that is an edge. +- `@appendNode(connections:, edgeTypeName:)` and `@prependNode(connections:, edgeTypeName:)` go on a payload field that is a node. +- `@deleteEdge(connections:)` goes on a payload field holding the id of the node to remove from the lists. +- `@deleteRecord` goes on a payload field holding the id of a record to delete outright. Every list skips that record, and every link reads it as null. -The `connections` variable carries the connection ids the view read from its lenses, so the same mutation can add to one list or three. Nothing edits the cache by hand, and nothing has to know which screens show the list. +The `connections` variable carries the connection ids the view read from its lenses, so the same mutation can add to one list or to three. Nothing edits the cache by hand, and nothing has to know which screens show the list. {{host comments CommentComposer | A comment appended to the issue's comments. The composer takes the `connectionID` the list's lens read and passes it in `connections`.}} -{{script connections steps=4 | What `@appendEdge` leaves in the store: the new note's record, the new edge, and the connection's edge list one longer, with no refetch.}} +{{script connections steps=4 | What `@appendEdge` leaves in the store is the new note's record, the new edge, and the connection's edge list one longer, with no refetch.}} ## Page-based lists -The Rick and Morty API pages by number, `characters(page: 2)`, which is not a connection, and Baton does not pretend it is one: each page is a query of its own with the page as a variable, and the store keeps each under its storage key. A screen that shows twenty rows per page holds the current page's query; a screen that wants an endless list over such an API composes it from the pages itself; the samples show one page at a time. Page-based pagination as a concept of its own is on the watch list, with the trigger that would promote it; [What earns a concept](../../principles/what-earns-a-concept/) is why it is not a thirteenth word. +The Rick and Morty API pages by number, `characters(page: 2)`, which is not a connection. Baton does not pretend it is one. Each page is a query of its own, with the page as a variable, and the store keeps each page under its storage key. A screen that shows twenty rows per page holds the current page's query. A screen that wants an endless list over such an API composes it from the pages itself. The samples show one page at a time. Page-based pagination, as a concept of its own, is on the watch list, with the trigger that would promote it. [What earns a concept](../../principles/what-earns-a-concept/) is why it is not a thirteenth word. ## Keys in a lazy list -Every lens carries its record's key, so a lazy list keys its items by it and a row keeps its state while a refetch reorders the list: +Every lens carries its record's key, so a lazy list keys its items by that key. A row keeps its state while a refetch reorders the list. ```swift tab ForEach(repository.issues.nodes) { issue in @@ -53,4 +53,4 @@ items(repository.issues.nodes, key = { it.recordID.key }) { issue -> } ``` -In Swift, `ForEach` takes a plural lens, a `Baton.List`, or a connection's `nodes` through initializers Baton adds, keyed by the record, so no `id:` is needed. +In Swift, `ForEach` takes a plural lens, a `Baton.List`, or a connection's `nodes`, through initializers Baton adds. They are keyed by the record, so no `id:` is needed. diff --git a/site/content/guide/live.md b/site/content/guide/live.md index 75a9f2f2..ef40a336 100644 --- a/site/content/guide/live.md +++ b/site/content/guide/live.md @@ -1,26 +1,26 @@ # Live data -Three ways data arrives after the first response: a subscription, a stream of events the server pushes; a deferred fragment, a part of a response the server sends later; and a streamed list, whose items the server sends one by one. All three go through the same door as a query's response, into the same records, so a view that reads a record it names updates as after any commit. +Data can arrive in three ways after the first response. A subscription is a stream of events the server pushes. A deferred fragment is a part of a response the server sends later. A streamed list is a list whose items the server sends one by one. All three go through the same door as a query's response, into the same records. A view that reads a record it names updates as it would after any commit. ## A subscription is a stream of events into the store -A subscription is the third kind of operation, declared like a query and resolved in a view. Its handle is open while the view lives and closed when it goes. Each event is a payload in the operation's shape, normalized at the subscription root and committed; an edge directive on the event's payload appends it to a connection, so a live list grows without a refetch. +A subscription is the third kind of operation. You declare it like a query, and you resolve it in a view. Its handle stays open while the view lives, and it closes when the view goes. Each event is a payload in the operation's shape, normalized at the subscription root and committed. An edge directive on the event's payload appends it to a connection, so a live list grows without a refetch. {{host live CharacterNotes | A notes list with a subscription for new notes. The event's edge is appended to the connection the list's lens named, exactly as a mutation's would be.}} -{{operation live NoteAdded | What the server receives for the subscription: the operation's text, with the edge directive left out, since it is the client's business.}} +{{operation live NoteAdded | What the server receives for the subscription is the operation's text, with the edge directive left out, since it is the client's business.}} ## The stream as a value -The handle reads the stream as a value, never awaited: `idle`, parked while the app is inactive; `connecting` until the first event; `open`; `waiting` to reconnect after a failure, by a fixed backoff, a step doubling from one second to thirty and jittered, reset by an event; or `ended`, by the server's completion, by a request error the server would repeat, or by the environment's end. `resumptions` counts the reopenings, since events may have been missed across each; an owner that cares observes it and refetches its baseline. `retry()` opens a waiting or ended stream at once. +The handle reads the stream as a value, and you never await it. `idle` means parked while the app is inactive. `connecting` lasts until the first event, and then the stream is `open`. `waiting` means it will reconnect after a failure, by a fixed backoff: a step that doubles from one second to thirty, jittered, and reset by an event. `ended` means the server completed, or a request error the server would repeat, or the environment ended. `resumptions` counts the reopenings, because events may have been missed across each one. An owner that cares observes it and refetches its baseline. `retry()` opens a waiting or ended stream at once. -{{script subscriptions | The specification's subscription script: a stream connecting, open on its first event, still open after an event of errors and no data, parked while the app is inactive and resumed when it returns, completed by the server, and waiting after a transport failure.}} +{{script subscriptions | The specification's subscription script shows a stream connecting, open on its first event, and still open after an event of errors and no data. It also shows the stream parked while the app is inactive and resumed when it returns, completed by the server, and waiting after a transport failure.}} -The environment's active flag is the app's to set, from the scene phase in Swift and from the process lifecycle on Android; while it is false every retained subscription is parked, and a `revalidate()` on the return refetches what went stale meanwhile. The Android sample's `Activation` and the UIKit recipe's lifecycle section are the two spellings. +The environment's active flag is yours to set, from the scene phase in Swift and from the process lifecycle on Android. While the flag is false, every retained subscription is parked. A `revalidate()` on the return refetches what went stale meanwhile. The Android sample's `Activation` and the UIKit recipe's lifecycle section are the two spellings. ## The transport underneath -Subscriptions travel through the environment's subscription transport, which is any transport with the one verb: the socket transport speaks `graphql-transport-ws`, and the HTTP transport reads `text/event-stream` for `graphql-sse` in its distinct-connections mode, so a server that streams over HTTP needs no socket at all. On Android the platform has no WebSocket client, so the socket is the app's own, through the `baton-okhttp` edge or any client that implements the protocol's few calls. +Subscriptions travel through the environment's subscription transport, which is any transport with the one verb. The socket transport speaks `graphql-transport-ws`. The HTTP transport reads `text/event-stream` for `graphql-sse`, in its distinct-connections mode, so a server that streams over HTTP needs no socket at all. On Android the platform has no WebSocket client, so the socket is the app's own, through the `baton-okhttp` edge or any client that implements the protocol's few calls. ```swift tab Environment( @@ -38,22 +38,22 @@ Environment( ## A part that arrives later: `@defer` -A spread marked `@defer` is a part the server may deliver in a later part of the same response. The spread's accessor is optional and nil until the fragment's fields are present, and the rest of the operation renders meanwhile; the availability check does not wait for the deferred part, and a part the store holds only half is cleared and fetched rather than read as empty. +A spread marked `@defer` is a part the server may deliver later, in the same response. The spread's accessor is optional, and it is nil until the fragment's fields are present. The rest of the operation renders meanwhile. The availability check does not wait for the deferred part. A part the store holds only half of is cleared and fetched, rather than read as empty. -{{host live CharacterScreen | A screen that renders the name at once and the notes when their part lands: the spread's accessor is optional, nil until then.}} +{{host live CharacterScreen | A screen that renders the name at once and the notes when their part lands. The spread's accessor is optional, and nil until then.}} -The built-in HTTP transport reads `multipart/mixed` for the parts, and the environment assembles each at the record its path names. A deferred part the server addresses to a place the store does not have is dropped and logged, never applied somewhere else. +The built-in HTTP transport reads `multipart/mixed` for the parts, and the environment assembles each part at the record its path names. A deferred part addressed to a place the store does not have is dropped and logged, and never applied somewhere else. ## A list that grows: `@stream` -A plural field marked `@stream(initialCount:)` comes with its first items, and the server sends the rest one or a few at a time, in the same response. Each item lands at its index in the list, keyed as it would be had it come with the first part, so a view that reads the list sees it grow in place; the list has no accessor the directive adds, and the handle's fetch is in flight while the stream runs, so `isRefreshing` says whether more is to come. A connection marked `@stream_connection(key:, initial_count:)` streams its edges and defers its page info, as Relay rewrites it, and each edge merges into the connection as it lands. +A plural field marked `@stream(initialCount:)` arrives with its first items. The server sends the rest one or a few at a time, in the same response. Each item lands at its index in the list, keyed as it would have been had it come with the first part, so a view that reads the list sees it grow in place. The directive adds no accessor on the list. The handle's fetch stays in flight while the stream runs, so `isRefreshing` says whether more is to come. A connection marked `@stream_connection(key:, initial_count:)` streams its edges and defers its page info, as Relay rewrites it. Each edge merges into the connection as it lands. {{host live EpisodeList | A list that shows three episodes at once and the rest as they arrive, with a spinner while the stream runs.}} -{{script streams steps=1,2,3,5 | The specification's stream script: the first part renders one episode with the fetch in flight; an item for index 2 arrives before index 1 and reads in its place, the hole skipped; the item for index 1 fills it and notifies the one list slot; the last part ends the stream and the fetch.}} +{{script streams steps=1,2,3,5 | In the specification's stream script, the first part renders one episode, with the fetch in flight. An item for index 2 arrives before index 1 and reads in its place, the hole skipped. The item for index 1 fills the hole and notifies the one list slot. The last part ends the stream and the fetch.}} -An item whose list another commit changed since the stream left it is dropped and logged, as Relay's executor drops it, so a mutation that replaced the list meanwhile is not overwritten by a stale order. A stream the transport ends before the part that says it is the last fails the fetch, and nothing dates the operation's root, so the next attach fetches the list whole. +An item is dropped and logged when another commit has changed its list since the stream left it, as Relay's executor drops it. A mutation that replaced the list meanwhile is not overwritten by a stale order. A stream the transport ends before the part that says it is the last fails the fetch. Nothing dates the operation's root, so the next attach fetches the list whole. ## What a view does not do -A view never awaits an event, never holds a socket, and never merges a stream into its own state. It reads the record the event changed, as it reads any record, and the stream's state when it wants to show a dot. The tests drive a subscription by hand with `ScriptedTransport`, delivering, completing or failing events; [Previews and tests](../../recipes/testing/) shows how. +A view never awaits an event, never holds a socket, and never merges a stream into its own state. It reads the record the event changed, the way it reads any record, and it reads the stream's state when it wants to show a dot. The tests drive a subscription by hand with `ScriptedTransport`, delivering, completing, or failing events. [Previews and tests](../../recipes/testing/) shows how. diff --git a/site/content/guide/reads.md b/site/content/guide/reads.md index c66099d6..a6faf4e0 100644 --- a/site/content/guide/reads.md +++ b/site/content/guide/reads.md @@ -1,24 +1,24 @@ # Reads -A screen declares one query and reads its data through a lens; a view declares one fragment and reads through its own. This chapter is the read side: the handle a query resolves to, the phase it reads, what a parent hands a child, and the policies that decide when the network is asked. +A screen declares one query and reads its data through a lens. A view declares one fragment and reads through its own. This chapter is the read side: the handle a query resolves to, the phase it reads, what a parent hands a child, and the policies that decide when the network is asked. ## A query per screen -The screen's document is a query with variables, carried by a marker. The compiler turns it into an operation value, a struct of the variables in Swift and a class in Kotlin, which the parent constructs and the view resolves when it appears. +The screen's document is a query with variables, carried by a marker. The compiler turns it into an operation value, a struct of the variables in Swift and a class in Kotlin. The parent constructs it, and the view resolves it when it appears. -{{host header CharacterScreen | A screen with one variable. In Swift the operation value is the view's stored property, resolved by the `@Query` macro; in Kotlin `rememberQuery` resolves it in the composable.}} +{{host header CharacterScreen | A screen with one variable. In Swift the operation value is the view's stored property, resolved by the `@Query` macro. In Kotlin `rememberQuery` resolves it in the composable.}} -Resolving means: the environment makes or shares a handle for that value, the handle checks the store, and if the store holds everything the operation selects, the first body renders it. Otherwise the environment fetches, and the body renders `loading` until the answer is committed. Two views that resolve equal values share one handle and one fetch. +Resolving works like this. The environment makes or shares a handle for that value, and the handle checks the store. If the store holds everything the operation selects, the first body renders it. Otherwise the environment fetches, and the body renders `loading` until the answer is committed. Two views that resolve equal values share one handle and one fetch. ## The phase -A handle's phase is `loading`, `ready(data)` or `failed(error)`, always readable, never awaited. Beside it: `isRefreshing`, true while a fetch runs behind data; `isStale`, true when the data is older than the operation's expiration or was marked stale; `fetch`, the last fetch as a value, idle, in flight or failed with a kind. A view switches over the phase and renders each case; a retry is a call on the handle. +A handle's phase is `loading`, `ready(data)`, or `failed(error)`. You can always read it, and you never await it. Beside it sit three more values. `isRefreshing` is true while a fetch runs behind data. `isStale` is true when the data is older than the operation's expiration, or was marked stale. `fetch` is the last fetch as a value: idle, in flight, or failed with a kind. A view switches over the phase and renders each case, and a retry is a call on the handle. -{{script phase | Every phase the specification scripts, with the fetch beside it. Note the handles that read `ready` while refreshing, the one whose refresh fails and keeps its data, and the one attached under `storeOnly` with nothing in the store, which fails with missing data and fetches nothing.}} +{{script phase | Every phase the specification scripts, with the fetch beside it. Note the handles that read `ready` while refreshing, the one whose refresh fails and keeps its data, and the one attached under `storeOnly` with nothing in the store. That one fails with missing data and fetches nothing.}} ## Fetch policies -Relay's four, chosen where a handle is attached: `storeOrNetwork`, the default, renders what the store has and fetches when the store cannot answer or the data is stale; `storeAndNetwork` renders and fetches; `networkOnly` shows loading until its own response, unless another holder already shows the handle; `storeOnly` never fetches. In Swift the policy is an argument of `@Query`; in Kotlin, of `rememberQuery`. +You pick one of Relay's four where a handle is attached. `storeOrNetwork` is the default: it renders what the store has and fetches when the store cannot answer or the data is stale. `storeAndNetwork` renders and fetches. `networkOnly` shows loading until its own response, unless another holder already shows the handle. `storeOnly` never fetches. In Swift the policy is an argument of `@Query`, and in Kotlin it is an argument of `rememberQuery`. ```swift tab @Query("…", fetchPolicy: .storeAndNetwork) var issues: IssuesQuery @@ -28,9 +28,9 @@ Relay's four, chosen where a handle is attached: `storeOrNetwork`, the default, val query = rememberQuery(IssuesQuery(owner, name), fetchPolicy = FetchPolicy.STORE_AND_NETWORK) ``` -A refetch asks the network again whatever the policy; `environment.invalidate()` marks everything stale and refetches what is retained; `environment.revalidate()` refetches what is stale or failed, for a return to the foreground. A query may state its own expiration with `@cacheExpiration(seconds:)`, and the store may be given a default for the rest; data past it reads as stale, which a `storeOrNetwork` attach refetches, as a revalidation does. +A refetch asks the network again, whatever the policy. `environment.invalidate()` marks everything stale and refetches what is retained. `environment.revalidate()` refetches what is stale or failed, for a return to the foreground. A query may state its own expiration with `@cacheExpiration(seconds:)`, and the store may be given a default for the rest. Data past that expiration reads as stale, and a `storeOrNetwork` attach refetches it, as a revalidation does. -{{script ages steps=1,2,3,4,5,6 | Two operations over the same character, under the store's default expiration of a minute; the second states thirty seconds of its own. As the clock advances the second reads stale first, then both; a revalidation refetches both, since both are stale by then.}} +{{script ages steps=1,2,3,4,5,6 | Two operations over the same character, under the store's default expiration of a minute. The second states thirty seconds of its own. As the clock advances the second reads stale first, then both. A revalidation refetches both, since both are stale by then.}} ## A fragment per view @@ -38,28 +38,28 @@ A view declares the fields it reads and takes the fragment's lens. The lens is t {{host header CharacterHeader | The header reads five fields of one record, one of them through a link, and gets a lens with exactly those five accessors.}} -{{lens header CharacterHeader_character | The lens as reported. `origin` is a nested lens over the linked record; nothing the fragment did not declare exists on the type.}} +{{lens header CharacterHeader_character | In the lens as reported, `origin` is a nested lens over the linked record, and nothing the fragment did not declare exists on the type.}} -A parent spreads the fragment and hands the child its lens through the spread's accessor; the accessor is named after the fragment, `characterHeader` for `CharacterHeader_character`, or by `@alias(as:)`. The parent cannot read the child's fields through it, and should not try: a view that reads another view's fields grows a dependency nobody declared. +A parent spreads the fragment and hands the child its lens through the spread's accessor. The accessor is named after the fragment, `characterHeader` for `CharacterHeader_character`, or named by `@alias(as:)`. The parent cannot read the child's fields through it, and should not try. A view that reads another view's fields grows a dependency nobody declared. ## Reading through the lens -Every accessor is a synchronous read on the main thread that loads a slot and registers the read with the UI framework. Reading `character.name` costs what reading an `@Observable` property costs. A body that read a field depends on that field of that record, and on nothing else; [the store](../store/) is where the notification side is. +Every accessor is a synchronous read on the main thread: it loads a slot and registers the read with the UI framework. Reading `character.name` costs what reading an `@Observable` property costs. A body that read a field depends on that field of that record, and on nothing else. [The store](../store/) is where the notification side lives. -What an accessor reads as follows the schema: a nullable field is an optional; a non-null field is not; a list of nullable elements is a list of optionals; a list of records is a collection of lenses, `List` in Swift and `LensList` in Kotlin, each lens keyed by its record. Interfaces and unions add an accessor per concrete type, `asIssue`, and a spread inside an inline fragment on them carries `@alias`, as Relay requires. +What an accessor reads follows the schema. A nullable field is an optional. A non-null field is not. A list of nullable elements is a list of optionals. A list of records is a collection of lenses, `List` in Swift and `LensList` in Kotlin, each lens keyed by its record. Interfaces and unions add an accessor per concrete type, `asIssue`. A spread inside an inline fragment on them carries `@alias`, as Relay requires. -{{host catch RepositoryScreen | A list of lenses, a spread that may read as absent, and a field under `@catch`; Honest data covers the last two.}} +{{host catch RepositoryScreen | A list of lenses, a spread that may read as absent, and a field under `@catch`. Honest data covers the last two.}} ## Refetch, retry, invalidate -- `refetch()` on a handle sends the operation again and commits the answer; the data stays while it runs, and a body wakes only if something changed. -- `retry()` after a failure: with data behind the failure it is a refetch; after any other failure the phase reads loading again. -- `refetch()` on a refetchable fragment's lens runs the fragment's own query with its arguments and updates the records in place; new arguments replace the lens. -- `environment.fetch(operation)` fetches by hand, outside any view, for a refresh of rows by id or a prefetch; [Discover once](../../recipes/discover-once/) is the pattern. +- `refetch()` on a handle sends the operation again and commits the answer. The data stays while it runs, and a body wakes only if something changed. +- `retry()` comes after a failure. With data behind the failure it is a refetch, and after any other failure the phase reads loading again. +- `refetch()` on a refetchable fragment's lens runs the fragment's own query with its arguments and updates the records in place, and new arguments replace the lens. +- `environment.fetch(operation)` fetches by hand, outside any view, for a refresh of rows by id or a prefetch. [Discover once](../../recipes/discover-once/) is the pattern. ## Lookups -A root field that names a record by id, `node(id:)` or `character(id:)`, can be answered from the store before the network: `baton.json` declares the lookup, and the availability check binds the field to the record it already holds, so a detail screen opened from a list renders in the first frame when the list fetched everything the detail selects, and is fetched whole otherwise. The binding happens in the check, never in a read, so a read stays a slot load. +A root field that names a record by id, `node(id:)` or `character(id:)`, can be answered from the store before the network. `baton.json` declares the lookup. The availability check binds the field to the record it already holds, so a detail screen opened from a list renders in the first frame when the list fetched everything the detail selects. Otherwise the detail is fetched whole. The binding happens in the check, never in a read, so a read stays a slot load. ```json { diff --git a/site/content/guide/store.md b/site/content/guide/store.md index 6dd365f6..7c3669f9 100644 --- a/site/content/guide/store.md +++ b/site/content/guide/store.md @@ -1,10 +1,10 @@ # The store -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. +The store is the UI's state. Every response becomes records keyed by type and id, and those 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 then to 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. +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. ```json { @@ -18,43 +18,43 @@ A record is `Type:id` by default. `baton.json` configures identity for types key } ``` -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. +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. 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 that has no 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. -{{normalize spec/rickandmorty/character-header-9 | A response and its records, linked both ways. The root record holds the query's root fields under their storage keys; a nested object with an id became a record of its own.}} +{{normalize spec/rickandmorty/character-header-9 | A response and its records are linked both ways. The root record holds the query's root fields under their storage keys, and a nested object with an id became a record of its own.}} ## 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 response's bytes are decoded straight into record slots, off the main actor, by the plan the compiler emitted. There is no model tree in between, and that is the ingest. What it produces is a change set, and the commit applies it on the main actor as one batch. Each slot that changed is written, and a slot whose value is equal is left alone. Each observed field that changed is notified once, when the batch ends. A view never sees half a response. -{{script notifications steps=1,2,3,5,6 | What each commit notifies. The same response again notifies nothing; a rename notifies one slot; a response that carries errors for three fields notifies those three; a response that answers them notifies the three again.}} +{{script notifications steps=1,2,3,5,6 | What each commit notifies. The same response again notifies nothing. A rename notifies one slot. A response that carries errors for three fields notifies those three, and a response that answers them notifies the three again.}} -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. +There are three kinds of batch. A server batch is written to the image. An optimistic batch keeps its undo log with its layer, and it is never written to the image. A local batch is the runtime's own writes, a page's loading flag or a lookup's link bound, and it 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. +Each record carries the UI framework's registrar: Observation's in Swift, and a snapshot-state channel per slot in Kotlin. Each lens accessor registers the read of one field of one record. The framework then knows 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. -{{stage characters CharactersQuery spec/rickandmorty/characters-page-1.store.json rows=4 | Four rows over real records. Change a row's field and that row's body runs; change the list and the screen's body runs; answer with the same data and nothing runs.}} +{{stage characters CharactersQuery spec/rickandmorty/characters-page-1.store.json rows=4 | Four rows sit over real records. Change a row's field and that row's body runs. Change the list and the screen's body runs. Answer with the same data and nothing runs.}} -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. +A commit is ordinary main-actor state mutation, so a data-driven change animates like any other state change in the framework. Nothing of Baton's sits 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 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. When the last retention ends, the root waits in a release buffer, ten by default, before its records become collectable. Going back to a screen you 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. -{{script lifetime steps=3,4,5,9 | The specification's lifetime script, with a release buffer of one. Three handles hold five records; releasing one changes nothing while the buffer holds its root; releasing a second pushes the first out and its character goes; releasing the last pushes the second out, and the last's own records stay while its root waits in the buffer, one of them held by a mutation's payload as well.}} +{{script lifetime steps=3,4,5,9 | The specification's lifetime script uses a release buffer of one. Three handles hold five records. Releasing one changes nothing while the buffer holds its root. Releasing a second pushes the first out, and its character goes. Releasing the last pushes the second out. The last handle's own records stay while its root waits in the buffer, one of them held by a mutation's payload as well.}} -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. +The consequence for a scrolling list is that memory plateaus. 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 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. The age survives a launch, because the image keeps it. -{{script ages steps=10,11,13,14,15,16 | An invalidation marks both handles stale and refetches them; after a relaunch a handle attached over the image reads ready and, when the clock passes the store's default expiration of a minute, stale.}} +{{script ages steps=10,11,13,14,15,16 | An invalidation marks both handles stale and refetches them. After a relaunch, a handle attached over the image reads ready. When the clock passes the store's default expiration of a minute, it reads stale.}} ## 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. +Every server batch is written behind to an image, one SQLite file, off the main actor. It holds a row per record, a row per root field, and each operation's fetch time. When a handle is made, the availability check reads from the image whatever memory lacks. An operation an earlier launch fetched is ready before the first body. The screen you left is the screen you get back. ```swift tab Store(persistence: Persistence(name: "App", version: Types.schemaDigest)) @@ -66,11 +66,11 @@ Store(persistence = Persistence.named("App", version = Types.schemaDigest, direc {{script check steps=1,2,3,4,5,7 | A commit, a check answered from memory, a check for data nobody fetched, a relaunch, the first check answered from the image, and the miss still a 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. +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 those 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. +A field the server does not have, a pin or a draft, is described in a client schema extension named in `baton.json`. You write it by committing a payload by hand, `environment.commitPayload(operation, payload)`, for an operation that selects it. Such a field reads through a lens like any other, 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. ```graphql extend type Character { @@ -81,4 +81,4 @@ extend type Character { ## 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`. +`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`. diff --git a/site/content/guide/writes.md b/site/content/guide/writes.md index 50244446..f73ac53e 100644 --- a/site/content/guide/writes.md +++ b/site/content/guide/writes.md @@ -1,36 +1,36 @@ # Writes -A mutation is an action: called with one argument per variable, asynchronous, able to fail, returning the payload's lens. What it changes is what its payload selects; what it adds to or removes from a list is an edge directive; what it shows before the server answers is its optimistic response. There is no cache updater, because a payload for an operation says every write. +A mutation is an action you call with one argument per variable. The call is asynchronous and can fail, and it returns the payload's lens. The payload's selection is what the mutation changes. An edge directive says what it adds to or removes from a list, and the optimistic response is what shows before the server answers. There is no cache updater, because a payload for an operation says every write. ## A mutation is an action -{{host star StarButton | A star toggle. The fragment is what the button reads; the two mutations are what it does. The optimistic response is a typed value of the payload's selection, with the concrete type named because `starrable` is an interface.}} +{{host star StarButton | A star toggle: the fragment is what the button reads, and the two mutations are what it does. The optimistic response is a typed value of the payload's selection, with the concrete type named because `starrable` is an interface.}} -The call is `try await addStar(input:optimistic:)` in Swift and `addStar(input = …, optimistic = …)` in a coroutine in Kotlin; `isInFlight` says the server has not answered. The payload selects `viewerHasStarred` and `stargazerCount` on the repository, so when the server answers, the record the button reads is updated and the button re-renders, and so does any other view reading those fields, on any screen. +The call is `try await addStar(input:optimistic:)` in Swift, and `addStar(input = ..., optimistic = ...)` in a coroutine in Kotlin. `isInFlight` says the server has not answered. The payload selects `viewerHasStarred` and `stargazerCount` on the repository. When the server answers, the record the button reads is updated, and the button re-renders. So does any other view reading those fields, on any screen. -{{lens star StarButtonAddStar | The action's result: the payload as a lens, with the concrete type's fields under a condition.}} +{{lens star StarButtonAddStar | The action's result is the payload as a lens, with the concrete type's fields under a condition.}} ## Optimistic responses -An optimistic response is rendered to a payload by a generated builder, ingested like a server's response, and applied as a layer with an undo log. The layer shows in the turn of the call, on the main actor, so the star fills before the request leaves. A commit under live layers lifts them, applies the response, re-applies them, and notifies only the slots whose value differs in the end. The server's answer replaces the layer; a failure reverts it. +An optimistic response is rendered to a payload by a generated builder, ingested like a server's response, and applied as a layer with an undo log. The layer shows in the turn of the call, on the main actor, so the star fills before the request leaves. A commit under live layers lifts them, applies the response, and re-applies them. Only the slots whose value differs in the end are notified. The server's answer replaces the layer, and a failure reverts it. -{{script optimistic | The specification's optimistic script: a rename applied as a layer, a server commit underneath it, the layer resolved, a second layer reverted, and a deleted note revived by a layer and deleted again when the layer goes.}} +{{script optimistic | The specification's optimistic script shows a rename applied as a layer and a server commit underneath it. The layer is resolved, a second layer is reverted, and a deleted note is revived by a layer and deleted again when the layer goes.}} -What the view does when the server refuses is the view's: the layer is already gone, so a button reads its old state again, and the action's error says why. The Kotlin GitHub sample's comment composer empties its field at once and puts the text back on a refusal; the Swift one clears it when the server answers. +What the view does when the server refuses is up to the view. The layer is already gone, so a button reads its old state again, and the action's error says why. The Kotlin GitHub sample's comment composer empties its field at once and puts the text back on a refusal. The Swift one clears it when the server answers. ## Adding to a list -A payload field that is an edge or a node carries an edge directive naming the connections to add to, through a `connections` variable the view fills from its lens's `connectionID`: +A payload field that is an edge or a node carries an edge directive. The directive names the connections to add to, through a `connections` variable the view fills from its lens's `connectionID`. -{{host comments CommentComposer | `@appendEdge` on the payload's `commentEdge`. The composer never touches the list; it names it.}} +{{host comments CommentComposer | `@appendEdge` sits on the payload's `commentEdge`. The composer never touches the list, and names it.}} -{{operation comments CommentComposerAddComment | The directive is the client's business and stays out of the text the server receives, and so does the `$connections` variable only it used; the request's variables still carry `connections`, which the server ignores.}} +{{operation comments CommentComposerAddComment | The directive is the client's business, so it stays out of the text the server receives. So does the `$connections` variable, which only the directive used. The request's variables still carry `connections`, which the server ignores.}} Removing is `@deleteEdge(connections:)` on a field holding the removed node's id, or `@deleteRecord` on a field holding the id of a record to delete outright. [Lists](../lists/) shows the edit in the store. ## A payload committed by hand -The same door a server's response comes through takes a payload from anywhere else: a REST response, a socket's tick, a preview's fixture, a test's seed. `environment.commitPayload(operation, payload)` runs the operation's plan over the bytes and commits them as a fetch's response is committed. A payload may carry part of what the operation selects, and it alone writes client fields. +The same door a server's response comes through takes a payload from anywhere else: a REST response, a socket's tick, a preview's fixture, a test's seed. `environment.commitPayload(operation, payload)` runs the operation's plan over the bytes and commits them the way a fetch's response is committed. A payload may carry part of what the operation selects, and only a payload writes client fields. ```swift tab try await environment.commitPayload(CharacterQuery(id: "1"), Payload(json: fixture)) @@ -44,8 +44,8 @@ A mutation outside any view is `environment.mutate(operation, optimistic:)`, wit ## What never to write -- **A cache updater.** No function edits the store after a mutation. What a mutation changes is what its payload selects; a list it changes is an edge directive; what it shows first is its optimistic response. -- **A model that copies a lens's fields** to hold them across the call. Read the lens inside the action's closure, on the main actor; the values are a slot load away. -- **A write the server does not know about.** Client fields are described in a schema extension and committed as a payload; nothing is set on a record by hand. +- **A cache updater.** No function edits the store after a mutation. What a mutation changes is what its payload selects. A list it changes is an edge directive. What it shows first is its optimistic response. +- **A model that copies a lens's fields** to hold them across the call. Read the lens inside the action's closure, on the main actor. The values are a slot load away. +- **A write the server does not know about.** Client fields are described in a schema extension and committed as a payload. Nothing is set on a record by hand. -[Porting from Relay](../../recipes/porting-from-relay/) lists the Relay write APIs that are not here and the reason for each. +[Porting from Relay](../../recipes/porting-from-relay/) lists the Relay write APIs that are not here, and the reason for each. diff --git a/site/content/home.md b/site/content/home.md index bec927a7..9d4dbf51 100644 --- a/site/content/home.md +++ b/site/content/home.md @@ -6,27 +6,27 @@

Relay, for SwiftUI and Compose.

-

A fragment beside every view, one request per screen, cached data in the first frame, and a re-render only where a field changed. You write what a view needs. The compiler assembles the screen's operation, checks every field against the schema, and emits a typed lens. The runtime turns responses into records that the UI framework itself observes.

+

You write the fields a view needs, next to the view. Baton asks for them in one request, shows the cached data on the first frame, and re-renders only where a field changed. The compiler checks every field against the schema. The UI framework watches the records.

```sh swift package add-dependency https://github.com/shergin/baton --from 0.15.0 ``` -

Swift 6.2 · Kotlin 2.4 · the platform's own frameworks and SQLite, nothing vendored · one prebuilt compiler

+

Swift 6.2 · Kotlin 2.4 · the platform's own frameworks and SQLite, nothing vendored · one compiler, already built

Start here New to GraphQL? One screen, end to end Open the playground

-{{host issues IssueRow | A row declares the fields it reads, beside its body, and can read nothing else. SwiftUI on one tab, Compose on the other: the same document, the same lens.}} +{{host issues IssueRow | A row names the fields it reads, right beside its body. It cannot read anything else. SwiftUI on one tab, Compose on the other. Same document, same lens.}} ## A field changes. One view re-renders. -The store below holds records the runtime wrote from a recorded response to the Rick and Morty API, and the screen beside it reads them through the fragments the compiler reported. Nothing in between is a view model. Click a field in the store and watch who notices. +The store below is a real response from the Rick and Morty API. The screen beside it reads those records through the fragments the compiler reported. There is no view model in between. Click a field. Watch who wakes up. -{{stage characters CharactersQuery spec/rickandmorty/characters-page-1.store.json rows=6 | The list reads the root's link and the list's links; each row reads the four fields its fragment declared, of its own record. A refetch that answers with the same bytes notifies nobody, because the store compares before it notifies.}} +{{stage characters CharactersQuery spec/rickandmorty/characters-page-1.store.json rows=6 | The list reads the root's link and the list's links. Each row reads the four fields its fragment declared, on its own record. A refetch that comes back with the same bytes notifies nobody. The store compares before it tells anyone.}}
@@ -34,7 +34,7 @@ The store below holds records the runtime wrote from a recorded response to the

The view never sees the data layer

-

No query files to keep in sync, no cache-update code, no view model copying fields around. A fragment next to a body, and the data is there. A parent hands a child a lens, not data, and the child reads exactly the fields it declared.

+

No query file to keep in sync. No cache-update function. No view model copying fields into a struct. The fragment sits next to the body, and the data is there. A parent hands a child a lens, not a bag of fields. The child reads exactly what it asked for.

@@ -42,7 +42,7 @@ The store below holds records the runtime wrote from a recorded response to the

Every claim can be checked

-

Reading a field through a lens equals reading the raw response at the same path, and fixtures under spec/ prove it for both runtimes. A claim of speed has a bench behind it in the repository. A view that re-rendered did so because a field it read changed, and a test can count it.

+

Read a field through a lens and you get the same value as the raw response, at the same path. Fixtures under spec/ prove it for Swift and for Kotlin. A speed claim has a bench in the repository. If a view re-rendered, a field it read changed, and a test can count that.

@@ -50,7 +50,7 @@ The store below holds records the runtime wrote from a recorded response to the

Relay's language, native's mechanisms

-

The directives, the conventions and the compiler's front end are Relay's, so a web team and a native team speak one language. What Relay built because React could not see who read what is replaced by what SwiftUI's Observation and Compose's snapshot state can see.

+

The directives, the conventions, and the compiler's front end are Relay's. A web team and a native team can use the same words. Relay built a runtime because React cannot see which view read which field. SwiftUI's Observation and Compose's snapshot state can. Baton uses those, and leaves Relay's read side out.

@@ -58,13 +58,13 @@ The store below holds records the runtime wrote from a recorded response to the ## The shape of a screen -A screen is one query. Every view on it is one fragment. The compiler assembles the query from the fragments spread into it, and nobody writes the screen's request by hand. +A screen is one query, and each view on it is one fragment. The compiler builds the query from the fragments spread into it, so nobody writes the screen's request by hand. -{{host issues IssuesScreen | The screen names the repository by its variables and spreads the row's fragment into the open issues. The row's accessor, `issueRow`, is the baton: the parent hands it over and cannot read through it.}} +{{host issues IssuesScreen | The screen names the repository with its variables, and spreads the row's fragment into the open issues. The row's accessor, `issueRow`, is the baton. The parent hands it over, and cannot read through it.}} -{{operation issues IssuesQuery | What the compiler sends: the operation with the fragment it reaches printed after it, the `id` fields identity needs added, the whole text on one line and hashed to an id. Under `persistConfig` the request carries the id alone.}} +{{operation issues IssuesQuery | What the compiler sends: the operation, then the fragment it reaches, with the `id` fields identity needs added. The whole text is one line, hashed to an id. Under `persistConfig` the request carries that id and nothing else.}} -{{lens issues IssueRow_issue | The row's lens, as the compiler reported it: one accessor per declared field, and no accessor for anything the fragment did not declare. Masking is not a convention here; it is the shape of the type.}} +{{lens issues IssueRow_issue | The row's lens, as the compiler reported it: one accessor per field the fragment declared, and no accessor for anything else. Masking is not a house rule. It is the shape of the type.}}
@@ -72,7 +72,7 @@ A screen is one query. Every view on it is one fragment. The compiler assembles

Records, not models

-

A response is decoded straight into records keyed by type and id, off the main actor, with no model tree in between. The list, the detail and the cast of an episode all read the one record a character is. The store shows a response beside what it became.

+

A response is decoded straight into records, keyed by type and id, off the main actor. There is no model tree in between. The list, the detail, and the cast of an episode all read the one record a character is. The store shows a response beside the records it became.

@@ -80,7 +80,7 @@ A screen is one query. Every view on it is one fragment. The compiler assembles

Lists that page in the store

-

A list is a Relay connection on a refetchable fragment. The lens pages with loadNext, the store merges the pages, and a mutation adds an edge with a directive instead of a cache updater. Lists walks the fixture that proves it.

+

A list is a Relay connection on a refetchable fragment. The lens pages with loadNext, the store merges the pages, and a mutation adds an edge with a directive rather than a cache updater. Lists walks the fixture that proves it.

@@ -88,7 +88,7 @@ A screen is one query. Every view on it is one fragment. The compiler assembles

Writes you can see at once

-

A mutation is an action. Its optimistic response is a typed value that goes through the same ingest as a server's answer, shows in the turn of the call, and is reverted if the server refuses. Writes steps through a layer applied, resolved and reverted.

+

A mutation is an action. Its optimistic response is a typed value. It goes through the same ingest as a server's answer, shows up in the turn of the call, and comes back off if the server says no. Writes steps through a layer applied, resolved, and reverted.

@@ -96,7 +96,7 @@ A screen is one query. Every view on it is one fragment. The compiler assembles

Honest data

-

Nullability is what the schema says. @required moves a null to where the product wants it, @catch turns a field error into a Result, and a field error survives caching. Missing data is reported and healed, never invented. Honest data has the directives.

+

Nullability is what the schema says. @required moves a null up to where the product wants to deal with it, and @catch turns a field error into a Result. A field error survives caching. Missing data is reported and healed, never invented. Honest data has the directives.

@@ -104,7 +104,7 @@ A screen is one query. Every view on it is one fragment. The compiler assembles

The first frame has the data

-

Reads are synchronous on the main actor. A handle resolves against the store before the first body runs, and after a launch the store reads what the handle needs from its image on disk. No loading flash for a screen the app has seen. The walkthrough follows one screen to the next launch.

+

Reads are synchronous on the main actor, so a handle resolves against the store before the first body runs. After a launch, the store reads what the handle needs from its image on disk, and a screen the app has seen does not flash a spinner. The walkthrough follows one screen through to the next launch.

@@ -112,7 +112,7 @@ A screen is one query. Every view on it is one fragment. The compiler assembles

The compiler decides

-

Every operation is known at build time. Identity, aggregation, masking, storage keys, persisted ids and diagnostics are settled before the app runs, and a wrong field is a build error pointing at the character in the GraphQL text. The compiler shows one.

+

Every operation is known at build time. Identity, aggregation, masking, storage keys, persisted ids, and diagnostics are settled before the app runs. A wrong field is a build error, pointing at the character in the GraphQL text. The compiler shows one.

@@ -120,21 +120,21 @@ A screen is one query. Every view on it is one fragment. The compiler assembles ## Twelve words -The vocabulary is closed and short. A feature is a composition of these, or a Relay directive the compiler understands, or it does not ship. +Twelve words, and that is the whole vocabulary. A feature is some combination of these, or a Relay directive the compiler already understands, or it does not ship.
  • schemathe server's menu, checked in and compiled in
  • -
  • documenta query, a mutation, a subscription or a fragment, beside a view
  • +
  • documenta query, a mutation, a subscription, or a fragment, beside a view
  • directivethe only way behaviour attaches to data
  • -
  • lensa typed, read-only view over one record
  • -
  • recordan observable object keyed by type and id
  • -
  • storeall records, owned by the main thread
  • +
  • lensa typed window onto one record, read-only
  • +
  • recordan observable object, keyed by type and id
  • +
  • storeall the records, owned by the main thread
  • planhow a response becomes records, decided by the compiler
  • operation valuean operation's variables, the thing a parent constructs
  • connectiona list that pages, merged in the store
  • environmentthe session: one store, one transport
  • transportone verb, send, yielding payloads
  • -
  • phaseloading, ready or failed, always readable
  • +
  • phaseloading, ready, or failed, and always readable
## Where to go @@ -145,9 +145,9 @@ The vocabulary is closed and short. A feature is a composition of these, or a Re

Start here

  • Getting startedInstall, the schema, the first screen, the first fragment, the first list.
  • -
  • GraphQL for mobile engineersThe menu, the order, the response with the order's shape, ids, lists, writes. No prior GraphQL needed.
  • -
  • One screen, end to endFrom the fragment beside a view to the frame, the records, the one re-render and the next launch.
  • -
  • PlaygroundType a fragment, read what the compiler wrote.
  • +
  • GraphQL for mobile engineersThe menu, the order, the response in the order's shape, ids, lists, writes. No GraphQL required.
  • +
  • One screen, end to endFrom the fragment beside a view to the frame, the records, the one re-render, and the next launch.
  • +
  • PlaygroundType a fragment. Read what the compiler wrote.
@@ -160,7 +160,7 @@ The vocabulary is closed and short. A feature is a composition of these, or a Re
  • ListsConnections, pages merged, edge directives.
  • WritesActions, optimistic responses, payloads.
  • Honest dataNulls, errors, missing data, enums, scalars.
  • -
  • Live dataSubscriptions, deferred fragments and streamed lists.
  • +
  • Live dataSubscriptions, deferred fragments, streamed lists.
  • The compilerWhat it checks, what it writes, how the build runs it.
  • The environmentThe transport, the session, the log, tests.
  • ComposeThe same screens in Kotlin.
  • @@ -172,7 +172,7 @@ The vocabulary is closed and short. A feature is a composition of these, or a Re
    • AgentsThe page a coding agent reads first.
    • Every recipeTesting, the exchange, UIKit, view models, porting from Relay, the build integrations.
    • -
    • The galleryEvery sample, with what the compiler found in it.
    • +
    • The galleryEvery sample, and what the compiler found in it.
    @@ -180,10 +180,10 @@ The vocabulary is closed and short. A feature is a composition of these, or a Re

    Why it is shaped this way

    • VisionThree commitments and five rules.
    • -
    • The principlesEight constraints, each with the failure it avoids.
    • -
    • DecisionsEvery choice, with its evidence and what would reopen it.
    • -
    • Faster, by skipping stepsWhat Apollo does on every response and every read that Baton does not.
    • -
    • ArticlesThe design argued at length: Relay without the read side, what Meta built, why it is fast, the first frame, two runtimes, the compiler, the honest store, twelve words, Baton beside fate, the small lies a client tells, and how it is built.
    • +
    • The principlesEight constraints. Each one names the failure it avoids.
    • +
    • DecisionsEvery choice, the evidence, and what would reopen it.
    • +
    • Faster, by skipping stepsThree things Apollo does on every response, or every read, that Baton never does.
    • +
    • ArticlesThe long version: Relay without the read side, what Meta built, why it is fast, the first frame, two runtimes, the compiler, the honest store, twelve words, Baton beside fate, the small lies a client tells, and how the library is built.
    @@ -201,15 +201,15 @@ The vocabulary is closed and short. A feature is a composition of these, or a Re ## Faster, by skipping steps -Baton is faster than Apollo iOS and Apollo Kotlin on the same data, and the reason is not a cleverer store: the store writes at about the same speed. The reason is three steps Apollo takes on every response or every read that Baton does not take at all. +Baton is faster than Apollo iOS and Apollo Kotlin on the same data, and the store is not the trick: the stores write at about the same speed. Apollo takes three steps, on every response or every read, that Baton does not take at all.
    -
    No model tree

    Apollo builds a model of the operation from the response's bytes, and builds it again from the store on every read. Baton decodes the bytes straight into record slots by the plan the compiler emitted, and a view reads a slot.

    -
    No re-execution

    When any field a query read changes, Apollo re-executes the whole query and rebuilds the tree. Baton notifies the slot that changed, and only the bodies that read it run.

    -
    No waiting

    Apollo's store reads are asynchronous, so a warm cache still shows a loading frame. Baton's reads are synchronous on the main actor, so the first body has the data, and after a launch the image on disk has it too.

    +
    No model tree

    Apollo builds a model of the operation from the response's bytes, and builds it again from the store on every read. Baton decodes the bytes straight into record slots, by the plan the compiler emitted. A view reads a slot.

    +
    No re-execution

    When any field a query read changes, Apollo re-executes the whole query and rebuilds the tree. Baton notifies the slot that changed. Only the bodies that read it run.

    +
    No waiting

    Apollo's store reads are asynchronous, so a warm cache still shows a loading frame. Baton's reads are synchronous on the main actor. The first body has the data. After a launch, the image on disk has it too.

    -Each is work a view never asked for. How much of a frame it costs depends on the device and the data, so the head-to-heads stay in the repository, in [`BENCHMARKS.md`](https://github.com/shergin/baton/blob/master/BENCHMARKS.md), each with its revision, machine, OS and date, and [`docs/comparison.md`](https://github.com/shergin/baton/blob/master/docs/comparison.md) is the longer argument, with the places Apollo is ahead. +Each of those is work a view never asked for. How much of a frame it costs depends on the device and the data, so the head-to-heads stay in the repository, in [`BENCHMARKS.md`](https://github.com/shergin/baton/blob/master/BENCHMARKS.md). Each one has its revision, machine, OS, and date. [`docs/comparison.md`](https://github.com/shergin/baton/blob/master/docs/comparison.md) is the longer argument, including the places Apollo is ahead. ## The name @@ -219,9 +219,8 @@ Each is work a view never asked for. How much of a frame it costs depends on the
    -

    In a relay, the baton is the thing that is actually handed over. Here it is the data a screen hands each view: exactly what the view asked for, nothing else. In Russian the same word, батон, is a loaf of bread, which is why the logo is a loaf and every release is named for one.

    +

    In a relay race, the baton is the thing that actually gets handed over. Here it is the data a screen hands each view: exactly what that view asked for, and nothing else. In Russian the same word, батон, is a loaf of bread. That is why the logo is a loaf, and why every release is named for one.

    - diff --git a/site/content/playground.md b/site/content/playground.md index c57205aa..201da0a6 100644 --- a/site/content/playground.md +++ b/site/content/playground.md @@ -1,18 +1,18 @@ # Playground -Write a fragment and a query against the Rick and Morty schema, and read what the compiler makes of them: the lens in Swift and in Kotlin, the operation's text as the server would receive it, and the diagnostic that names the character that is wrong. The compiler on this page is `batonc` built to WebAssembly, the same crate the build plugins run, so what it writes here is what it writes in a build. +Write a fragment and a query against the Rick and Morty schema, and read what the compiler makes of them: the lens in Swift and in Kotlin, the operation's text as the server would receive it, and the diagnostic that names the wrong character. A lens is a typed window onto one record. The compiler on this page is `batonc`, built to WebAssembly, the same crate the build plugins run, so what it writes here is what it writes in a build. {{playground}} ## What to try -- Add `gender` to the fragment and watch one accessor appear in both lenses and one field in the operation's text. Nothing else moves. +- Add `gender` to the fragment. One accessor appears in both lenses, and one field appears in the operation's text. Nothing else moves. - Misspell a field. The diagnostic points at the line and the column inside the document, with the schema's suggestion. -- Declare a fragment no query spreads, and read the warning: a fragment nobody spreads is one nothing can read. +- Declare a fragment no query spreads, and read the warning. A fragment nobody spreads is one nothing can read. - Mark the fragment `@inline` and see a value in place of a lens: a struct in Swift, a data class in Kotlin. -- Put `@required(action: THROW)` on `name` and see the accessor stop being optional and start throwing. +- Put `@required(action: THROW)` on `name`. The accessor stops being optional and starts throwing. - Give the query `@cacheExpiration(seconds: 60)`. The text the server receives does not change, because the directive is the client's business. ## What this is not -A playground over a schema with no server behind it shows the compile-time half: what the build settles before the app runs. The run-time half, a response becoming records and a field change becoming one re-render, is on [One screen, end to end](../walkthrough/), drawn from the specification's fixtures. +A playground over a schema with no server behind it shows the compile-time half, what the build settles before the app runs. The run-time half is a response becoming records, and a field change becoming one re-render. That half is on [One screen, end to end](../walkthrough/), drawn from the specification's fixtures. diff --git a/site/content/start.md b/site/content/start.md index caa9cbcf..132ada56 100644 --- a/site/content/start.md +++ b/site/content/start.md @@ -1,10 +1,10 @@ # Getting started -Four things, in order: the package, the schema, the environment, the first screen. Then a fragment, a list and a write, which is most of what an app does. The GraphQL in every figure is compiled by the real compiler when this site is built, and the SwiftUI and Compose tabs show the same thing twice. +You add four things, in order: the package, the schema, the environment, the first screen. Then a fragment, a list, and a write. That is most of what an app does. The GraphQL in every figure is compiled by the real compiler when this site is built, and the SwiftUI and Compose tabs show the same thing twice. ## Install -In Swift, add the package and put the build plugin on each target that holds GraphQL. The plugin runs the compiler over the target's sources at build time; the binary comes with the release, prebuilt for macOS and Linux. +In Swift, add the package and put the build plugin on each target that holds GraphQL. The plugin runs the compiler over the target's sources at build time. The binary comes with the release, prebuilt for macOS and Linux. ```swift // Package.swift @@ -46,11 +46,11 @@ kotlin { } ``` -[Gradle](../recipes/gradle/) has the repositories, the Android variant wiring and the plugin's properties; [Bazel](../recipes/bazel/) has the rule; [the command line](../recipes/batonc/) is the contract everything else spells. +[Gradle](../recipes/gradle/) has the repositories, the Android variant wiring, and the plugin's properties. [Bazel](../recipes/bazel/) has the rule. [The command line](../recipes/batonc/) is the contract everything else spells. ## The schema -The compiler needs the server's schema as SDL, checked in beside the code. Download it with whatever your server offers, the server's own tooling or an introspection query, and name it in `baton.json` in the target's directory: +The compiler needs the server's schema as SDL, the schema written out as text, checked in beside the code. Download it with whatever your server offers, the server's own tooling or an introspection query, and name it in `baton.json` in the target's directory. ```json { @@ -58,11 +58,11 @@ The compiler needs the server's schema as SDL, checked in beside the code. Downl } ``` -That is a complete configuration for a schema whose objects carry an `id`. Identity is configurable when they do not, custom scalars can map to your types, and persisted operations are one more key; [the compiler](../guide/compiler/) lists them. The Kotlin target adds `"kotlin": {"package": "app"}` for the generated shared file. +That is a complete configuration for a schema whose objects carry an `id`. Identity is configurable when they do not, custom scalars can map to your types, and persisted operations are one more key. [The compiler](../guide/compiler/) lists them. The Kotlin target adds `"kotlin": {"package": "app"}` for the generated shared file. ## The environment -One environment per session, made at the root of the app and injected once. It holds the store, the transport and the image on disk. For an endpoint that takes a bearer token: +Make one environment per session, at the root of the app, and inject it once. It holds the store, the transport, and the image on disk. For an endpoint that takes a bearer token: ```swift tab @main @@ -100,49 +100,49 @@ fun App(tokens: TokenStore) { } ``` -`Types.schemaDigest` is generated: an image written under another schema starts again instead of being migrated. The credentials closure is read on every attempt, so a token the store renews reaches the next request without a new environment; on Android the image also needs a directory, `Persistence.named("App", version = Types.schemaDigest, directory = cacheDir.path)`. A production endpoint wants a retry and a deadline around that transport, which is [the exchange](../recipes/exchange/), a few dozen lines the app owns. +`Types.schemaDigest` is generated. An image written under another schema starts again, instead of being migrated. The credentials closure is read on every attempt. A token the store renews reaches the next request without a new environment. On Android the image also needs a directory, `Persistence.named("App", version = Types.schemaDigest, directory = cacheDir.path)`. A production endpoint wants a retry and a deadline around that transport. That is [the exchange](../recipes/exchange/), a few dozen lines the app owns. ## The first screen -A screen declares one query and switches over its phase. The query is a document in a marker, right in the file; the compiler finds it there. +A screen declares one query and switches over its phase. The query is a document, the GraphQL text, in a marker right in the file, where the compiler can find it. -{{host issues IssuesScreen | The screen's query and its three cases. `IssuesQuery` is generated from the document: a value of the two variables, which the parent constructs.}} +{{host issues IssuesScreen | The screen's query and its three cases. `IssuesQuery` is generated from the document. It is a value of the two variables, which the parent constructs.}} -The parent constructs the operation value, `IssuesQuery(owner:name:)`, and the view resolves it against the store when it appears: data already there renders in the first body, otherwise the environment fetches. `phase` is `loading`, `ready(data)` or `failed(error)`, and `retry` is right there for the failed case. +The parent constructs the operation value, `IssuesQuery(owner:name:)`, and the view resolves it against the store when it appears. Data already there renders in the first body. Otherwise the environment fetches, and `phase` is `loading`, `ready(data)`, or `failed(error)`, with `retry` right there for the failed case. ## The first fragment -The screen's query already spreads `IssueRow_issue`, so the row has to exist. A fragment is the fields one view reads, beside that view, and the view takes the fragment's lens as its one input: +The screen's query already spreads `IssueRow_issue`, so the row has to exist. Write the fields that one view reads, beside that view. That piece of GraphQL is a fragment, and the view takes the fragment's lens as its one input. A lens is a typed window onto one record. -{{host issues IssueRow | The row's fragment and its body. The parent writes `IssueRow(issue: node.issueRow)`; `issueRow` is the accessor the spread compiled to.}} +{{host issues IssueRow | The row's fragment and its body. The parent writes `IssueRow(issue: node.issueRow)`. `issueRow` is the accessor the spread compiled to.}} -Two rules that make the rest work: a view reads only through its own lens, and a parent never reads a child's fields. Add a field to the row's fragment and the row gets it; nothing in the screen changes. +Two rules make the rest work. A view reads only through its own lens, and a parent never reads a child's fields. Add a field to the row's fragment and the row gets it. Nothing in the screen changes. ## The first list -A list that pages is a connection on a refetchable fragment. The fragment owns the pagination, the store owns the merged pages, and the view reads them like any other field: +A list that pages is a connection on a refetchable fragment, which means the fragment has its own query for the next page. The fragment owns the pagination, the store owns the merged pages, and the view reads them like any other field. -{{host issue-list IssueList | The connection fragment, the row it spreads, and a screen that spreads the fragment in place of its own `issues` field. `loadNext` fetches the next page with the fragment's own query, and the store merges it in.}} +{{host issue-list IssueList | The connection fragment, the row it spreads, and a screen that spreads the fragment in place of its own `issues` field. `loadNext` fetches the next page with the fragment's own query. The store merges it in.}} [Lists](../guide/lists/) shows what the store holds after each page, from the fixture that proves it. ## The first write -A mutation compiles to an action. Call it with one argument per variable and, when the user should see the result at once, a typed optimistic response: +A mutation compiles to an action. Call it with one argument per variable, and, when the user should see the result at once, pass a typed optimistic response: a value shown immediately and reverted if the server refuses. -{{host star StarButton | A star toggle: a fragment for what it reads, two mutations for what it does. The optimistic response is a typed value that is reverted if the server refuses.}} +{{host star StarButton | A star toggle: a fragment for what it reads, two mutations for what it does. The optimistic response is a typed value. It is reverted if the server refuses.}} [Writes](../guide/writes/) has the rest: edge directives for lists, payloads committed by hand, and why there is no cache updater. ## When something is wrong -Misspell a field and the build fails at the character in the GraphQL text, in the file that carries it, with the schema's suggestion: +Misspell a field. The build fails at that character in the GraphQL text, in the file that carries it, with the schema's suggestion. -{{diagnostics typo | The compiler over a row with a typo. The position points into the string inside the Swift file, so the editor shows it on the line.}} +{{diagnostics typo | The compiler over a row with a typo. The position points into the string inside the Swift file. The editor shows it on the line.}} ## Previews and tests -A store is filled from a payload as well as from the network, so a preview or a test gets a real store without a server and without mocking anything of Baton's: +A store is filled from a payload as well as from the network. A preview or a test gets a real store, with no server, and without mocking anything of Baton's. ```swift tab let environment = Environment(transport: SilentTransport()) @@ -154,11 +154,11 @@ val environment = Environment(SilentTransport()) environment.commitPayload(IssuesQuery(owner = "shergin", name = "baton"), Payload(fixture)) ``` -[Previews and tests](../recipes/testing/) is the catalogue: recorded transports, a held mutation to observe an optimistic layer, a rule tested with a value. +[Previews and tests](../recipes/testing/) is the catalogue: recorded transports, a held mutation to observe an optimistic layer, and a rule tested with a value. ## Where next -- [One screen, end to end](../walkthrough/): what just happened, step by step, from the fragment to the next launch. -- [GraphQL for mobile engineers](../graphql/): the schema, the document and the response, if those words are new. +- [One screen, end to end](../walkthrough/): what happened, step by step, from the fragment to the next launch. +- [GraphQL for mobile engineers](../graphql/): the schema, the document, and the response, if those words are new. - [The twelve words](../guide/concepts/): the whole vocabulary on one page. - [Agents](../recipes/agents/): the shape of a screen and a review checklist, for a coding agent or for you. diff --git a/site/content/walkthrough.md b/site/content/walkthrough.md index 91588454..0df94a8c 100644 --- a/site/content/walkthrough.md +++ b/site/content/walkthrough.md @@ -1,95 +1,95 @@ # One screen, end to end -A character's header: a name, a status, a species, a picture, and where the character is from. Small enough to follow every step, and every step on this page is program output. The compiler compiled the documents below when the page was built; the response is one the specification recorded from the API; the records are dumps the runtime wrote under the specification's fixtures, and the notifications and the phases are what the specification requires of both runtimes. +A character's header, small enough to follow every step: a name, a status, a species, a picture, and where the character is from. Every step on this page is program output. The compiler compiled the documents below when the page was built, and the response is one the specification recorded from the API. The records are dumps the runtime wrote under the specification's fixtures, and the notifications and the phases are what the specification requires of both runtimes. ## 1. A fragment beside the view -The view declares the fields it reads, next to its body, and takes a lens as its one input. It cannot read a field it did not declare, because the lens has no accessor for one. +The view declares the fields it reads, next to its body, and takes a lens as its one input. A lens is a typed window onto one record, so the view cannot read a field it did not declare: the lens has no accessor for one. -{{host header CharacterHeader | The header's fragment and its body. `origin { name }` reads through a link to another record, and the lens makes that a nested lens.}} +{{host header CharacterHeader | The header's fragment and its body. `origin { name }` reads through a link to another record. The lens makes that a nested lens.}} ## 2. One query for the screen -The screen declares one query and spreads the header's fragment into it. The parent never lists the header's fields; it hands the header its lens through the spread's accessor, `characterHeader`. +The screen declares one query and spreads the header's fragment into it. The parent never lists the header's fields. It hands the header its lens through the spread's accessor, `characterHeader`. {{host header CharacterScreen | The screen's query, with one variable, and the three cases of its phase. `@cacheExpiration(seconds: 300)` says data older than five minutes reads as stale.}} ## 3. What the compiler makes of them -The compiler reads the schema and every document in the module, validates them, assembles the operation with the fragments it reaches, and adds the fields identity needs. This is the text the app will send, printed on one line as the compiler prints it: +The compiler reads the schema and every document in the module, validates them, assembles the operation from the fragments it reaches, and adds the fields identity needs. This is the text the app will send, printed on one line as the compiler prints it. -{{operation header CharacterQuery | The operation's text, the fragment printed after it. The compiler added `id` to the character and under `origin`, because both types are keyed by id and the store needs the keys to make the records; the directive on the query stays out of the text, since it is the client's business.}} +{{operation header CharacterQuery | The operation's text, the fragment printed after it. The compiler added `id` to the character and under `origin`, because both types are keyed by id and the store needs the keys to make the records. The directive on the query stays out of the text, since it is the client's business.}} -For each fragment and each operation it emits a lens: one accessor per declared field, typed as the schema types it. +For each fragment and each operation, the compiler emits a lens. You get one accessor per declared field, typed as the schema types it. -{{lens header CharacterHeader_character | The header's lens. `name` is optional because the schema says so; `origin` is a nested lens; nothing else exists on the type.}} +{{lens header CharacterHeader_character | The header's lens. `name` is optional because the schema says so. `origin` is a nested lens. Nothing else exists on the type.}} -And it writes the code. The generated Swift is one line per field plus the data tables the runtime walks a response by; there is no model, no decoder and no cache policy in it. +The compiler writes the code too. The generated Swift is one line per field, plus the data tables the runtime walks a response by, and there is no model, no decoder, and no cache policy in it. -{{generated header | What the compiler wrote for the two files above. The lens reads a slot per field through the anchor it holds; the operation value carries its variables, its text and its plan.}} +{{generated header | What the compiler wrote for the two files above. The lens reads a slot per field through the anchor it holds. A slot is one field of one record. The anchor is the record the lens is looking at. The operation value carries its variables, its text, and its plan.}} ## 4. The request -The view appears, and its handle checks the store. Nothing is there yet, so the environment sends the operation through the transport: the text above, with `{"id": "9"}` as the variables, as a standard GraphQL-over-HTTP request. Under `persistConfig` it would send the id of the text instead, and the server would look the text up. +The view appears, and its handle checks the store. Nothing is there yet, so the environment sends the operation through the transport. The request is the text above, with `{"id": "9"}` as the variables, as a standard GraphQL-over-HTTP request. Under `persistConfig` it would send the id of the text instead, and the server would look the text up. -While the request is out, the view reads `phase` as `loading`. Here is what a handle shows at each moment, from the specification's script of phases: +While the request is out, the view reads `phase` as `loading`. Here is what a handle shows at each moment, from the specification's script of phases. -{{script phase steps=1,2,3,4,5 | A handle attached with nothing in the store is loading with a fetch in flight; the answer makes it ready. A second attach of the same operation is ready at once, with no fetch. One attached under `storeAndNetwork` is ready and refreshing; when its refresh fails, the data stays and the fetch records the failure.}} +{{script phase steps=1,2,3,4,5 | A handle attached with nothing in the store is loading with a fetch in flight. The answer makes it ready. A second attach of the same operation is ready at once, with no fetch. One attached under `storeAndNetwork` is ready and refreshing. When its refresh fails, the data stays and the fetch records the failure.}} ## 5. The response -The server answers in the document's shape: +The server answers in the document's shape. {{json spec/rickandmorty/character-header-9.json | The recorded response for `id: "9"`. Five values, two objects, and the two ids the compiler asked for.}} ## 6. The records -The response's bytes are decoded straight into record slots, off the main actor, by the plan the compiler emitted, and committed on the main actor as one batch. Hover a field to see where it went: +The response's bytes are decoded straight into record slots, off the main actor, by the plan the compiler emitted. They are committed on the main actor as one batch. Hover a field to see where it went. {{normalize spec/rickandmorty/character-header-9 | Three records: `Character:9`, `Location:20`, and the root that links to the character under its storage key, `character(id:"9")`. The `origin` slot holds a link, not a copy.}} ## 7. The reads -The specification's manifest says what the lens must read at each path after that commit, and both runtimes are held to it: +The specification's manifest says what the lens must read at each path after that commit. Both runtimes are held to it. -{{case rickandmorty/character-header-9 | The reads the fixture requires, and the override below them: under an optimistic response that changes the name, the lens reads the new name until the server answers.}} +{{case rickandmorty/character-header-9 | The reads the fixture requires, and the override below them. Under an optimistic response that changes the name, the lens reads the new name until the server answers.}} -The view's body runs. `character.name` loads a slot; so do `status`, `species` and `image`; `origin` follows the link and `origin.name` loads a slot of the other record. Each read registers with Observation, so the body now depends on exactly six slots of those two records: five on the character, the `origin` link among them, and one on the location. +The view's body runs. `character.name` loads a slot. So do `status`, `species`, and `image`. `origin` follows the link, and `origin.name` loads a slot of the other record. Each read registers with Observation. The body now depends on exactly six slots of those two records: five on the character, the `origin` link among them, and one on the location. ## 8. A field changes -Later a refetch, another screen's query, a mutation's payload or a subscription's event changes a record. The commit compares each slot before it writes, and notifies only the slots that changed. The specification scripts this on a character with notes: +Later a refetch, another screen's query, a mutation's payload, or a subscription's event changes a record. The commit compares each slot before it writes, and notifies only the slots that changed. The specification scripts this on a character with notes. -{{script notifications steps=1,2,3 | The same response committed again notifies nothing. A response that renames the character notifies one slot, `Character:1.name`, and the bodies that read it run; a body that read only the notes does not.}} +{{script notifications steps=1,2,3 | The same response committed again notifies nothing. A response that renames the character notifies one slot, `Character:1.name`. The bodies that read it run. A body that read only the notes does not.}} -That is the whole re-render story. No watcher re-executes the query, no snapshot is diffed; a body that read a field is invalidated when that field of that record changes, and at no other time. +That is the whole re-render story. No watcher re-executes the query. No snapshot is diffed. A body that read a field is invalidated when that field of that record changes, and at no other time. ## 9. A write, seen at once -A mutation's optimistic response is a typed value that goes through the same ingest as a server's answer and is applied as a layer. The layer shows in the turn of the call; the server's answer replaces it; a failure reverts it, and the store notifies only what differs in the end. +A mutation's optimistic response is a typed value that goes through the same ingest as a server's answer. Ingest is how a payload becomes records. The value is applied as a layer, shows in the turn of the call, and is replaced by the server's answer or reverted if the server refuses. The store notifies only what differs in the end. -{{script optimistic steps=2,4,5,6 | A rename applied optimistically notifies `name`; the server's answer resolves it with no further notification, since the value is the same; a second layer applied and reverted leaves the resolved name in place.}} +{{script optimistic steps=2,4,5,6 | A rename applied optimistically notifies `name`. The server's answer resolves it with no further notification, since the value is the same. A second layer applied and reverted leaves the resolved name in place.}} ## 10. The image, and the next launch -Every server batch is written behind to an image, one SQLite file, off the main actor. When the app launches again, the handle's availability check reads what memory lacks from the image before the first body runs, so the screen the user left is the screen they get back, before any request. +Every server batch is written behind to an image, one SQLite file, off the main actor. When the app launches again, the handle's availability check reads what memory lacks from the image, before the first body runs. The screen the user left is the screen they get back, before any request. -{{script check steps=1,2,3,4,5,7 | A commit writes records; the check for an operation whose data is in memory answers `memory`, and for one it lacks, `miss`; after a relaunch the first check answers `image`, and the lens reads the same value, while the miss is still a miss, which a handle would fetch.}} +{{script check steps=1,2,3,4,5,7 | A commit writes records. The check for an operation whose data is in memory answers `memory`, and for one it lacks, `miss`. After a relaunch the first check answers `image`, and the lens reads the same value. The miss is still a miss, which a handle would fetch.}} -The image is a cache, not a database. An image written under another schema, another format or another identity configuration starts again; a record that goes a whole launch unread is dropped at the next; optimistic layers never reach it. +The image is a cache, not a database. An image written under another schema, another format, or another identity configuration starts again. A record that goes a whole launch unread is dropped at the next. Optimistic layers never reach it. ## 11. Signing out -The environment is the session. Ending it cancels what it started, closes the image, and leaves every handle failed as gone; a new environment makes its own store, on the same file or another. +The environment is the session. Ending it cancels what it started, closes the image, and leaves every handle failed as gone. A new environment makes its own store, on the same file or another. -{{script end steps=1,2,3,4 | Two handles attached, one still fetching, and an optimistic rename applied; then the environment ends: every handle reads as gone, the records are cleared, and nothing is logged. Signing out is `await environment.end()` and a new environment.}} +{{script end steps=1,2,3,4 | Two handles attached, one still fetching, and an optimistic rename applied. Then the environment ends. Every handle reads as gone, the records are cleared, and nothing is logged. Signing out is `await environment.end()` and a new environment.}} ## Run it -The screen on this page is a trimmed copy of the Rick and Morty sample, which is in the repository: +The screen on this page is a trimmed copy of the Rick and Morty sample, which is in the repository. ```sh swift run RickAndMorty ``` -Quit it, launch it again, and the characters are there before the network answers. Then change the header's fragment, add `type`, and build: the lens gains an accessor, the operation's text gains a field, and nothing else in the app moves. Or do the same without leaving this site, in [the playground](../playground/). +Quit it, launch it again, and the characters are there before the network answers. Then change the header's fragment, add `type`, and build. The lens gains an accessor. The operation's text gains a field. Nothing else in the app moves. Or do the same without leaving this site, in [the playground](../playground/). diff --git a/site/src/gallery.rs b/site/src/gallery.rs index da71e62c..ae9a2d77 100644 --- a/site/src/gallery.rs +++ b/site/src/gallery.rs @@ -38,7 +38,7 @@ const ENTRIES: &[Entry] = &[ config: Some("examples/GitHubTriage"), hosts: ".", language: Language::Swift, - story: "What the viewer has to look at: open issues and pull requests assigned to or created by them, a repository with a star toggle, and an issue with its comments and reactions. Reads, writes with optimistic responses, interfaces and a union, a connection a mutation appends to, `@required` and `@catch`, and a lookup by id, against a schema of about 1,800 definitions. The README's screen is this app's.", + story: "The work a person actually has to look at: open issues and pull requests assigned to them or opened by them, a repository with a star toggle, and an issue with its comments and reactions. The sample covers reads, writes with optimistic responses, interfaces and a union, a connection a mutation appends to, `@required` and `@catch`, and a lookup by id. The schema is about 1,800 definitions, and the screen on the README is this app.", run: "GITHUB_TOKEN=$(gh auth token) swift run GitHubTriage", }, Entry { @@ -48,7 +48,7 @@ const ENTRIES: &[Entry] = &[ config: Some("kotlin/samples/github"), hosts: "src/jvmMain/kotlin/baton/github", language: Language::Kotlin, - story: "The same screens over GitHub's API, as a Compose for Desktop app: the same documents, the same lenses and the same phase, in Kotlin's words. It sends through the Kotlin exchange and keeps an image in the user's cache directory, so a relaunch renders before the network answers.", + story: "The same screens, over GitHub's API, as a Compose for Desktop app. Same documents, same lenses, same phase, in Kotlin's words. It sends through the Kotlin exchange and keeps an image in the user's cache directory. Relaunch, and the screen draws before the network answers.", run: "GITHUB_TOKEN=$(gh auth token) gradle :samples:github:run", }, Entry { @@ -58,7 +58,7 @@ const ENTRIES: &[Entry] = &[ config: Some("examples/RickAndMorty"), hosts: ".", language: Language::Swift, - story: "The public Rick and Morty API through Baton: characters, their details, episodes and locations share one store, so a character seen in a list, in an episode's cast and among a location's residents is one record. The store keeps an image on disk: quit and launch again, and the screens seen before are drawn from it before the network answers. The schema is small enough to read whole, which is why this site's GraphQL chapter uses it.", + story: "The public Rick and Morty API, through Baton. Characters, their details, episodes, and locations share one store. Rick in the list, Rick in an episode's cast, and Rick among a location's residents are one record. The store keeps an image on disk. Quit and launch again, and the screens you already saw draw from it before the network answers. The schema is small enough to read whole, which is why the GraphQL chapter uses it.", run: "swift run RickAndMorty", }, Entry { @@ -68,7 +68,7 @@ const ENTRIES: &[Entry] = &[ config: Some("kotlin/samples/shared"), hosts: "src/commonMain/kotlin/baton/sample", language: Language::Kotlin, - story: "The same screens as Compose Multiplatform, shared by a Compose for Desktop app (`kotlin/samples/desktop`, with an inspector pane over the store) and an Android app (`kotlin/samples/android`, which tells the environment the process's lifecycle). The Android app has an Apollo Kotlin twin, `kotlin/samples/apollo-android`, the same two screens over Apollo Kotlin, for a head-to-head over the same launch.", + story: "The same screens as Compose Multiplatform. A Compose for Desktop app (`kotlin/samples/desktop`, with an inspector pane over the store) and an Android app (`kotlin/samples/android`, which tells the environment the process's lifecycle) share them. The Android app has an Apollo Kotlin twin, `kotlin/samples/apollo-android`: the same two screens, for a head-to-head over the same launch.", run: "gradle :samples:desktop:run", }, Entry { @@ -78,7 +78,7 @@ const ENTRIES: &[Entry] = &[ config: Some("examples/Controllers"), hosts: ".", language: Language::Swift, - story: "A `UIViewController` and an `NSViewController` holding the same handle a view does: the operations live in a `.graphql` file, the controller asks the environment for the handle, holds the retention, renders what it reads, and renders again when that changes. The recipe is [UIKit and AppKit](/recipes/uikit/).", + story: "A `UIViewController` and an `NSViewController` hold the same handle a view does. The operations live in a `.graphql` file. The controller asks the environment for the handle, holds the retention, draws what it reads, and draws again when that changes. The recipe is [UIKit and AppKit](/recipes/uikit/).", run: "swift build --target Controllers", }, Entry { @@ -88,7 +88,7 @@ const ENTRIES: &[Entry] = &[ config: None, hosts: ".", language: Language::Swift, - story: "The few dozen lines a production endpoint needs and the library does not ship: a transport that wraps a transport, with one replay of a challenge, a bounded retry with backoff, a deadline across every attempt, and the rule that a mutation is never sent twice. The Kotlin twin is `kotlin/samples/exchange`. The recipe is [the exchange](/recipes/exchange/).", + story: "The few dozen lines a production endpoint needs, which the library does not ship: a transport that wraps a transport, one replay of a challenge, a bounded retry with backoff, a deadline across every attempt, and the rule that a mutation is never sent twice. The Kotlin twin is `kotlin/samples/exchange`. The recipe is [the exchange](/recipes/exchange/).", run: "", }, ]; @@ -119,7 +119,7 @@ pub fn url_for(repo_path: &str) -> Option { pub fn render(context: &Context) -> Body { let root = &context.site.root; let mut source = String::new(); - source.push_str("

    Every sample is compiled in the repository's checks, and what each plate below lists was found by the compiler when this page was built: the operations each sample sends and the fragments its views declare. The sources are linked; read one screen whole before writing one.

    \n\n"); + source.push_str("

    Every sample is compiled in the repository's checks. Each plate below is what the compiler found when this page was built: the operations the sample sends, and the fragments its views declare. The sources are linked. Read one screen whole before you write one.

    \n\n"); source.push_str("
      "); for entry in ENTRIES { source.push_str(&format!( @@ -154,7 +154,7 @@ pub fn render(context: &Context) -> Body { } } source.push_str("## Caton {#caton}\n\n"); - source.push_str("[Caton](https://github.com/shergin/caton) is the demo app: a macOS menu bar inbox for GitHub notifications, built to show this framework in a real product. It joins each notification to the live state of its pull request or issue, and the number in the menu bar is the number of things waiting on you.\n"); + source.push_str("[Caton](https://github.com/shergin/caton) is the demo app. A macOS menu bar inbox for GitHub notifications, built to show this framework in a real product. It joins each notification to the live state of its pull request or issue. The number in the menu bar is the number of things waiting on you.\n"); markdown::render(&source, context) } diff --git a/site/src/main.rs b/site/src/main.rs index eee1069e..b71dfebd 100644 --- a/site/src/main.rs +++ b/site/src/main.rs @@ -196,7 +196,7 @@ fn index_source(section: &Section, index: &Page) -> String { let mut out = String::new(); // A decisions index keeps the repository's README as its text. if index.url == "/decisions/" { - out.push_str("

      These are not principles: a principle must not rot, a decision may be superseded, and says so in its status line. Each record has the same shape: a status line, then Context, Decision, Evidence, Not chosen. The list is read from the directory, so a new record is a new page.

      \n\n"); + out.push_str("

      These are not principles. A principle must not rot, while a decision may be superseded, and its status line says so. Each record has the same shape: a status line, then Context, Decision, Evidence, Not chosen. The list is read from the directory, so a new record is a new page.

      \n\n"); } out.push_str("
        \n"); for page in §ion.pages { diff --git a/site/src/pages.rs b/site/src/pages.rs index d20cc88f..668ef92a 100644 --- a/site/src/pages.rs +++ b/site/src/pages.rs @@ -134,31 +134,31 @@ pub fn sections(root: &Path) -> Vec
        { page( "/", "Baton", - "Relay for SwiftUI and Compose: a fragment beside every view, one request per screen, cached data in the first frame, and a re-render only where a field changed.", + "Relay for SwiftUI and Compose. You write a fragment beside each view. One request per screen, cached data on the first frame, and a re-render only where a field changed.", Source::Home, ), content( "/start/", "Getting started", - "Install, the schema, the first screen, the first fragment, and the first list. SwiftUI and Compose side by side.", + "The package, the schema, the environment, and the first screen. Then a fragment, a list, and a write. SwiftUI and Compose, side by side.", "start.md", ), content( "/graphql/", "GraphQL for mobile engineers", - "What a schema is, what a document asks, why the response has the document's shape, what an id buys, and what Relay adds. No prior GraphQL needed.", + "GraphQL, explained from REST. The menu, the order, the one id, and the lists. No prior GraphQL needed.", "graphql.md", ), content( "/walkthrough/", "One screen, end to end", - "A screen followed from the fragment beside its view to the frame it renders: the operation the compiler assembles, the request, the response, the records, the reads, the one view that re-renders, and the image on disk.", + "One character's header, followed from the fragment beside the view to the next launch. Every step on the page is program output.", "walkthrough.md", ), page( "/playground/", "Playground", - "Write a fragment and a query in the browser and watch the compiler answer: the lens in Swift and Kotlin, the operation's text, and the diagnostic that names the character that is wrong.", + "Type a fragment. Read the lens in Swift and Kotlin, the text the server would get, and the diagnostic that points at the typo.", Source::Playground, ), ], @@ -169,61 +169,61 @@ pub fn sections(root: &Path) -> Vec
        { content( "/guide/concepts/", "The twelve words", - "Schema, document, directive, lens, record, store, plan, operation value, connection, environment, transport, phase: the closed vocabulary, one plate each.", + "Twelve words, and then the list is closed. One plate each, so you can see the thing the word names.", "guide/concepts.md", ), content( "/guide/reads/", "Reads", - "A query per screen, a fragment per view, a lens per fragment, and a phase a view switches over. Fetch policies, refetch, retry.", + "One query per screen, one fragment per view, and a phase you can switch on. Fetch policies, refetch, and retry.", "guide/reads.md", ), content( "/guide/store/", "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.", + "Records by type and id, what a view actually watches, and the image on disk that draws the next launch.", "guide/store.md", ), content( "/guide/lists/", "Lists", - "A connection on a refetchable fragment: pages merged in the store, `loadNext` on the lens, and the edge directives that add to or remove from it.", + "A list that pages in the store. `loadNext` on the lens, and a directive to add or remove an edge.", "guide/lists.md", ), content( "/guide/writes/", "Writes", - "A mutation is an action; an optimistic response shows at once and is reverted on failure; nothing edits the cache by hand.", + "A mutation is an action. The optimistic change shows at once, and comes off if the server says no.", "guide/writes.md", ), content( "/guide/honest-data/", "Honest data", - "Nullability as the schema says it, `@required` and `@catch`, field errors that survive caching, missing data that heals, enums and mapped scalars.", + "Null means what the schema says. `@required` and `@catch`, errors that survive the cache, and missing data that gets healed.", "guide/honest-data.md", ), content( "/guide/live/", "Live data", - "Subscriptions as a stream of events into the store, deferred fragments, streamed lists, and what a handle does when the connection drops or the app sleeps.", + "Subscriptions, deferred fragments, and streamed lists. What the handle does when the connection drops, or the app sleeps.", "guide/live.md", ), content( "/guide/compiler/", "The compiler", - "What `batonc` reads, what it checks, what it writes, and how the build runs it: SwiftPM, Gradle, Bazel, or the command line.", + "What `batonc` reads, checks, and writes. SwiftPM, Gradle, Bazel, or the command line.", "guide/compiler.md", ), content( "/guide/environment/", "The environment", - "The one door: the transport with one verb, the session that ends at sign-out, the log of value-free events, and a store without a server for previews and tests.", + "One door. A transport with one verb, a session that ends at sign-out, and a store you can use with no server.", "guide/environment.md", ), content( "/guide/kotlin/", "Compose", - "The same screens in Kotlin: where the marker stands, what differs in spelling, and the Gradle setup.", + "The same screens in Kotlin. Where the marker stands, what the spelling changes, and the Gradle setup.", "guide/kotlin.md", ), ], @@ -234,7 +234,7 @@ pub fn sections(root: &Path) -> Vec
        { page( "/recipes/", "Recipes", - "One page per composition of what ships: the shape of a screen, the exchange, testing, UIKit, view models, porting from Relay, and the build integrations.", + "One page for each thing you actually put together. A screen, the exchange, tests, UIKit, view models, Relay, and the builds.", Source::Index, ), repo( @@ -322,7 +322,7 @@ pub fn sections(root: &Path) -> Vec
        { pages: vec![page( "/examples/", "The gallery", - "Every sample in the repository, Swift and Kotlin, with what the compiler found in it: the GitHub triage apps, Rick and Morty, UIKit and AppKit controllers, the exchange, and Caton.", + "Every sample, Swift and Kotlin, and what the compiler found in it. GitHub triage, Rick and Morty, UIKit, the exchange, and Caton.", Source::Gallery, )], }, @@ -410,7 +410,7 @@ fn why(root: &Path) -> Vec { repo_with( "/vision/", "Vision", - "The argument: three commitments and five rules.", + "Three commitments and five rules. The data layer gets out of the way of the screen.", "docs/vision.md", &[( "## The rules", @@ -420,7 +420,7 @@ fn why(root: &Path) -> Vec { repo_with( "/why-graphql/", "Why GraphQL", - "For iOS and Android engineers who build on REST and view models: what it is, what it changes, what it costs.", + "For people who build iOS and Android screens on REST and view models. What GraphQL changes, and what it costs.", "docs/why-graphql.md", &[ ( @@ -522,63 +522,63 @@ fn articles() -> Vec { ( "relay-without-the-read-side", "Relay without the read side", - "Why Baton keeps Relay's compiler and the write side of its store and deletes the read side: Observation and snapshot state already answer the question Relay's runtime was built to answer.", + "Baton keeps Relay's compiler and the write side of its store, and drops the read side. Observation and snapshot state already know which view read which field.", ), ( "what-meta-built", "What Meta built, and what you can download", - "Meta's native apps run an in-house GraphQL client that is not public. What its engineers have said about it, where Baton makes the same choices, and where Baton goes further.", + "Meta's native apps run a GraphQL client you cannot download. What its engineers have said, where Baton agrees, and where Baton goes further.", ), ( "materialization-is-the-bill", "Materialization is the bill", - "Why Baton is faster than Apollo iOS and Apollo Kotlin on the same data: not a faster store, but three steps it never takes. The numbers on a Mac and on a phone, and the caveats.", + "Baton is faster than Apollo on the same data because it skips three steps, not because the store is cleverer. The numbers, on a Mac and on a phone, with the caveats.", ), ( "the-first-frame", "The first frame has the data", - "A warm cache that still shows a spinner is a design, not a bug. Why reads are synchronous on the main actor, what the image on disk does before the first body, and what a phone measured.", + "A warm cache that still shows a spinner is a design. Reads are synchronous, the image on disk feeds the first body, and a phone measured it.", ), ( "two-runtimes-no-bridge", "Two runtimes, no bridge", - "The compiler is Rust; the runtimes are Swift and Kotlin, written twice on purpose. Why a shared core would cost the read path the design exists for, and what holds two runtimes to one behaviour.", + "The compiler is Rust. The runtimes are Swift and Kotlin, written twice on purpose. A shared core would cost the read path this design exists for.", ), ( "a-compiler-not-a-configuration", "A compiler, not a configuration", - "Every operation is static, so identity, storage keys, masking, persisted ids and diagnostics are settled at build time. Why the runtime has no parser and no policy object, and why that matters more now that agents write screens.", + "Every operation is known at build time. The runtime has no parser and no policy object. That matters more now that agents write the screens.", ), ( "how-a-fast-store-stays-honest", "How a fast store stays honest", - "A normalized store is a chain of clever steps, and every step is a place to lose a value. The oracle, the fixtures, Relay's own tests and the benches that keep Baton's answers equal to the server's, on both runtimes.", + "A fast store is a chain of clever steps, and every step can drop a value. The fixtures, Relay's own tests, and the benches that keep the answer equal to the server's.", ), ( "twelve-words", "Twelve words, and why the list is closed", - "GraphQL clients die of configuration. Baton's vocabulary is twelve concepts and Relay's directives; what that refuses, what it buys, and how it looks beside a client that dropped GraphQL.", + "GraphQL clients die of configuration. Baton's vocabulary is twelve words and Relay's directives. What that refuses, and what it buys.", ), ( "baton-beside-fate", "Baton beside fate", - "fate takes Relay's three insights and removes GraphQL; Baton takes them and keeps the compiler. Where the two agree, where they part, three of fate's mechanisms Baton watches, and a proof against fate's own server.", + "fate takes Relay's insights and removes GraphQL. Baton takes them and keeps the compiler. Where they agree, where they part, and a proof against fate's own server.", ), ( "the-small-lies", "The small lies a client tells", - "Every field optional, an error hidden behind a null, a failure that is a string, missing data that is nothing, a value that does not fit read as zero, cached served as fresh: six lies, and the mechanism that refuses each.", + "Six small ways a GraphQL client lies about data, and the mechanism that refuses each one.", ), ( "settled-stays-settled", "Settled stays settled", - "How Baton is built: research first, principles with the mechanism that fails when they rot, decisions with the condition that reopens them, spikes with gates written first, fixtures as the oracle, and a release whenever there is something to use.", + "How the library is built. Research first, a mechanism for each principle, a condition that reopens each decision, and a release when there is something to use.", ), ]; let mut pages = vec![page( "/articles/", "Articles", - "The design argued at length: one essay per choice that shapes the library, with the evidence behind it and the alternatives it refused.", + "The long version. One essay per choice, with the numbers, the alternatives, and what was refused.", Source::Index, )]; for (stem, title, blurb) in essays {