|
2 | 2 | // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
3 | 3 | = AffineScript Migration Playbook: Re-decomposition Patterns |
4 | 4 | Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
5 | | -v1.0, 2026-05-02 |
| 5 | +v1.1, 2026-05-02 |
6 | 6 | :sectnums: |
7 | 7 | :toc: left |
8 | 8 | :icons: font |
9 | 9 | :source-highlighter: rouge |
10 | | -:revnumber: 1.0 |
| 10 | +:revnumber: 1.1 |
11 | 11 | :revdate: 2026-05-02 |
12 | 12 |
|
13 | 13 | [NOTE] |
@@ -90,6 +90,10 @@ The rest of this document is a catalogue of common source-language patterns and |
90 | 90 | | `unit` returns from impure code |
91 | 91 | | `() / IO` (or whichever effects apply) |
92 | 92 | | In ReScript, `unit` says nothing about whether the function did IO. In AffineScript, the effect row makes it visible. |
| 93 | + |
| 94 | +| Module-level getter for a singleton runtime — `Module.get(): Option<Thing>` |
| 95 | +| Lift to an effect (`effect Thing { fn current() -> Option[ref Thing]; }`); make the "is the runtime available?" branch a typed `Result`, not a silent no-op |
| 96 | +| A service-locator is mutable global state with a thin `Option` wrapper. Every consumer gains a hidden dependency on whether the global has been initialised, and the "not initialised" branch tends to become a silent no-op that masks real bugs. Lifting it to an effect makes the dependency visible at the call site and lets tests install a mock without monkey-patching a global. |
93 | 97 | |=== |
94 | 98 |
|
95 | 99 | === TypeScript → AffineScript |
@@ -157,6 +161,23 @@ The default for resources (files, sockets, tokens, allocations) is `own` — any |
157 | 161 |
|
158 | 162 | Prefer `own`. Reach for `@linear` only when forgetting to consume the value is a real bug — typically protocol handles, transactions, and capability tokens. |
159 | 163 |
|
| 164 | +=== Coupled writes — multiple effects in one action |
| 165 | + |
| 166 | +When a single user action must update both an in-memory subsystem AND persist to storage (volume that touches `Audio` and `Storage`, theme that touches `UI` and `Storage`, etc.), there are two ways to express it: |
| 167 | + |
| 168 | +[cols="1,2"] |
| 169 | +|=== |
| 170 | +| Shape | When to choose |
| 171 | + |
| 172 | +| **One function calling two (or more) effects.** Default. |
| 173 | +| Each effect is independently meaningful and independently testable. The wrapper function *is* the coupling; it is visible at every call site through both effects appearing in the row. |
| 174 | + |
| 175 | +| **A wrapper effect that internally sequences the two.** E.g. `effect Settings { fn set_master_volume(v: Float) -> Result[(), NoEngine]; }`. |
| 176 | +| The runtime invariant is "Audio and Storage must always agree" and partial success is a bug. The wrapper's handler enforces atomicity (both succeed or neither does), which the two-effect call site cannot. |
| 177 | +|=== |
| 178 | + |
| 179 | +The decision tracks the same logic as `mut` vs `State`: keep coupling local unless the invariant must be observable across callers. If two callers can validly compose `Audio.set_master_volume` and `Storage.set_number` differently — for example, "set the audio without persisting" for a transient ducking effect — the wrapper effect is wrong, because it forecloses that composition. |
| 180 | + |
160 | 181 | [#anti-patterns] |
161 | 182 | == Anti-patterns: Faithful-but-monolithic Translation |
162 | 183 |
|
@@ -274,4 +295,8 @@ If you complete a non-trivial `.res → .affine` translation and the re-decompos |
274 | 295 | | 1.0 |
275 | 296 | | 2026-05-02 |
276 | 297 | | Initial draft. ReScript and TypeScript pattern indices, decision criteria, file-buffer anti-pattern. Companion to `frontier-guide.adoc` v1.0. |
| 298 | + |
| 299 | +| 1.1 |
| 300 | +| 2026-05-02 |
| 301 | +| Two additions surfaced by the link:lessons/migrations/idaptik-user-settings.adoc[idaptik UserSettings design walk-through]: (1) service-locator-globals row added to the ReScript pattern index; (2) coupled-writes subsection added to Decision Criteria, with the rule "keep coupling local unless the invariant must be observable across callers." No removals; existing v1.0 guidance unchanged. |
277 | 302 | |=== |
0 commit comments