From c618916e2fa690138bcf31a715b397a580702aa6 Mon Sep 17 00:00:00 2001 From: Valentin Shergin Date: Tue, 13 Oct 2026 17:43:29 -0700 Subject: [PATCH] Draft three more design articles Baton beside fate, the small lies a client tells, and settled stays settled, with their entries on the site, in the docs index and in the changelog. --- CHANGELOG.md | 5 +- docs/README.md | 5 +- docs/articles/README.md | 14 +- docs/articles/baton-beside-fate.md | 167 +++++++++++++++++++++++ docs/articles/settled-stays-settled.md | 165 +++++++++++++++++++++++ docs/articles/the-small-lies.md | 176 +++++++++++++++++++++++++ site/content/home.md | 2 +- site/src/pages.rs | 15 +++ 8 files changed, 542 insertions(+), 7 deletions(-) create mode 100644 docs/articles/baton-beside-fate.md create mode 100644 docs/articles/settled-stays-settled.md create mode 100644 docs/articles/the-small-lies.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a95a5a4b..c873fe35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,14 @@ are expected and listed without apology. ## Unreleased -- Eight articles under `docs/articles/`, and an Articles section on the +- Eleven articles under `docs/articles/`, and an Articles section on the site: the design argued at length, one essay per choice that shapes the library, with its evidence and the alternatives it refused. Relay without the read side; what Meta built, and what you can download; materialization is the bill; the first frame has the data; two runtimes, no bridge; a compiler, not a configuration; how a fast store stays - honest; twelve words, and why the list is closed. Every number in them + honest; twelve words, and why the list is closed; Baton beside fate; the + small lies a client tells; settled stays settled. Every number in them is one `BENCHMARKS.md` already holds, with its machine and date, or is attributed to the public source it came from. - The Relay harvest runs its `@stream` cases. The five normalizer tests diff --git a/docs/README.md b/docs/README.md index c4012a6e..bec90943 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,8 +19,9 @@ What to read when. - **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, and - the twelve words. + 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. - **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. diff --git a/docs/articles/README.md b/docs/articles/README.md index 9f49a3d5..5eb3e7f2 100644 --- a/docs/articles/README.md +++ b/docs/articles/README.md @@ -31,5 +31,15 @@ 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 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 + 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 + 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 + dropped. diff --git a/docs/articles/baton-beside-fate.md b/docs/articles/baton-beside-fate.md new file mode 100644 index 00000000..4b81920c --- /dev/null +++ b/docs/articles/baton-beside-fate.md @@ -0,0 +1,167 @@ +# 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. + +## 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 +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. + +| fate | Baton | +|---|---| +| A release buffer of ten, "inspired by Relay" | The release buffer, ten by default ([the terminology](../terminology.md)) | +| An optimistic layer over the confirmed base, rebased beneath later writes | Optimistic layers over the base, rebased on every commit | +| A partial optimistic record whose absent fields leave the store alone | A typed optimistic response that carries part of a selection | +| A request marked stalled after one refetch | The heal refetches once per fetch ([the principle](../principles/honest-data.md)) | +| An age per request | An expiration per operation, stated in its document ([the decision](../decisions/an-operation-states-its-expiration.md)) | +| A hydration scope that refuses another client's snapshot | The image's digest: a file written under another schema starts again ([the decision](../decisions/what-may-reach-the-image.md)) | +| 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. + +## 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 +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. +- **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 + 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 + 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)). + +## 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 +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. + +**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 +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 +awkward. + +## Two things taken + +Two things came out of the reading, neither a change to the runtime. + +The first is a page. fate's `AGENTS.md` per template is the right idea, and +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. + +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 +[`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. + +## 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. + +## 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 +[`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. diff --git a/docs/articles/settled-stays-settled.md b/docs/articles/settled-stays-settled.md new file mode 100644 index 00000000..aefff0af --- /dev/null +++ b/docs/articles/settled-stays-settled.md @@ -0,0 +1,165 @@ +# 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. + +## 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. + +## 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. + +## 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 +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 +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 +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. + +## 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, +[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 +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. + +## 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. + +## 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. + +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 +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. diff --git a/docs/articles/the-small-lies.md b/docs/articles/the-small-lies.md new file mode 100644 index 00000000..6f1b1e22 --- /dev/null +++ b/docs/articles/the-small-lies.md @@ -0,0 +1,176 @@ +# 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 +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. + +## The first lie: every field is optional + +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. + +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 +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. + +## 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. + +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` +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 +([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. + +## 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 +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 +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 +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 +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. + +## 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. diff --git a/site/content/home.md b/site/content/home.md index 241a65a8..bec927a7 100644 --- a/site/content/home.md +++ b/site/content/home.md @@ -183,7 +183,7 @@ The vocabulary is closed and short. A feature is a composition of these, or a Re
  • 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.
  • +
  • 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.
  • diff --git a/site/src/pages.rs b/site/src/pages.rs index fb9be00d..a929a240 100644 --- a/site/src/pages.rs +++ b/site/src/pages.rs @@ -547,6 +547,21 @@ fn articles() -> Vec { "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.", ), + ( + "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.", + ), + ( + "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.", + ), + ( + "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.", + ), ]; let mut pages = vec![page( "/articles/",