Guides

Reviewing generated code and versioning the API

The Swift, Kotlin and TypeScript that undra bindgen writes are an artifact of the core's schema. Review the schema; let CI prove the bindings.

The rule#

What the lines are#

The generated code is long because each public item becomes native code: an observable class with typed members, a decoder per signal, a documented wrapper per method, in three languages. That is the price of bindings a Swift, Kotlin or TypeScript engineer would write themselves, so the fix is not to generate less. Here is what the playground, with 15 stores, produces per platform.

LinesCodeDoc commentsOther commentsBlank
Swift7,7876,62669943419
Kotlin7,3655,6981,26318386
TypeScript7,6926,1211,17011390

Lines of each kind of generated file:

FileSwiftKotlinTypeScript
Types885755785
Errors422270462
Objects1,4901,6501,902
Stores3,7373,4303,268
Ports222827
Queries763727744
Callbacks9110586
Ids286290283
Core377768
Index––11
Package and build files543356
Total7,7877,3657,692

Three things stand out. Doc comments are 9 to 17 percent of a tree and the reason the bindings can be used without the Rust source open. Stores is almost half of every tree. And one fixture, the Bench store (128 counters and a 10,000-row list, there to measure the boundary), is 1,698 of the Swift lines, 1,325 of the Kotlin and 1,082 of the TypeScript; the median of the other fourteen stores is about 125 lines per platform, so one real feature is on the order of 130 to 250 lines on each of the three. node scripts/generated-weight.mjs [dir] prints these tables for any generated tree.

Collapsed in review#

Every generated file says what it is on its first line: the generator, the core, the schema hash and "Do not edit". package.json has no comments, so it carries the sentence as a "//" member, and tsconfig.json as a comment line. undra bindgen also writes a .gitattributes at the root of each tree, swift/, kotlin/ and ts/:

swift/.gitattributes
# Generated by `undra bindgen`: do not edit, it is rewritten with the bindings.
# GitHub collapses these files in a pull request and leaves them out of the language statistics.
# Review the schema (`undra schema diff`), not the bindings: `undra bindgen --check` proves they
# match it. A collapsed file opens to its full diff when you click it.
* linguist-generated=true

GitHub reads linguist-generated to fold a file in a pull request's diff and to leave it out of the repository's language bar. The diff is not hidden: click a collapsed file to see it, and git diff prints it as ever (the rule does not set -diff). The pattern covers the whole tree, so a file of your own kept inside one folds too; put a .gitattributes with -linguist-generated beside it, or keep your files outside the generated trees. Lint exclusions for the trees are a separate matter, handled with the build.

Review the schema, not the bindings#

bash
undra schema export -o schema.json              # once, and after each API change; commit it
undra schema diff --against origin/main         # what changed since the base
text
Public API: origin/main:schema.json (schema 0x4d503c0c6414f9bc) -> schema.json (schema 0x53c10651c39d0ca4)

breaking  case Filter.Archived: added (an exhaustive `switch` or `when` over it stops compiling)
breaking  field Todo.due: added (Option<Timestamp>, with a default): Swift and Kotlin initializers default it, but a TypeScript object literal that builds a `Todo` must name it
breaking  field Todo.priority: added without a default (u8): everything that builds a `Todo` must supply it
breaking  method Todos.add: parameter `tags: Vec<String>` added
additive  method Todos.clear: added (fn clear())
breaking  method Todos.toggle: removed (was fn toggle(id: u32))
additive  signal Todos.open_count: added (u32, computed)
additive  function import_all: added (async fn import_all(items: Vec<Todo>) -> u32)
breaking  method Notifier.dismiss: added (fn dismiss()); the app implements it, so its implementation must add it
additive  port Presence: added (an event port: the app sends its events when it has them, and implements nothing)
additive  callback Cancel: added
breaking  mutation add_todo: removed (was async fn add_todo(title: String) -> Result<Todo, TodoError>)
additive  query todo: stale time changed from none to 30000 ms

7 breaking, 6 additive.

The old side is the file as committed at the ref (git show; nothing is built), the new side is the file in the working tree; name another file after the ref, or give two files and no ref. The order is fixed, so the same two schemas print the same text, and doc comments and wire ids are not compared. The command exits with status 0 whatever it finds, so run it freely; add --exit-code to make a breaking line exit 1 in CI.

Nothing is built, so the report is only as current as the file. The bindings beside it say which schema they were generated from, and the command compares: when schema.json disagrees with the bindings in the working tree (an API change nobody exported) or at the ref, it prints a warning that names both hashes, and a stale working-tree file fails --exit-code too.

The size of a review is the point. The thirteen changes above, on a to-do core small enough to read, change 581 lines of generated code (183 of Swift, 193 of Kotlin, 205 of TypeScript), 77 lines of schema.json and print 13 lines of diff. schema export writes every type and every small definition on one line, so the playground's whole schema is about 3,300 lines and a field that became optional is one changed line.

The pull request template asks for this output when a core's API changes: paste it, and justify every breaking line.

What breaking means#

A line is breaking when code an app wrote against the old bindings can stop compiling or change what it means against the new ones, and additive when it cannot. A removed or changed signature is breaking; an added function, method, store, object or record is additive. An added record field is breaking even with a default: a TypeScript record is an interface, and an object literal that builds one must name every field (Swift and Kotlin default an appended field of a primitive, string, time, optional or collection type). Items are matched by name.

ItemAdditiveBreaking
Any itemaddedremoved
Record fielda default gainedadded, with or without #[undra(default)]; removed; retyped; reordered; a default lost
Enum or error casemessage changedadded (an exhaustive switch or when stops compiling); removed; payload or index changed
Method, constructor, functionadded; Ctx taken or not (no generated signature shows it)removed; a parameter added, removed, renamed, retyped or reordered; return type or async changed
Store signaladded; no_coalesce, default, computed or key changed (every signal is a read-only property)removed; type changed
Query or mutationadded; cache key, stale time, persist, idempotent, polling changed; a paged query's cursor typeremoved; parameters or return type changed; paging added or removed; the item key changed to or from id
Port (the app implements it)a standard port addedan opt-in standard port added (a web app registers its adapter); any other port added; a method added, removed or changed; removed
Event port (the app sends it)added; a method addeda method removed or changed; removed
Callback (the app implements it)addeda method added, removed or changed; background changed (the calls move on or off the main thread); removed

"Additive" means compatible, so it includes behaviour that no signature shows, like a query's stale time. A line holds for all three languages: what breaks in any one of them is breaking. The full rules are SPEC 2.6.

Keeping it honest in CI#

Show a GitHub Actions job
yaml
- uses: actions/checkout@v4
  with: { fetch-depth: 0 }
- run: undra bindgen --check --docs                        # the bindings are what the schema generates
- run: undra schema export --check                          # schema.json is the core's schema
- run: undra schema diff --against origin/main --exit-code   # fails on a breaking change

The first step proves the committed bindings, the second proves the committed schema, and the third turns a breaking change into a decision somebody makes on purpose. Without the third, the diff is for the reviewer to read. To skip committing bindings at all, generate them at build time and ignore the trees; the committed bindings are what a native reviewer's IDE and a Swift package see without running undra, which is why Undra commits them by default.

Edit this page on GitHub