-
Notifications
You must be signed in to change notification settings - Fork 0
docs(architecture): decide pure Go helpers from the bounded client (ADR 0016, #519) #616
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| # ADR 0016: Callable Pure Go Helpers From the Bounded Client | ||
|
|
||
| Date: 2026-06-21 | ||
|
|
||
| Status: Accepted | ||
|
|
||
| ## Context | ||
|
|
||
| The escape from the bounded `client {}` language (ADR 0008) is binary today: | ||
| either an expression fits the bounded subset, or the whole component is rewritten | ||
| as a hand-authored Go WASM island (ADR 0003 / ADR 0004). There is no middle | ||
| rung, so any logic the bounded language cannot express — non-trivial list | ||
| filtering/sorting, multi-field derivations, domain calculations — forces a | ||
| disproportionate jump to a full island the author owns end to end. | ||
|
|
||
| The bounded language is intentionally small and must stay that way (ADR 0008). | ||
| Widening it to cover every transform would erode the "bounded / Go-owned / | ||
| deterministic" principle and grow the surface the compiler must parse, | ||
| type-check, and explain. We want a ladder, not a cliff: | ||
|
|
||
| `bounded` → `bounded + rich expressions (#501/#504)` → `bounded + pure Go helpers` | ||
| → `full WASM island`. | ||
|
|
||
| The hard constraint is ADR-level: **#384 — single-source client semantics.** | ||
| Whatever executes the helper must not reintroduce a second, drifting | ||
| implementation of any behavior. There must remain exactly one source of truth. | ||
|
|
||
| This ADR decides the three open questions from issue #519: the helper execution | ||
| model, purity enforcement, and type bridging. | ||
|
|
||
| ## Decision | ||
|
|
||
| Add a `compute`/`computed`-level call to **pure, imported Go functions** from the | ||
| bounded client. The reactive shell stays compiler-owned and bounded; only the | ||
| helper body is author Go. | ||
|
|
||
| ```gwdk | ||
| client { | ||
| // ui.FilterRows is an ordinary, pure Go function in a sibling/imported package. | ||
| computed Visible []Row { return ui.FilterRows(Rows, Query) } | ||
| } | ||
| ``` | ||
|
|
||
| ### 1. Execution model: compile the helper to WASM from its Go source | ||
|
|
||
| The pure Go helper is **compiled to WebAssembly from the same Go source the | ||
| server uses** and invoked through the existing component WASM island ABI (ADR | ||
| 0004). It is never transpiled or re-expressed in JavaScript. | ||
|
Comment on lines
+46
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because Go compiles all non-tag-excluded files in a package for Useful? React with 👍 / 👎. |
||
|
|
||
| This is the only model that satisfies #384 by construction: the helper has one | ||
| implementation (the Go source), and WASM is merely its compiled form. A Go→JS | ||
| transpiler (rejected below) would be a second implementation of Go semantics and | ||
| is exactly the divergence #384 forbids. | ||
|
|
||
| Concretely: | ||
|
|
||
| - A component that calls a pure Go helper resolves to the **WASM island lane**. | ||
| Its bounded reactive shell is still generated by the compiler from the bounded | ||
| client language (the author does not hand-write the island); the pure helpers | ||
| are linked into the same module and called by the generated shell. | ||
| - The shell ↔ helper boundary reuses ADR 0004's JSON bootstrap/patch ABI and the | ||
| single expression/runtime spec (the `RuntimeExpressionSpec` that already drives | ||
| Go/JS parity for builtins). The bounded shell semantics keep their single | ||
| source; the helper keeps its single source. Neither is duplicated. | ||
| - This is implemented in phases (see Follow-Up) so the first slice is a small, | ||
| reviewable delta on top of the existing WASM ABI rather than a new runtime. | ||
|
|
||
| Two surface details this ADR commits to so implementers do not have to | ||
| re-decide them: | ||
|
|
||
| - **Qualified-call syntax.** The bounded client grammar currently only parses a | ||
| call whose callee is a bare identifier (a component-local helper). A | ||
| package-qualified helper call such as `ui.FilterRows(...)` requires extending | ||
| the call grammar to accept a qualified callee `alias.Name`, resolved through | ||
| the page/component's declared `import` alias to a Go package symbol — the same | ||
| alias resolution `build {}` already uses for imported build-data functions. | ||
| Until that grammar extension lands, the example above does not parse; defining | ||
| it is part of the implementing change. | ||
| - **Package build isolation.** Go compiles every non-build-tag-excluded file in a | ||
| package for `GOOS=js`, so pointing a helper at a package that also holds | ||
| server-only files/imports would fail the WASM build even when the helper's own | ||
| call graph is pure. The helper module is therefore built from an extracted | ||
| dependency set — only the helper and its transitively pure dependencies — not by | ||
| compiling the whole sibling package; alternatively the author keeps helpers in a | ||
| browser-buildable package. The compiler owns the extraction so "an ordinary | ||
| sibling/imported function" stays usable for common app packages. | ||
|
|
||
| ### 2. Purity enforcement is mandatory and static | ||
|
|
||
| A function callable from the client must be **pure and deterministic**. The | ||
| compiler runs a purity analysis on the helper's call graph and rejects, with | ||
| diagnostics, any function that: | ||
|
|
||
| - performs I/O (network, filesystem, `os`, `syscall`, logging); | ||
| - reads or writes package-level mutable state, or captures mutable globals; | ||
| - starts goroutines, uses channels, `sync`, or other concurrency; | ||
| - uses non-determinism (`time.Now`, `math/rand`, map-iteration-order-dependent | ||
| output, `unsafe`, pointers escaping into observable identity); | ||
| - **mutates a bridged input.** A `computed` helper must not write through its | ||
| parameters (or values reachable from them); evaluating a derived value may not | ||
| change its inputs or depend on aliasing. So an in-place mutator like | ||
| `sort.Slice(rows, …)` on the `Rows` argument is rejected even though `sort` is | ||
| otherwise pure; | ||
| - calls another function that is not itself pure. | ||
|
|
||
| Allowed: parameters (read-only), locals, pure arithmetic/string/slice/struct/map | ||
| value construction, and calls to other functions proven pure. The curated pure | ||
| standard-library allowlist (`strings`, `math`, `strconv`, the non-mutating parts | ||
| of `sort` such as `sort.SliceStable` on a locally-allocated copy, etc.) admits a | ||
| package only for its non-mutating, deterministic surface — a package is not | ||
| blanket-trusted, and its in-place mutators are allowed only on compiler-proven | ||
| local copies. Purity is a transitive property checked over the reachable call | ||
| graph; the result is cached per function. A helper that cannot be proven pure is | ||
| a compile error that names the offending call site, not a silent downgrade. | ||
|
|
||
| ### 3. Type bridging reuses the bounded-language value model | ||
|
|
||
| Helper parameters and results must be **bridgeable types**: the same JSON-encoded | ||
| value model the bounded client already uses for `state`, `props`, and the island | ||
| ABI — scalars (`string`, `bool`, integer/float numerics), structs of bridgeable | ||
| fields, and slices/maps of bridgeable values. The compiler: | ||
|
Comment on lines
+118
to
+121
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Allowing all integer numerics through the JSON value model loses exactness for Useful? React with 👍 / 👎. |
||
|
|
||
| - validates each helper argument's bounded-client type against the Go parameter | ||
| type, and the Go result type against the `computed` declared type; | ||
| - generates the marshal/unmarshal glue at the ABI boundary from the resolved Go | ||
| types (reusing the typed-result/struct-field machinery already used for SSR | ||
| load results and contracts); | ||
|
Comment on lines
+125
to
+127
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The bridge contract only calls out type validation and marshal/unmarshal glue, but the motivating Useful? React with 👍 / 👎. |
||
| - **exposes resolved result-field metadata with the computed value.** A | ||
| `computed Visible []Row` must publish `Visible[].Name` (and the rest of `Row`'s | ||
| bridgeable fields) into the client symbol table, not just the top-level array | ||
| type — otherwise `g:for`, `g:key`, and row interpolations over the helper | ||
| result would see unknown fields. The same struct-field resolution that backs | ||
| typed SSR load results provides this metadata; | ||
| - **constrains wide integers.** `int64`/`uint64` values outside the IEEE-754 | ||
| safe-integer range (±(2^53−1)) cannot survive the JSON/number bridge exactly, | ||
| which would let a correct Go/WASM helper return a value the bounded client | ||
| observes rounded — breaking single-source semantics. Bridgeable integers are | ||
| therefore range-checked (or must be encoded as strings) with a diagnostic when a | ||
| signature can carry out-of-range values. This matches the safe-integer bound the | ||
| formatting/date builtins already enforce; | ||
| - rejects unbridgeable signatures (channels, funcs, interfaces without a | ||
| bridgeable concrete contract, `unsafe.Pointer`) with a diagnostic. | ||
|
|
||
| No new value kinds are introduced; the bridge is the existing JSON contract. | ||
|
|
||
| ## Consequences | ||
|
|
||
| ### Positive | ||
|
|
||
| - Turns the bounded→WASM cliff into a ladder: authors reach for a pure Go helper | ||
| for the one transform that doesn't fit, instead of rewriting the screen. | ||
| - Single source of truth is preserved by construction (#384): the helper is | ||
| compiled, never re-implemented. | ||
| - Reuses existing infrastructure: the WASM island ABI (0004), the expression | ||
| runtime spec, and the typed struct-field/result machinery — minimal new | ||
| surface. | ||
| - Keeps the bounded language small (0008): pressure to add every transform as a | ||
| builtin is relieved by "extract a pure Go helper." | ||
|
|
||
| ### Negative | ||
|
|
||
| - A component that uses helpers pays the WASM island cost (module size, load) it | ||
| would not pay as a pure-JS island. The ladder makes this an explicit author | ||
| choice, but it is a real cost. | ||
| - Purity analysis is non-trivial and must be conservative (false negatives that | ||
| reject a pure function are acceptable; false positives that admit an impure one | ||
| are not). | ||
| - Determinism across Go/WASM is inherited from the Go toolchain, but | ||
| floating-point and map-ordering pitfalls still need test coverage. | ||
|
|
||
| ### Neutral | ||
|
|
||
| - Default JS islands are unchanged; nothing here alters ADR 0003's default. | ||
| - This is the chosen direction; the implementation is phased and not yet built | ||
| (see Follow-Up). The bounded language and existing islands behave as before | ||
| until a phase lands. | ||
|
|
||
| ## Alternatives Considered | ||
|
|
||
| - **Transpile the pure Go helper to JavaScript.** Rejected: a Go→JS compiler is a | ||
| second implementation of Go semantics and is precisely the divergence #384 | ||
| exists to prevent. Even a "subset" transpiler drifts. | ||
| - **Widen the bounded client language to cover the transforms.** Rejected: it | ||
| grows the surface 0008 deliberately bounds, and never covers the long tail; | ||
| authors still hit a wall, just later. | ||
| - **Force a full hand-authored WASM island (status quo).** Rejected: that is the | ||
| cliff this ADR removes; it makes the author own the reactive shell, props, | ||
| events, and lifecycle for what should be a one-function escape. | ||
| - **A JS island that calls a small WASM "helper sidecar."** Considered as a | ||
| size-optimization phase (keep the shell in JS, ship only the helper as WASM). | ||
| Deferred behind the single-module model because two runtimes per page is more | ||
| moving parts; revisited in Follow-Up if WASM shell size proves prohibitive. | ||
|
|
||
| ## Follow-Up | ||
|
|
||
| - Depends on **#384** (single-source client semantics) landing first, so the | ||
| bounded shell has one IR/evaluator before helpers cross the boundary. | ||
| - **Phase 1:** allow pure Go helper calls only inside components already in the | ||
| WASM lane, with purity validation, type bridging, and generated glue. Smallest | ||
| reviewable delta; no lane change. Note that ADR 0004's component-level `wasm` | ||
| declaration today points at a *hand-authored* browser Go package that owns the | ||
| `GOWDKMount/Handle/Destroy` exports. Helper support is a **distinct | ||
| generated-helper path**: the compiler still owns the reactive-shell exports and | ||
| links the pure helpers as ordinary called functions (not lifecycle exports), so | ||
| there is one lifecycle owner. Phase 1 therefore targets compiler-generated WASM | ||
| components, not the hand-authored-island export contract, which is unchanged. | ||
| - **Phase 2:** auto-promote a bounded JS component to the WASM lane when it calls | ||
| a helper, so authors do not manage the lane manually. | ||
| - **Phase 3:** evaluate the JS-shell + WASM-helper-sidecar option if WASM shell | ||
| size is a problem in practice. | ||
| - Each phase ships with purity/bridging diagnostics, a native example, and | ||
| generated-output tests, per the #519 checklist. | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The decided syntax uses a package-qualified call, but the bounded client expression grammar currently only permits calls when the callee is a bare identifier (
internal/clientlang/expr_parser.gorejects member calls withonly helper names can be called). If this ADR is the implementation contract, imported helpers have no parseable/resolvable form, so the documented example would be rejected unless an undocumented syntax change lands; define the qualified-call syntax and resolution here.Useful? React with 👍 / 👎.