diff --git a/docs/engineering/decisions/0016-pure-go-helpers-from-bounded-client.md b/docs/engineering/decisions/0016-pure-go-helpers-from-bounded-client.md new file mode 100644 index 00000000..2646f705 --- /dev/null +++ b/docs/engineering/decisions/0016-pure-go-helpers-from-bounded-client.md @@ -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. + +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: + +- 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); +- **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. diff --git a/docs/engineering/decisions/README.md b/docs/engineering/decisions/README.md index 298b0e5a..6724e985 100644 --- a/docs/engineering/decisions/README.md +++ b/docs/engineering/decisions/README.md @@ -32,3 +32,10 @@ Recommended naming: query-owned elements as the first realtime reactivity source contract. - `0013-built-in-tracing-observability.md`: accepted dependency-free `runtime/trace` primitives before generated app auto-instrumentation. +- `0014-addon-runtime-config-split.md`: accepted split between addon config + packages and request-time runtime helper packages. +- `0015-generated-binary-lifecycle-services.md`: accepted generated binary + lifecycle service contracts. +- `0016-pure-go-helpers-from-bounded-client.md`: accepted direction to call pure + Go helpers from the bounded client by compiling them to WASM (single-source + semantics, purity validation, JSON type bridging).