Skip to content

Releases: shergin/baton

0.12.0 Palianytsia

Choose a tag to compare

@github-actions github-actions released this 08 Oct 08:55
  • Baton and Apollo Kotlin end to end on a phone: kotlin/samples/apollo-android,
    the Android sample's twin over Apollo Kotlin 5.2.0 and its memory and SQL
    caches, and kotlin/benchmarks/macro, a Macrobenchmark module that drives
    both release builds against a fixed server in each app's process. On a
    Google Pixel 9, a cold start over the data on disk shows the list in
    240 ms, in the first frame, against Apollo's 272 ms after a spinner; a
    tap to a detail takes 22 ms against 33 ms; scrolling is the same in both.
    From the response's first byte to the list's frame Apollo is faster,
    42 ms against 58 ms. The Android sample's release build is signed with
    the debug key, its screens report their first draws, and a launch can
    ask for the fixed server.
  • Apollo Kotlin measured beside Baton, kotlin/benchmarks/apollo-comparison:
    the same operation and graph through Apollo Kotlin 5.2.0 and its
    normalized cache 1.0.9, configured as documented, on the JVM and on a
    Google Pixel 9 in a release build. On the phone the response is in
    Baton's store in 6.5 ms and in Apollo's in 44.6 ms, and Apollo's read of
    the query back takes 25 ms where Baton's availability check takes 1.2 ms;
    a field of Apollo's read model costs 2 ns against 43 ns for a lens read.
    docs/comparison.md quotes these in place of Apollo's own bench.
  • The Kotlin runtime's first numbers on a device, in BENCHMARKS.md: on a
    Google Pixel 9 running Android 17, the 899-record fixture tokenizes in
    19.0 ms off the main thread and commits in 6.7 ms on it, a debuggable
    device-test build, and in 5.1 ms and 1.3 ms from a release build that is
    not debuggable, kotlin/benchmarks/android, the medians of 300 runs;
    the native-runtimes record carries the number as the ingest budget's
    first evidence.
  • The desktop sample keeps its page bar through a failure, so a page the
    public API refused (HTTP 429, Cloudflare's 1015 under a burst) can be
    left or retried; caches avatars for the process; keys its rows by
    recordID; keeps the store's image under the schema's digest, so a
    relaunch shows the characters before the network answers; and draws its
    screens to PNG files without a window through
    gradle :samples:desktop:screenshot.
  • A second Kotlin sample, kotlin/samples/github, the Compose for Desktop
    twin of examples/GitHubTriage: sign-in with a token kept in memory, a
    repository with a star toggle whose optimistic response flips the star and
    the count, its open issues as a connection that loads the next page at
    the list's end, an issue whose composer appends an optimistic comment by
    the viewer through @appendEdge, and sign-out that ends the environment
    and removes the image. Its tests run the screens over ScriptedTransport.
  • A Kotlin operation's companion is a QueryType, MutationType or
    SubscriptionType naming the operation's class beside its data, so
    val rename = rememberMutation(RenameMutation) infers its action's type.
    OperationType and the three are application API, outside the
    baton.Generated opt-in that an app's call to rememberMutation failed
    before; what generated code alone calls on them stays inside it.
  • @Fragment, @Query, @Mutation and @Subscription repeat in Kotlin,
    so one composable can host several documents, as a button that stars and
    unstars hosts two mutations.
  • The Kotlin Retention is Hold (handle.retain(): Hold,
    hold.release()), since baton.Retention hid
    kotlin.annotation.Retention from every file that writes
    import baton.*. Swift keeps Retention.
  • Phase, Fetch and MutationAction are @Stable in Kotlin, so a
    composable handed a failed phase skips like any other. The Compose
    compiler's reports, turned on with -PcomposeReports=true, show every
    generated lens and LensList stable and every composable of the desktop
    sample restartable and skippable.
  • baton-inspector, the Kotlin store inspector: StoreInspector(environment),
    a live, searchable Compose view of the store's records by type with their
    fields, values and field errors, and StoreExport.text(store), the dump.
    The desktop sample shows it in a third pane from its View menu
    (Command-I), and follows the system's light or dark appearance.
  • The Kotlin runtime builds for Android (API 23 and up): the image on the
    system's SQLite through AndroidSQLiteDriver, HttpTransport and
    Environment(url) shared with the JVM, and
    Persistence.named(name, directory = cacheDir.path), since the runtime
    holds no Context. baton-inspector builds for Android too; the
    desktop sample's screens moved to kotlin/samples/shared, which
    kotlin/samples/android, a phone app, shows as well; and
    IngestBenchmark, a device test, logs the Fixture's ingest and commit
    medians with the device's model for the ingest budget.

0.11.0 Karavai

Choose a tag to compare

@github-actions github-actions released this 08 Oct 03:03
  • rules_baton, a Bazel module under bazel/, versioned with Baton: a
    toolchain its extension fetches from the release's artifact bundle,
    selecting the variant for the execution platform, or from the compiler
    BATON_COMPILER names; baton_generate, the command with every input
    and output declared, one output per source named as the SwiftPM plugin
    names it, whose files a swift_library or a kt_jvm_library lists in
    its srcs, with the report and the persisted documents file as output
    groups; and baton_check_test, --check over committed output.
    docs/recipes/bazel.md is its page, in place of the genrule on the
    command's contract page, and
    docs/decisions/a-build-integration-holds-no-logic.md records why a
    shell holds no logic and wraps no library rule. Answers issue 40.
  • batonc generate --persisted <file> names where the persisted documents
    file is written, as --shared and --report name theirs, so a build
    system that declares its outputs before it reads baton.json can declare
    this one; without it the file goes under --out, else beside the
    configuration, as before. The contract page now also says that --schema
    beside --config overrides the configuration's schema, and that any
    source an --emit names gets a header-only output when it holds no
    GraphQL, both of which the compiler already did.
  • The compiler's artifact bundle carries a static Linux binary for x86_64
    and one for aarch64 beside the universal macOS one, listed under the
    gnu triples a Linux host reports and built with the musl target, so one
    binary runs on any distribution. SwiftPM on a Linux host and a Linux CI
    runner take the compiler from the same bundle a Mac downloads; the
    release workflow builds each on a host of its architecture, and CI builds
    and runs them so a release is never their first build. The Linux half of
    issue 40.
  • A subscription's stream that fails on its environment, as one with no
    subscription transport does, ends with that failure as a request error
    ends it, in both runtimes, instead of retrying on the backoff forever;
    retry() opens it again. A transport failure still reconnects on the
    backoff.
  • The Kotlin host's marker is recorded in
    docs/decisions/the-kotlin-host-marks-a-document-on-the-composable.md:
    a document is an annotation on the composable that renders, holds or
    acts, written in a $$ raw string since $ is a template in Kotlin and
    a variable in GraphQL; answers issue 6.
  • batonc writes Kotlin lenses: a @Stable class per fragment and per
    operation's Data, equal by its anchor, with a property per field over
    the anchor's readers and a companion of its checks; an @inline
    fragment as a data class read once; a connection's state, loadNext
    and refetch. A mutation gets its OptimisticResponse and a
    suspend operator fun invoke on its MutationAction. A generated enum's
    case for an undeclared value is Undeclared, since Unknown and a
    schema value UNKNOWN are one class file on a file system that ignores
    case. The Kotlin runtime's tests now run every case of the manifest,
    its records and its reads, through the code batonc generates from
    spec/sources.
  • The Kotlin runtime has its environment: the wire's Request and
    standard encoding, a Transport whose one verb returns a Flow, the
    handle with its fetch policies, its derived Phase and its Fetch,
    roots with retention, ages, the verdict and collection, the
    availability check, the heal, mutate, commitPayload, end() and
    the value-free log; baton-testing holds the scripted, recorded and
    silent transports. Every script of the manifest runs through it, to the
    steps that wait for the image, the subscriptions and the optimistic
    layers. A Kotlin operation's companion forwards its Data's
    fieldErrors and missingRequiredField, so the handle judges its data.
  • The Kotlin target refuses, with an error at the name, a field named
    Variable or Variables, and a fragment or an operation named like
    what the generated Kotlin calls or declares where it names the class
    (Unit, OptIn, JvmName, listOf, anchor, equals, fieldErrors,
    name, text, data, Data, invoke among them), for which it wrote
    Kotlin that did not compile; it writes suspend, out and dynamic in
    backticks. A corpus of Kotlin's hostile names,
    compiler/src/tests/hosts/HostileKotlinDocuments.kt, holds every
    keyword and every name the generated Kotlin declares in every position
    inside a document, and scripts/hostile-name-sweep-kotlin.py compiles
    the names of fragments and operations with kotlinc.
  • The Kotlin runtime applies an optimistic response as a layer that a
    commit rebases under, the server's answer replaces and a failure
    reverts, notifying only what differs at the batch's end; holds a
    subscription's stream in a SubscriptionHandle that reconnects by the
    fixed backoff and parks while the environment is inactive; and loads a
    connection's pages and refetches a fragment. Store is public, and
    Environment(transport, subscriptions, store) takes it, with a debug
    flag that prints missing data until a log is set. Every script but the
    image's relaunch runs whole, and every case's override reads under
    its layer.
  • The Kotlin runtime keeps the store's image: Persistence(path) or
    Persistence.named(name), handed to Store(persistence), writes every
    server batch behind the commit on a thread of its own, and the
    availability check reads back what memory lacks, so a relaunch draws
    its first screen from the last launch's data and ages survive it. The
    schema, the versions, the eviction by launch, the names forgotten with
    their rows and what never reaches the file are the Swift runtime's; the
    file is reached through the AndroidX SQLite driver API, with the bundled
    engine on the JVM alone. All twelve scripts run whole.
  • The Kotlin runtime speaks to a server and to Compose. HttpTransport
    posts over HttpURLConnection, reading a deferred response's
    multipart/mixed parts and a subscription's graphql-sse events
    through the common MultipartParser and EventStreamParser, credentials
    per attempt, a request error answered as
    application/graphql-response+json as its GraphQLErrors;
    GraphQLTransportWebSocket speaks graphql-transport-ws over the JVM's
    java.net.http.WebSocket, one connection per transport; and
    Environment(url) makes an environment over HTTP. LocalBaton,
    rememberQuery, rememberMutation and rememberSubscription resolve
    operation values in a composition, retained while the composable stays.
    kotlin/samples/desktop is a Compose for Desktop app over the Rick and
    Morty API.
  • Generated Kotlin compiles for two shapes it did not: a fragment or a
    field named like a plan's selection, selection0, which a lens read as
    the selection, since the selections are now members of a private object
    beside the operation's class, `HeroQuery-plan`, that no lens sees;
    and an @inline fragment's value of some two hundred linked fields,
    whose constructor passed the 64 KiB the JVM allows a method, since the
    constructor now reads each field through a private function of the
    value's companion, `read-name`.

0.10.0 Vatrushka

Choose a tag to compare

@github-actions github-actions released this 08 Oct 00:21
  • The macros accept swift-syntax from 602 to 604, where they accepted 602
    alone, so an app that pins swift-syntax to its compiler's release, 603
    for Swift 6.3 or 604 for 6.4, resolves Baton, and gets swift-syntax's
    prebuilt macro support with it. The range spans the floor's toolchain to
    the newest release and CI builds both ends; recorded in
    docs/decisions/swift-syntax-spans-the-floor-to-the-newest.md.
  • A list of scalars follows its element type in what it accepts, as the
    contract says and the Kotlin readers do: a list of strings reads a
    number's or a boolean's text, a list of floats reads an int, a list of
    ints reads a whole float, as the single-value readers always did. Before,
    a list element of another kind was dropped or read as nil where the
    scalar reader converted it.
  • The Kotlin runtime has its store and its ingest: a response's bytes
    become a change set by the plan, with no JSON tree between, and the
    commit writes it into records whose cells are Compose snapshot state, so
    a read in composition registers the field and a write tells its readers
    alone. Connections merge, edge directives edit, errors land on their
    fields and deferred parts on their records, and every case of
    spec/manifest.json leaves its dump byte for byte on the JVM. The
    lenses, layers, retention and the image follow.
  • batonc writes Kotlin: generate --language kotlin, or a .kt host,
    writes each operation as a class of its variables, equal by them, whose
    companion holds its document and plan, and the shared Baton.baton.kt
    with Types, Slots, the schema's enums and its input objects, in the
    package baton.json names under "kotlin": {"package": …} or the host's
    own. The .kt scanner reads every Kotlin string form, and a document with
    a variable is written in a $$ string. A mapped scalar names its Kotlin
    type and converter under kotlin. Lenses follow with the Kotlin readers.
  • The Kotlin runtime reads: the anchor's readers behind every accessor
    generated code prints, the owner that settles a lens's keys, conditions
    and @arguments once, and the placeholder behind a non-null link with no
    record. A read registers the one cell it reads; a missing or wrong-kind
    value is reported to the store's log once and reads as null or a zero
    value; @required, @catch, @throwOnFieldError and @defer read by
    Relay's rules, @catch as a kotlin.Result. Lenses written by hand
    after the Swift goldens read the reads rows of ten operations' cases
    as the cases say. Pagination and refetch come with the environment.
  • URLSessionTransport reads a request error answered with a 4xx or 5xx
    status as application/graphql-response+json, as GraphQL Yoga, Hive
    Gateway and Apollo Router do for a document that fails to parse or
    validate or an unknown persisted document: it throws the server's
    GraphQLErrors, the request kind of failure, where it threw a
    TransportError with the body as text. A query fails with the server's
    errors and their extensions, and a subscription refused so ends rather
    than reconnecting by backoff. Another body outside 2xx, and any
    application/json one, is still a TransportError.
  • The common-first record is amended: the JVM, through Compose for
    Desktop, is the first actual and the development target of the Kotlin
    runtime; Android is the first shipped target and the ingest budget's
    home. It names the two primitives a target supplies beside the image,
    the transports and the activity signal: a thread's identity and a
    double's shortest text.
  • The authors' documents are under spec/sources/, one .graphql file
    each, with the configuration they compile with in spec/tests/baton.json,
    so a second runtime's harness compiles its lenses from the specification
    alone. The compiler's tests check them against the Swift test target's
    markers and prove they plan what the markers plan. The manifest is format
    3: sources names the directory and the configuration.
  • The spec's scripts compare the requests a step sends: a sent
    expectation lists each request's operation and the exact body the
    standard encoding writes. Script transport holds the body's member
    order, the text without client fields and client directives, an enum and
    a mapped scalar sent as their text, and an input object's unset fields
    left out.
  • A nullable variable left unset is left out of the request, and one the
    operation declares with a default is sent as that default, where both
    were sent as null; GraphQL applies an argument's default only to an
    absent variable, so the server and the store's keys now see one value.

0.9.0 Krendel

Choose a tag to compare

@github-actions github-actions released this 07 Oct 21:57
  • Payload, bytes in a response's shape, is what the door takes:
    commitPayload takes a Payload where it took Data, and
    mutate(_:optimistic:) and a mutation's action take one where they took a
    Variable; a mutation's OptimisticResponse builder renders its
    payload, and the JSON value it collected its fields in is generated
    code's alone. Variable is a variable's JSON value and nothing else.
    Format 18. Recorded in
    docs/decisions/a-payload-is-bytes-in-a-responses-shape.md.
  • A key the store holds at two slots, a rendering and the constant the
    build named for it afterwards, is one field in every report: a commit's
    changed count counts the pair once, and the store's dump and the
    inspector list the key once, and a record a batch creates counts among
    nothing changed whether or not the store holds twins. Before, a late
    constant doubled the count, made created records count, and showed the
    key twice in the dump, so a count or a dump compared across test runs
    differed by what else the process had touched.
  • A handle stores no phase. The root, which is the store's, holds whether
    the store has the operation's data and the verdict on it, what the data
    deserves by @throwOnFieldError and a bubbling @required, settled by
    the store at the end of a batch that changed a null, a link, an error or
    a deletion, and when the handle finds or fetches the data; the handle's
    phase is derived from the root and its own fetch when it is read.
    The settling chain from the store through the environment to every
    retained handle is gone, and with it the store's last covert pointer to
    its environment. With data the phase reads the verdict and not the
    fetch, so a body that reads the phase is not woken by a fetch that
    changed nothing. Two readings change: a storeOnly handle that found no
    data reads ready once the operation's response is committed by any route
    or it is attached again over data the store now holds, and a handle that
    failed with nothing to show reads ready when it is attached again over
    such data; before, both stayed failed. Recorded in
    docs/decisions/the-verdict-is-the-roots.md.
  • The compiler's decide stage spells nothing itself: it asks a naming the
    target supplies for every identifier, suffix, family and member name it
    needs, and the driver decides first and prints through the Swift target
    after; host files are found through a table of languages, with the Swift
    scanner and its checks behind it. The generated code does not change by
    a byte; the seams are those a Kotlin emitter needs.
  • Scripts, the second kind of fixture under spec/: a file under
    spec/scripts/ runs steps over time in one environment over one store,
    through a transport the steps answer, and after any step compares the
    dump, the reads, the fields notified, a handle's phase, fetch and
    stream, the check's answer, the records held and the log's events.
    Eleven scripts (notifications, optimistic, connections, lifetime,
    ages, phase, check, heal, end, events, subscriptions)
    hold the rules of the contract that no single commit could; the
    manifest goes to format 2, with scripts beside cases, and
    spec/README.md says what a script holds. The Swift runtime passes
    them all; loading a page through a lens and the transport's framings
    stay with the Swift tests for now.
  • customScalarTypes takes, beside the Swift type as a string, an object
    by language, {"swift": "Foundation.Decimal", "kotlin": "..."}; a mapped
    scalar with no swift entry is an error at the configuration. The plan
    the compiler lowers carries the scalar's name and the onError value, no
    longer a Swift type or a Swift case: the Swift writer resolves both, so a
    second emitter reads the same plan. The refetch descriptor names the
    @fetchable field Relay's metadata names instead of a field spelled
    id. The generated Swift does not change by a byte.
  • A view outside every .environment(\.baton, ...) reads
    .failed(EnvironmentError.notInjected) on its first body and makes no
    handle, and a mutation action in such a view throws the same; the shared
    placeholder environment, a real store that every view which forgot the
    injection fetched into, is gone. The absence of an environment is not a
    session. Format 17: an operation value's resolution is a Resolution,
    unresolved, resolved to its handle, or not injected.
  • A lens is Equatable, by its anchor: the same record, the same scope and
    the same origin, by identity, as the principle always said. A row view
    whose stored state is a lens conforms in one line and opts into
    .equatable(), so a parent's re-render skips it.
  • Five decisions recorded before the Kotlin lane, in docs/decisions/: the
    verdict is the root's and the phase is derived from it (superseding in
    part The phase stays stored; built behind a gate once the phase scripts
    exist); a payload is bytes in a response's shape, Relay's word, which the
    door and the optimistic builders will take in place of a Variable; a
    mapped scalar's host type is named per language under Relay's
    customScalarTypes, so the plan carries the scalar's name; the Kotlin
    runtime is common first with a platform as an actual (issue 24); and a
    format is per emitter.
  • spec/runtime.md, the runtime contract: what a runtime does with a plan,
    a response and a store, in no language's terms, one paragraph a rule,
    each ending with the fixture that holds it or the word unheld, so a
    second runtime is held to the rules and not to the Swift that spells
    them. The principle Two runtimes, one compiler names it, and no longer
    counts the image's bytes among what the fixtures specify: a second store
    over the image reading the same records is what is shared.
  • @inline is built: a fragment so marked compiles to a Sendable,
    Hashable struct of its fields in place of a lens, with a nested struct
    per link, an array per plural link and an initializer that takes the
    fields. The spread's accessor on the parent's lens builds the value from
    the record when it is called, on the main actor, through the readers a
    lens's accessors use, so what a read registers and reports is the same;
    a conditional or deferred spread yields an optional value and an aliased
    @catch around one a Result. An inline fragment spreads only inline
    fragments and takes no @connection, @refetchable or @required; a
    non-null mapped scalar in it reads optional, since a stored property
    cannot throw. A value's field errors include those of the values it
    spreads, so a catch or a policy around it sees them, and
    @throwOnFieldError on a value spread inside another value is refused.
    Format 16: generated code names the readers that build a plural link's
    values. A fragment spread by no operation is warned as one nothing can
    read, lens or value. Recorded in
    docs/decisions/a-fragment-has-one-reading.md.

0.8.0 Back Straight

Choose a tag to compare

@github-actions github-actions released this 07 Oct 04:28

The spine: the architecture the decision records describe, built. A
handle's fetch and a subscription's stream are values beside its phase,
and a failure says its kind; every write is a batch through one door; the
store owns the roots that keep records alive, stamps their ages at the
commit and collects from them alone; an environment ends; and the keys a
session renders are its store's. Around it, the shapes a production schema
has read as Swift types (enums, input objects, mapped scalars, configured
identity, client fields), the transport has one verb, the image evicts by
launch and keeps the rows a partial response did not name, and the
compiler validates, prints and reports what it compiled.

  • The runtime reads a plural link out as values: values, requiredValues,
    caughtValues and caughtRequiredValues on an anchor build one value per
    linked record at the read, for the @inline fragment the compiler is
    learning to emit. FieldErrors and every MappedScalar are Hashable,
    so a value holding a caught field or a mapped scalar can be. A mapped
    scalar type of the app's own that was not Hashable must become it.
  • A response with a few of a record's fields no longer empties the
    record's row in the image of the rest. The writer replaced every row with
    the commit's snapshot of the record, which for a record memory had not
    read from the image held only what this launch's responses wrote: a
    header fetched before a screen left the screen's next check a miss. The
    snapshot of a record memory has not read is now merged into its row, the
    response's cells over the row's; a record the check has read replaces
    its row as before, and so does a deleted one. Recorded in
    docs/decisions/the-image-is-sqlite.md.
  • An image's close() is final. A commit or a read that reached a closed
    image opened its file again and took it back from the next environment's
    image, which then ran without one or, in a debug build, tripped the
    assertion that one image holds a file. A closed image drops what is
    queued after and misses every read; removeAll() still deletes the
    file. Recorded in docs/decisions/an-image-belongs-to-one-store.md.
  • An operation's text is printed compact, with Relay's printer's own
    option: no newline, indentation or optional space, a comma between
    items, strings as they are. The test target's 115 operations hold
    61,797 bytes of text where they held 83,885, and the deepest realistic
    one 47% of what it held; the 65 KB operation of issue 36 that a server
    refused goes out at about half. Every persisted id changes with the
    text, so a team with registered ids regenerates the file and registers
    again; batonc print and spec/documents show the compact text.
    Recorded in docs/decisions/operation-text-is-printed-compact.md.
  • A connection on the query root comes back from the image. The
    availability check hydrates the root a waited field at a time, and the
    connection's client link, Relay's handle key on the root, was never among
    them, so a reopened store read the connection as ready and empty while the
    same connection under a record came back whole; the check now reads the
    root's cell for the link before walking the connection. Issue 35.
  • An operation's plan declares each distinct selection once, as a static
    member with its type stated, where it was one nested expression that
    copied a fragment at every spread. A union inside a union no longer
    exhausts the Swift compiler: the case of issue 34 that was killed at
    12 GB compiles in 1.7 s and 0.24 GB, and its plan holds 82 selections
    where it held 2,258. Recorded in
    docs/decisions/a-plan-declares-each-selection-once.md.
  • An object under an interface or union takes its type from its
    __typename by a byte comparison with the names the plan lists, where
    the ingest made a string and took the registry's lock for every object;
    an escaped or unlisted name still asks the registry. A page of 899 union
    results ingests in 508 µs against 541, in BENCHMARKS.md.
  • The compiler's tests fence the bytes of generated code per accessor line
    at 120 over the goldens, the budget 0.1.0 named and never enforced; the
    goldens stand at 106.1.
  • A part of a deferred response that names a place no earlier part created,
    or a label the plan does not know, is logged as partDropped with its
    response path, where it was dropped without a word.
  • An image over its size limit evicts instead of starting over: the rows
    of launches before the last go first, then the last launch's, and the
    file shrinks; only a file still over the limit with nothing left to evict
    starts again. Recency is the launch's, which the rows already record; no
    rule per type. Recorded in docs/decisions/the-image-evicts-by-launch.md.
  • CI compares the benchmark suite's deterministic counts, the notifications
    a commit path fires and the events a commit logs, with
    benchmarks/counts.txt: swift run -c release BatonBenchmarks --counts
    prints them, one count <name> <value> a line, and a change to them is a
    diff to review where a timing would be noise.
  • A recipe, docs/recipes/discover-once.md: a subject discovered once by
    its natural key and refreshed by id through nodes(ids:), the pattern an
    app with external keys needs; the GitHub sample refreshes its rows that
    way from the toolbar.
  • A recipe for derived state outside views, docs/recipes/derived-state.md:
    a model derives its value inside an Observations closure over the
    lenses it reads, and no commit signal is added; recorded in
    docs/decisions/derived-state-is-observed-not-signaled.md.
  • List.empty, the value a view substitutes for a nullable list it reads
    as empty: fragment.reviewRequests?.nodes ?? .empty. A nullable list
    still reads as List?, since the server's null and its empty list
    differ.
  • Record, Value, Slot, TypeID, Owner and Members are generated
    code's interface, behind @_spi(Generated), now that no hook hands them
    out; an app's own files see lenses, handles, the environment, the log,
    transports and persistence.
  • The GitHub sample keeps its store in an image across launches and signs
    out from the toolbar: the environment ends, the image's file is removed,
    and a new environment takes over, as the README describes.
  • Two recipes: docs/recipes/uikit.md, a handle held by a view controller
    and rendered through Observations, with a cell over a lens; and
    docs/recipes/porting-from-relay.md, Relay's words beside Baton's, what
    differs on purpose, and what is not ported with its reason.
  • A recipe for previews and tests, docs/recipes/testing.md: a fixture
    committed as a response, recorded responses, a held mutation and a driven
    subscription, the log in tests, and a bug report's dump as a fixture.
  • Environment.fetch has one spelling, the operation value's. The one by
    type and variables, which returned the uncaught field errors as an array
    beside the other's throwing under @throwOnFieldError, is gone: a
    fetch's field errors are read where a view reads them, from the data,
    and each one no @catch handled is a fieldError event of the log.
  • The environment logs. Environment.log is one closure called with each
    LogEvent, a value-free enum of names and counts: a fetch started,
    completed with its duration or failed with its failure's kind; a commit
    with its kind and the slots it changed in records that existed; a field error a fetch's response
    carried that no @catch handled, by operation and response path, as
    Relay's field logger reports them; the image opened, unavailable,
    written with its batches or failed; a field read and never fetched, a value
    a reader's type cannot hold, an id naming records of several types, a
    @required(action: LOG) field that is null, each by type and field. It
    replaces the four hooks, reportMissing, reportUnexpected,
    reportAmbiguousIdentity and requiredFieldMissing, which handed out
    records, slots and values. Debug builds print the missing-data cases
    until log is set.
  • A query or subscription value's resolution, the handle a view resolved
    it to, is the mechanism's: declared behind @_spi(Generated) on the
    protocols and in generated code, read through phase, fetch,
    isStale and the rest as before.
  • Lens.typeName is gone: a line of generated code per lens and a public
    requirement, read by nothing. Format 15.
  • A fragment no operation reaches, directly or through another fragment,
    is a warning at its definition: nothing can read its lens, and the code
    generated for it is dead. The test target's fragments now all reach an
    operation.
  • batonc validate, the same compilation with no output, for an editor or a
    hook; batonc print <Name>, one operation's text and id as the app sends
    them; and batonc generate --check, which writes nothing and names every
    output on disk that differs from what it would write, for a team that
    commits its generated code. The command's contract, with a Bazel
    genrule over it, is docs/recipes/batonc.md.
  • BatonInspector, a third product of the package for a debug menu:
    StoreInspector(environment) is a view over the store, its counts, its
    records by type searchable by key, each record's slots with their values
    and field errors, and a share button that exports the store in the dump
    format spec/ freezes, so a bug report can become a fixture. It reads
    and never writes.
  • The compiler writes a report of what it compiled for a target:
    batonc generate --report <file>, and Baton.report.json in the build's
    output directory under the plugin. Every operation with its kind, source,
    id, variables, the fragments it reaches and its text; every fragment with
    its type, source, the operations that reach it and its printed
    definition; the schema's digest. Deterministic and by name, so a diff of
    two reports is the contract's change. The repor...
Read more

0.7.0 Split Time

Choose a tag to compare

@github-actions github-actions released this 06 Oct 01:26

The ground before the spine: the module's boundaries held by a check, the
compiler refusing what the runtime cannot hold, the plugin telling the truth
about its inputs and outputs, and the numbers the next steps are measured
against, taken before any of them moves anything. The first release that
publishes the compiler's bundle, so a package can depend on Baton by its
tag.

  • The bench suite measures what the next steps move, so that each has its
    number before it moves anything: a collection pass over one root that
    reaches 50,000 records, over 300 roots, and the pass that clears a store
    of 50,000 records because no root is left; the re-evaluation a commit
    runs today for a retained @throwOnFieldError handle, against the verdict
    a phase read would compute in a body's own tracking scope; and a commit
    with the three report closures set. The numbers are in BENCHMARKS.md.
    No iPhone 12-class device was at hand for the suite, and the three
    decision records whose reopening lines wait for one now say so.
  • Small truths. Persistence(name:) resolves under the app's bundle
    identifier, or the process's name when it has none, so two apps on a Mac
    that both name their image "Main" no longer share one file; an app
    upgrading finds an empty image at the new path, which is a cache's lot.
    The public error types are LocalizedErrors, so localizedDescription
    shows the text they carry. Three names that never passed the terminology
    leave the public surface, to go with the steps that remove them:
    Environment.collect(), OperationHandle.settle() and
    OperationHandle.isComplete. The docs tell the truth again where they had
    not: onError is baton.json's, not the environment's; a record's
    type-membership bits, configured identity and an introspection command
    are not built, and are marked so or dropped; the lookup entry links the
    decision that stands; baton.json takes Relay's key names, and
    relay.config.json is not read.
  • The collector takes a root's entries with the records it sweeps. A root
    field rendered from variables, character(id:"7") or a page after a
    cursor, kept its entry on the root with a blank value after its record
    was collected, so a long session's root held one per id ever looked up;
    the entry now goes with the record, and writing a missing value to such
    a key takes its entry out the same way. The long-session bench looks up
    50,000 ids, where it looked up 2,000, and reports what the process's
    table of keys grows by.
  • The hostile-name sweep is a check of the repository:
    scripts/hostile-name-sweep.py type-checks, one document at a time, the
    names of fragments, operations and refetch queries the corpus tests prove
    accepted but cannot compile, and every spread form of them, against the
    module just built. CI runs it in the Swift job; it is among the local
    checks.
  • The build plugin tells the truth about its inputs and outputs. The
    compiler is among the build command's inputs, so a rebuilt compiler
    regenerates; the compiler removes from its output directory the
    generated files it did not write in this run, so a source renamed or
    removed leaves none behind; and generated code and the runtime share a
    format number, Types.format naming Baton.Format1, so code of another
    format fails to compile at one line that says which side is behind,
    rather than at every line that names the runtime.
  • A field whose type is a list of lists, [[Int!]!]!, is a compile error
    at the field. The plan says of a type that it is a list or not, so such
    a field was lowered to a flat list and read wrong; the refusal stands
    until the plan carries a type that can say the depth. A fragment spread
    that reaches the normalization program, which Relay inlines, is an
    internal error rather than a silent skip.
  • RecordedTransport and SilentTransport move to BatonTesting, a
    library product of the package for an app's tests and previews, so that
    neither ships in the app. A test or a preview that uses them adds
    import BatonTesting.
  • The boundaries inside the runtime module are checked:
    scripts/check-boundaries.sh, in CI and among the local checks, holds
    the module decision's rules (one file imports SwiftUI, one imports
    SQLite, the record, the plan and the ingest do not name the store, the
    store's files do not name the environment or a transport, and the runtime
    imports nothing else) as a ratchet whose list of tolerated violations
    can only shrink. SwiftUI's part of the runtime, the environment value,
    the storages behind the marker macros and the ForEach initializers,
    now sits in one file.
  • A custom scalar is its text: a string's contents, or the bytes of any
    other token exactly as the server wrote it, so 1.50, an integer past
    2^53 and an object or array all read back unchanged. Before, numbers were
    rounded through Double, and objects and arrays were stored as null.
  • Ingest errors instead of wrong values or traps: an Int field given a
    fraction, an exponent or a value outside Int fails the response with an
    IngestError (it wrapped, rounded, or trapped), and Int.min reads. A
    null inside a list of scalars is stored as a null element; it failed the
    whole response. The generated readers, typed as lists of non-optional
    values, still leave such elements out. A \u escape cut short by the end of a string, or a high
    surrogate followed by an escape that is not a low surrogate, reads as
    U+FFFD; the first read past the string and the second trapped.
  • Storage keys are built from the arguments, not parsed from text. A
    string argument holding $ (price(format: "$0.00")) was read as a
    variable, a lookup argument holding a comma was cut at it, and a list or
    input object with a variable inside was stored under its own text; an
    input object's keys were written unquoted and unsorted. Floats in keys are
    written as the runtime renders a variable. A lookup in baton.json whose
    argument the selection does not pass is a compile error.
  • Plans know types and conditions. The normalization plan was flat: every
    field of every type condition was expected on every record, so the
    availability check failed right after an operation's own response for any
    selection on an interface or union, and the ingest bound a response key to
    the first field of that name, storing a Location's label: dimension in
    its name. The compiler now decides, for each abstract selection, the
    fields each group of concrete types reads, and turns @include and
    @skip into guards the plan settles once per set of variables; the
    ingest reads an object by its type's variant, and the check, collection
    and deferred parts follow the same variants.
  • Lenses follow types and conditions. A type condition on an interface
    emitted asNode behind a test of the record's concrete type, so it was
    always nil; it is now tested against the set of types that satisfy it,
    emitted once in the shared file, and folds into the parent when every
    type the parent admits satisfies it. An accessor under @include or
    @skip is optional and reads nil, reporting nothing missing, when its
    condition does not select; totalCount @include(if: $x) read 0 and
    reported missing data. A field selected twice, or a fragment spread
    twice, emits one accessor: the file did not compile. An aliased spread
    of a fragment on an interface or union is tested against the types that
    satisfy it; it was always nil.
  • A field no variables can select, such as one under @include(if: $x)
    and @skip(if: $x) at once, is fetched and read under no variables; it
    was planned as always selected, so the check waited for a field the
    server never sends. A field the initial part and a deferred one both
    select is read from the initial payload by its own selection; the
    deferred copy could come first, and the initial fields under it were
    dropped.
  • One write path. Everything a batch does, field errors and deletion
    included, is in its undo log and its net notification: a failed
    optimistic write to a field no longer loses the server's error on it, an
    optimistic response that revived a deleted record no longer leaves it
    revived when it fails, and a server commit under a layer that deletes a
    record no longer fires every channel of it twice.
  • A deletion is announced to the bodies that hold it. A body that read
    only a list, or a connection's nodes, kept a row for a record
    @deleteRecord removed, because the slot holding the link did not
    change; the commit that changes whether a record is deleted now notifies
    every slot that links to it, in one pass over the store (2.2 ms for a
    commit that deletes one record from 8,965 on an M1 Pro, against 7 µs
    without the pass). A read of a deleted record's field reports nothing
    missing.
  • Identity is the key alone. The store indexed entities by bare id as well,
    last created wins across types, and @deleteRecord, @deleteEdge and
    lookups without a type resolved through it: in the Rick and Morty data
    Character:1, Location:1 and Episode:1 coexist, and the index named
    the episode. The index is gone. @deleteRecord deletes the one live
    record of any type with the id, and when several types have it deletes
    nothing and calls the new Store.reportAmbiguousIdentity (debug builds
    print); @deleteEdge drops the edges whose node has the id; a lookup
    without a type probes the field's possible types with the same rule. A
    lookup the image cannot answer no longer leaves an empty record behind.
    Store.existing(id:) is removed. An interface is keyed by id when the
    types that implement it have one, as a union is. An object under an
    interface or union that is keyed by its path is a record per concrete
    type; before, a payload of another type at the same path wrote its fields
    into the first type's record. The image's format moved to 2, so an image
    an earlier version w...
Read more

0.5.0 Baton Pass

Choose a tag to compare

@shergin shergin released this 03 Oct 03:39

Honest data on the wire: field errors stored beside their fields, Relay's
error directives in Swift's terms, @defer over the incremental formats, and
subscriptions.

  • Field errors. The ingest reads a response's errors, resolves each path
    through the plan to the record and slot it names, and the commit stores the
    error beside the field; a payload that answers the field clears it, and
    either change notifies the field. A plain accessor reads an errored field
    as null, as before; @catch reads the error; a cached read sees what the
    network read saw. A response with data: null and errors fails the fetch
    with GraphQLErrors; errors whose path leads nowhere in the plan are dropped.
  • @required(action:). A required field reads non-optional. NONE and LOG
    bubble at the lens boundary, as Relay nulls the enclosing object: the
    accessor that produces a lens (a linked field, a spread, a list element, a
    connection node) produces nil when a required field in it is null, through
    a generated satisfied; LOG also reports the path through
    Environment.requiredFieldMissing. THROW makes the field's own accessor
    get throws, raising RequiredFieldError. A root whose required fields
    bubble fails the operation, since there is no null data.
  • @catch(to:). RESULT makes the accessor a Result<T, FieldErrors> whose
    failure holds the field's error and every error below it, THROW-required
    nulls included; NULL keeps the optional type and reads errors as null.
  • @throwOnFieldError. On a fragment, the spread accessor is get throws
    and throws FieldErrors for an uncaught error inside; on an operation, an
    uncaught field error puts the handle in .failed(FieldErrors) with the
    data in the store regardless. Under either, and inside @catch,
    @semanticNonNull fields read non-optional, as Relay types them.
  • Environment.errorBehavior sends the onError request parameter
    (PROPAGATE, NULL, ABORT) when set.
  • @defer. An operation with a deferred spread asks for multipart/mixed
    and reads the parts as they arrive: the first commits and renders, each
    later part is normalized at the record its path names with the fields its
    label marks, and the availability check does not wait for deferred fields.
    Three shapes are read: the June 2023 incremental[{data, path, label}],
    the 2024 pending/incremental[{id, data}]/completed, and Relay's
    {data, path, label} per part. A deferred spread's accessor is nil until
    the fragment's fields are present. The schema has to declare @defer; a
    server without it gets Relay's "Unknown directive", which is the truth.
  • Subscriptions. @Subscription("…") var live: NoteAddedSubscription expands
    like @Query: the storage subscribes while the view lives and closes the
    stream when it goes; the handle exposes events, latest, error and
    isActive. Every event is normalized at client:root:subscription and
    committed, so its entities merge and edge directives on a subscription
    payload work. GraphQLTransportWebSocket speaks graphql-transport-ws over
    URLSessionWebSocketTask; SubscriptionTransport is the protocol behind
    it, passed as Environment(transport:subscriptions:).
  • Transport.stream(_:), with a default that answers once;
    MultipartParser splits multipart/mixed bodies however the bytes arrive.
  • The GitHub sample: @catch on the repository lookup shows the server's
    reason for a missing repository; issue rows require an author with
    @required(action: LOG), so an issue without one is no row.

Tests: field errors land beside the field and a plain read, a @catch read, a
@catch(to: NULL) read and a cached read each see what they should; a
payload that answers an errored field clears the error and notifies; NONE
drops the list element, LOG reports the path, THROW throws at the read;
@throwOnFieldError throws at the spread and fails the operation while a
caught error does not; a semantic field reads non-optional; a response with
errors and no data fails with the messages; onError is sent; a deferred
fragment is absent after the first part and present after the second in both
incremental formats; a subscription's events commit and append through
@appendEdge and the stream closes on release; the multipart parser splits
parts at any chunking. The bench measures a payload with twenty field errors
and the cost of a @catch read and a satisfied check.

Deliberately not added: @stream; operation-level @catch (accepted, no
effect; @throwOnFieldError is the operation's policy); reconnection and
retry for the WebSocket transport beyond a clean error; extensions on
field errors; a sample screen for @defer or subscriptions, because neither
public API supports them (both ship on fixtures); the missing-data heal (a
refetch of the owning operation), still planned.

Numbers for the error bench are in BENCHMARKS.md.

0.4.0 Hand-off

Choose a tag to compare

@shergin shergin released this 03 Oct 02:43

Lists: Relay's connections with pagination, fragment arguments, @alias(as:),
and the declarative edge directives.

  • Connections. A field with @connection(key:) is read through Relay's
    handle key. The page lands under its server storage key as always; the
    commit then merges it into a connection record keyed by Relay's connection
    id (<parent>:__<key>_connection(filters)), as ConnectionHandler.update
    does: a page fetched without a cursor becomes the connection, one fetched
    after a cursor appends, one fetched before a cursor prepends, edges
    deduplicate by node, pageInfo merges per direction, and a page after a
    cursor that is no longer the end is ignored. The lens over the field
    exposes nodes, hasNext, hasPrevious, isLoadingNext,
    isLoadingPrevious and connectionID; the loading flags are client fields
    on the connection record. Roots mark through the connection as well as
    through the page they fetched, so merged pages live as long as any root
    reaches the connection, whatever fetched them.
  • Pagination. A fragment with @refetchable(queryName:) whose connection
    takes first/after from @argumentDefinitions gets loadNext(_:) on the
    connection lens (loadPrevious(_:) for last/before): the generated
    query runs with the lens's variables, the merged end cursor and the owner's
    id, as a fetch with no handle and no root; the count defaults to the
    argument's default. Every @refetchable fragment lens has refetch().
  • Fragment arguments. A spread binds the target's @argumentDefinitions
    into the child lens's scope over the parent's variables: the passed literal
    or variable, else the default, else null (Relay's fragment variables). A
    storage key with a fragment variable resolves against that scope; the
    normalization and the operation text have the arguments inlined, by Relay.
  • Edge directives. @appendEdge/@prependEdge(connections:),
    @appendNode/@prependNode(connections:, edgeTypeName:),
    @deleteEdge(connections:) and @deleteRecord become edits the ingest
    records in the change set and the store applies after the entries, under
    the same transaction and undo log, so an optimistic insert shows at once,
    rebases under commits and reverts on failure. connections is a variable
    of connection ids (comments.connectionID). Inserted edges are copied into
    records the connection owns, numbered by Relay's
    __connection_next_edge_index, because a payload's edge record is keyed by
    its path and the next mutation would alias it. A deleted record reads as
    null through links, is skipped by lists and nodes, and tells every
    observer; a payload that names it again revives it.
  • @alias(as:) names the accessor verbatim, for spreads and inline fragments;
    without as: the derived names stay.
  • Records size their values by what is written, not by the type's slot
    count: a cursor-paginated field registers a storage key per page on its
    parent type, and the other records of that type no longer pay for pages
    they never saw. The scroll bench's footprint fell from +6.5 MB to +4.4 MB.
  • Environment.fetch(_:variables:) fetches an operation by type without a
    handle. Anchor keeps the record it was reached from and binds scopes with
    binding(_:). ForEach takes an array of lenses, for nodes.
  • The GitHub sample: open issues as an infinite-scroll connection on the
    repository screen; comments as a connection with "load more"; the comment
    composer appends through @appendEdge instead of refetching the issue; the
    issue screen's spread is aliased. All four read operations were checked
    against the live API.
  • The compiler's property-type check takes the longest matching document
    name, so TestAddNoteFirst.Action no longer warns about TestAddNote.

Tests: two pages merge in order with the last page's info and a stale page is
ignored; a refetch of the first page replaces the merged list and an equal
page notifies nothing; loadNext fetches after the end cursor, appends,
toggles isLoadingNext, is a no-op at the end and creates no root;
@appendEdge and @prependEdge insert once per node; an optimistic edge
shows at once, survives a page under it, is replaced by the server's edge and
reverts on failure; @deleteEdge removes the edge and @deleteRecord makes
the record read as null; @arguments binds the scope and defaults apply;
@alias(as:) renames. The bench merges 42 pages of 50 notes into one
connection.

Deliberately not added: @stream_connection and prefetchable_pagination;
refetch with new variables (replace the lens; watch list); null arguments in
storage keys (Relay omits them, Baton renders null; both sides agree, and
the format is internal until persistence); page-based lists (watch list);
@required, @catch and @defer (0.5).

Numbers for the connection bench are in BENCHMARKS.md.

0.3.0 Exchange Zone

Choose a tag to compare

@shergin shergin released this 03 Oct 01:51

The write side: mutations as action values, optimistic responses as layers
that rebase under every commit, abstract types, lookups by id, and a second
sample against GitHub's API.

  • Mutations. @Mutation("…") var star: StarMutation.Action expands to an
    action value after SwiftUI's dismiss and openURL: the compiler generates
    callAsFunction with one labelled parameter per variable plus
    optimistic:; it is async throws, returns the mutation's data lens, and
    isInFlight is observable. Mutation payloads root at
    client:root:mutation; the entities inside merge into their records as
    always, so a view reading a repository re-renders when a star mutation
    answers.
  • Optimistic responses. The compiler generates an OptimisticResponse
    builder tree per mutation (every field optional, memberwise initializers).
    It renders to JSON and goes through the same ingest and plan as a server
    response, so optimistic data obeys the oracle rule and masking. The store
    keeps optimistic layers with undo logs; a commit while layers exist lifts
    them, applies the payload, re-applies them, and notifies only slots whose
    value differs in the end. The server's answer replaces its layer in one
    batch; a failure reverts it and the error is rethrown.
  • Abstract types. Selections on interfaces and unions key each object by
    the payload's __typename (the compiler adds it to every abstract
    selection; the ingest settles the key when the typename arrives, before or
    after the id). Lenses read such selections through the record's concrete
    type; asRepository-style accessors and conditional spreads work. Relay's
    rule applies unchanged: a spread inside an inline fragment on an abstract
    selection needs @alias.
  • Lookups by id across types. A lookup without a type (Query.node)
    resolves through an id index the store keeps for every entity, so
    node(id:) renders from the store for anything a list already fetched.
  • Per-target baton.json: read from the target's directory first, then the
    package root, so one package holds the Rick and Morty sample, the GitHub
    sample, the tests and the benchmarks against three schemas.
  • The GitHub sample (examples/GitHubTriage,
    GITHUB_TOKEN=$(gh auth token) swift run GitHubTriage): the viewer's
    assigned and authored issues and pull requests over the SearchResultItem
    union, a repository screen with an optimistic star toggle, an issue screen
    with comments and reactions and a comment composer. Its schema has about
    1,800 definitions and compiles in 35 ms.
  • The compiler renders constant arguments in storage keys as JSON, as
    Relay's formatStorageKey does (issues(states:"OPEN")), and escapes them
    in generated Swift; it accepts <Operation>.Action and module-qualified
    property types.
  • TransportError has a public initializer, for transports and tests.

Tests: an optimistic response shows at once and is reverted when the server
fails; the server's answer replaces the layer in one batch and the mutation
returns its data; a server payload commits under a live layer and the layer
stays on top until it resolves; resolving or reverting a layer notifies only
the slots whose value differs in the end; objects behind a union are keyed by
their concrete type in either typename order; node(id:) finds a cached
entity by id across types.

Deliberately not added: the declarative edge directives (@appendEdge,
@prependEdge, @deleteEdge, @deleteRecord), which ship with
@connection in 0.4; imperative updaters; @alias(as:) renaming (the
directive is accepted, the accessor keeps its default name); typed input
objects (variables of input-object type are still Baton.Variable); a
mutation queue or offline retry (never, see the non-goals).

Numbers for the write cycle are in BENCHMARKS.md.

0.2.0 First Leg

Choose a tag to compare

@shergin shergin released this 03 Oct 00:28

Lifetime: the store now forgets, on purpose and on Relay's terms.

  • Retained roots. A @Query storage retains its handle while the view lives
    and releases it when SwiftUI drops the view's state. Released handles wait
    in a release buffer (Environment.releaseBufferSize, default 10, oldest
    out first); retained and buffered handles are the roots that keep records
    alive.
  • Collection. Mark and sweep over the roots' plans, coalesced into one pass
    per batch of releases; swept records are cleared so cycles break, and the
    root's links to them are dropped. Environment.collect() runs it on demand.
  • Fetch policies, as @Query("…", fetchPolicy:): storeOrNetwork,
    storeAndNetwork (the default), networkOnly, storeOnly. A storeOnly
    operation without data fails with MissingDataError.
  • Staleness. Environment.invalidate() marks everything stale and refetches
    retained handles while their data stays visible; queryCacheExpiration
    does the same by age; isStale on every operation value.
  • Preload parks a fetching handle in the buffer; a view attaching within the
    window finds the request in flight or the data present. Equal operation
    values share one handle and never fetch twice at once; settle() awaits
    the fetch in flight.
  • RecordedTransport takes a responder closure, for tests and benchmarks that
    serve many pages.
  • The compiler's scanner skips attribute arguments after the GraphQL literal.

Tests: re-entry within the buffer makes no request and eviction does;
collection removes unreachable records and keeps shared ones; each policy's
first-attach behaviour; invalidation and expiration refetch; preload. The
bench scrolls 42 synthetic pages through a buffer of 10 and reports records,
roots and footprint per page, plus the cost of a collection pass.

Deliberately not added: field-level invalidation, off-main marking, a
collection budget per pass, holdGC for optimistic updates (0.3).

Numbers for the scroll bench are in BENCHMARKS.md.