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#
- Do not read the generated files in a review. GitHub collapses them, and
undra bindgen --checkproves they are exactly what the schema generates. - Read the schema diff.
undra schema diff --against origin/mainlists what changed in the public API, one line per change, each markedbreakingoradditive. - Version the API as the schema, not as its generated text. Commit
schema.jsonnext togenerated/: an API change is a few lines of JSON, and thousands of lines of bindings move with it.
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.
| Lines | Code | Doc comments | Other comments | Blank | |
|---|---|---|---|---|---|
| Swift | 7,787 | 6,626 | 699 | 43 | 419 |
| Kotlin | 7,365 | 5,698 | 1,263 | 18 | 386 |
| TypeScript | 7,692 | 6,121 | 1,170 | 11 | 390 |
Lines of each kind of generated file:
| File | Swift | Kotlin | TypeScript |
|---|---|---|---|
| Types | 885 | 755 | 785 |
| Errors | 422 | 270 | 462 |
| Objects | 1,490 | 1,650 | 1,902 |
| Stores | 3,737 | 3,430 | 3,268 |
| Ports | 22 | 28 | 27 |
| Queries | 763 | 727 | 744 |
| Callbacks | 91 | 105 | 86 |
| Ids | 286 | 290 | 283 |
| Core | 37 | 77 | 68 |
| Index | – | – | 11 |
| Package and build files | 54 | 33 | 56 |
| Total | 7,787 | 7,365 | 7,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/:
# 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=trueGitHub 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#
undra schema export -o schema.json # once, and after each API change; commit it
undra schema diff --against origin/main # what changed since the basePublic 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.
| Item | Additive | Breaking |
|---|---|---|
| Any item | added | removed |
| Record field | a default gained | added, with or without #[undra(default)]; removed; retyped; reordered; a default lost |
| Enum or error case | message changed | added (an exhaustive switch or when stops compiling); removed; payload or index changed |
| Method, constructor, function | added; Ctx taken or not (no generated signature shows it) | removed; a parameter added, removed, renamed, retyped or reordered; return type or async changed |
| Store signal | added; no_coalesce, default, computed or key changed (every signal is a read-only property) | removed; type changed |
| Query or mutation | added; cache key, stale time, persist, idempotent, polling changed; a paged query's cursor type | removed; 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 added | an 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 added | a method removed or changed; removed |
| Callback (the app implements it) | added | a 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
- 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 changeThe 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.