@zakkster/lite-signal-decorators 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,122 @@ All notable changes to `@zakkster/lite-signal-decorators` are documented here.
4
4
  The format follows Keep a Changelog; this project adheres to Semantic
5
5
  Versioning.
6
6
 
7
+ ## [0.2.0] - 2026-08-26
8
+
9
+ The adversarial-torture release: the 0.2 surface is frozen for 0.x, proven by
10
+ the full semantic suite (12 scenarios, 12 failing controls) and a 300-seed
11
+ oracle. Stage gate: 171/171 tests on both lanes; torture 12/12 with every
12
+ control breaking as required; suite gate `leak=size 0/0 findings=0 warnings=0 |
13
+ gc major=0 minor=0`.
14
+
15
+ ### Added
16
+
17
+ - Seven adversarial torture scenarios (dev-side, never shipped):
18
+ `capacity-torture` (every overflow point x both construction paths, host
19
+ chains, nested constructions -- each atomic), `disposed-poison` (full
20
+ post-dispose surface + resurrection storms), `leak-torture` (lite-leak
21
+ kernels over 4096 cycles), `oracle-fuzzer` (300 seeds x 20k ops, decorated
22
+ vs hand-wired raw twins in lockstep: values, effect fire counts, and graph
23
+ opcode tallies), `interop-torture` (raw/decorated one-graph interop,
24
+ cross-registry and `destroy()` contracts pinned raw, the two documented
25
+ D-2f limits pinned drift-loud), `batch-untrack-torture`, and `churn-soak`
26
+ (wall-clock soak, pools at floor, flat heap).
27
+ - The full package README replaces the 0.1.0 stub: positioning, quick start on
28
+ both paths (decorator and buildless), the wiring/teardown model, the
29
+ half-alive-view-model trap trio, a complete API reference with the rejection
30
+ matrix, a verbatim-runnable composability pipeline, the measured numbers
31
+ (storage-bench and batched-cost tables, quoted from their committed probes),
32
+ zero-GC design notes with the allocation table and gate outputs, design
33
+ decisions, the 171-test/12-scenario testing map, compatibility, ecosystem, and
34
+ FAQ. Every behavioral claim and code sample was executed and verified against
35
+ 0.2.0 before landing.
36
+
37
+ ### Fixed
38
+
39
+ - Init-phase capacity atomicity (D-2h, S2b finding P-1): a `CapacityError` thrown
40
+ while creating a decorator signal box during `super()`'s field initialization
41
+ no longer leaks the boxes already created earlier in that construction. A
42
+ module-level scratch-frame protocol tracks in-flight boxes and disposes the
43
+ whole frame LIFO on a throw, so `new` fails closed with zero node leak (F-0
44
+ holds) at every capacity point, on the decorator path (init-phase) and the
45
+ buildless path (wire-time) alike. Hot canon and the dispose path are untouched;
46
+ steady-state cost is one push + one length reset per construction.
47
+ The fix hardened twice under review and torture: the scratch frame is owned
48
+ only by the most-derived (wiring) wrapper, so `@reactiveHost` inheritance
49
+ chains roll back base-class boxes too (an intermediate-host truncation defect
50
+ the reviewer caught with a measured counterexample), and the buildless signal
51
+ wire-loop plus the R-A anchor creation moved inside `wireInstance`'s guard, so
52
+ an overflow at ANY point -- init box, wire-time box, anchor, derived, or
53
+ effect -- tears down exactly what was built and rethrows.
54
+
55
+ ## [0.2.0-preview.1] - 2026-08-26
56
+
57
+ The full runtime surface. Exports grow from 8 to 11. Never published: the
58
+ preview publish was pending npm authentication when 0.2.0 superseded it; the
59
+ changes below first shipped with 0.2.0.
60
+
61
+ ### Added
62
+
63
+ - `reactiveEffect` -- `@reactiveEffect m()` (bare) / `@reactiveEffect({ scheduler })`
64
+ (factory): a method that auto-runs as an effect once the instance is wired
65
+ (after every field and every derived). The auto-effect tracks its reads; the
66
+ public method is a leak-guarded manual entry -- a call inside a foreign tracking
67
+ scope is untracked, so it records zero stray dependencies (0004 D-4b). Self-
68
+ dispose from inside an owned effect is allowed with pinned semantics (0004
69
+ D-4d): the current run completes, later decorated-member touches throw
70
+ `ReactiveDisposedError`, no re-runs follow, conservation is exact.
71
+ - `batched` -- `@batched m()` (bare) / `@batched()` (factory): runs the method
72
+ body inside one engine batch, coalescing its writes into a single flush.
73
+ Action-grade (one call per user intent), not a per-frame path: it allocates a
74
+ thunk + rest-array per call by design and is excluded from the zero-GC hot-path
75
+ gates.
76
+ - `defineReactive(Class, spec)` -- the buildless twin of the decorators (0005).
77
+ `spec` is `{ signals, deriveds, effects, host }`; it installs the members on
78
+ `Class.prototype` and wraps the class through the SAME wiring core the
79
+ decorators use (shared by function identity, not merely behaviorally alike).
80
+ Consumers without a Stage-3 transpiler get the complete feature set.
81
+ - `@reactiveHost({ registry })` -- an optional `registry` from lite-signal
82
+ `createRegistry()` isolates the whole host chain. Every engine call routes
83
+ through the bound registry, closing the cross-registry dispose trap (the
84
+ default `dispose` is a silent no-op across registries). One registry per chain:
85
+ a heterogeneous chain or an invalid (duck-check-failing) registry is a named
86
+ throw.
87
+
88
+ ### Changed
89
+
90
+ - `@reactiveHost` unknown options now report the standard unknown-option
91
+ did-you-mean over `["registry"]` (the 0.1.0 "takes no options" message is
92
+ retired) -- `registry` is now a valid option.
93
+ - The duplicate-key error wording now names both causes: subclass redeclaration
94
+ OR two stacked package decorators on one member.
95
+ - Internal error-string refactor (housekeeping): the package prefix is hoisted to
96
+ one `ERR` const and cold message building uses template literals; prewired
97
+ handles build their message once and share it. Message text is byte-identical
98
+ to 0.1.0 except the two changes above. Hot accessor bodies are untouched.
99
+ - `VERSION` is `"0.2.0-preview.1"`.
100
+
101
+ ### Fixed
102
+
103
+ - Manual-call identity guard (D-4e): calling a `@reactiveEffect` or `@batched`
104
+ method on a foreign, primitive, null, or cross-class receiver (e.g. via
105
+ `Class.prototype.m.call(...)`) now throws a named error instead of running the
106
+ body against the wrong instance. Uses byKey identity, so a subclass instance
107
+ calling a base-declared method still passes. Cold manual-call path only.
108
+ - Frozen-instance dispose refusal (D-2g): `disposeReactive` (and `using`) now
109
+ refuses a frozen instance up front with a named error, atomically -- previously
110
+ it half-disposed (tore down boxes) then threw a raw `TypeError`, leaving
111
+ disposed boxes behind live-looking slots. `Object.seal`/`preventExtensions`
112
+ remain fine.
113
+
114
+ ### Notes
115
+
116
+ - `@batched` per-call cost, measured by the committed cold-process probe
117
+ (`spikes/batched-cost.mjs`, K=10 cold processes, node 26.3.1, Apple M4 Pro,
118
+ lite-signal 1.5.0): the decorated method 22.12 ns/op vs raw `batch(fn)`
119
+ 15.25 vs the plain unbatched method 11.67 (median-of-medians; 0 major GC in
120
+ every lane). The ~7 ns over raw is the guarded rest-array + thunk (risk R8).
121
+ Action-grade, not a per-frame path; never in the zero-GC torture gates.
122
+
7
123
  ## [0.1.0] - 2026-08-26
8
124
 
9
125
  Initial release -- the decorator core.
@@ -36,4 +152,6 @@ Initial release -- the decorator core.
36
152
  - Torture skeleton (`@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`):
37
153
  retention, conservation, lifecycle, and zero-GC lanes.
38
154
 
155
+ [0.2.0]: https://github.com/zakkster/lite-signal-decorators/releases/tag/v0.2.0
156
+ [0.2.0-preview.1]: https://github.com/zakkster/lite-signal-decorators/releases/tag/v0.2.0-preview.1
39
157
  [0.1.0]: https://github.com/zakkster/lite-signal-decorators/releases/tag/v0.1.0
package/README.md CHANGED
@@ -1,9 +1,21 @@
1
1
  # @zakkster/lite-signal-decorators
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@zakkster/lite-signal-decorators.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-signal-decorators)
4
+ [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
5
+ ![Zero-GC](https://img.shields.io/badge/Zero--GC-Engine-00C853?style=for-the-badge&logo=leaf&logoColor=white)
6
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-signal-decorators?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-signal-decorators)
7
+ [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-signal-decorators?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-signal-decorators)
8
+ [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-signal-decorators?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-signal-decorators)
9
+ [![lite-signal peer](https://img.shields.io/npm/dependency-version/@zakkster/lite-signal-decorators/peer/@zakkster/lite-signal?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-signal)
10
+ ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational?style=for-the-badge)
11
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
12
+
3
13
  > Stage-3 decorators that turn a plain class into a reactive view-model with a
4
14
  > measured per-property cost, one deterministic teardown, and poison-on-dispose
5
15
  > safety -- built on @zakkster/lite-signal.
6
16
 
17
+ **`@reactive accessor` fields, `@derived` getters, `@reactiveEffect` methods, `@batched` actions, one `@reactiveHost` wiring site -- and `disposeReactive()` tears the whole instance down in one call, every time, with nothing left dangling. A decorated read costs ~1.0x a hand-written instance-field signal read. An instance costs exactly P + D + E + 1 pool nodes and gives all of them back on dispose. A buildless twin, `defineReactive()`, delivers the identical feature set with zero transpiler.**
18
+
7
19
  ```js
8
20
  import { reactive, derived, reactiveHost, disposeReactive } from "@zakkster/lite-signal-decorators";
9
21
 
@@ -15,23 +27,455 @@ class Vector {
15
27
  }
16
28
 
17
29
  const v = new Vector();
18
- v.len; // 5
30
+ v.len; // 5
19
31
  v.x = 6;
20
- v.len; // 7.211...
21
- disposeReactive(v); // cascade teardown; later reads throw ReactiveDisposedError
32
+ v.len; // 7.211...
33
+ disposeReactive(v); // cascade teardown; later touches throw ReactiveDisposedError
22
34
  ```
23
35
 
24
- A `@reactive accessor` is a per-instance signal in a symbol slot; a `@derived
25
- get` is a lazy computed owned by the instance's anchor; `@reactiveHost` is the
26
- one place wiring happens. `disposeReactive` (or a `using` block) cascades the
27
- anchor, disposes each signal box, and poisons every slot.
36
+ No base class to extend. No `makeObservable(this, {...})` mirror object to keep in sync. No "did I remember to dispose the reaction?" -- the instance IS the lifetime, and when it ends, every box, computed, and effect it owns ends with it, provably (a 4096-cycle leak gate and a wall-clock churn soak run on every change).
37
+
38
+ ---
39
+
40
+ ## Contents
41
+
42
+ - [The reactive class layer the ecosystem was missing](#the-reactive-class-layer-the-ecosystem-was-missing)
43
+ - [What you get](#what-you-get)
44
+ - [How it works](#how-it-works)
45
+ - [The half-alive view-model trap](#the-half-alive-view-model-trap)
46
+ - [API reference](#api-reference)
47
+ - [Composability](#composability)
48
+ - [The numbers](#the-numbers)
49
+ - [Design decisions worth knowing](#design-decisions-worth-knowing)
50
+ - [Testing (for clients & QA)](#testing-for-clients--qa)
51
+ - [Compatibility](#compatibility)
52
+ - [What this is not](#what-this-is-not)
53
+ - [Ecosystem](#ecosystem)
54
+ - [FAQ](#faq) - [License](#license)
55
+
56
+ ---
57
+
58
+ ## The reactive class layer the ecosystem was missing
59
+
60
+ Fine-grained signal engines are function-shaped: `signal()`, `computed()`, `effect()`. Real applications are often class-shaped: an entity in a fleet, a HUD panel, a document model -- a thing with identity, N reactive properties, derived state, reactions, and a *death*. The gap between the two is where reactive class layers historically go wrong: MobX-style decorators bring GC pressure and administration objects; hand-rolling `this.x = signal(0)` per class brings copy-pasted dispose lists that drift out of sync with the fields.
61
+
62
+ `lite-signal-decorators` is that layer done with lite-signal's discipline: **zero allocation on the hot path, a measured cost for everything else, and fail-closed on every unverified state.** Every property you declare is accounted for -- created at a known point, owned by a known root, destroyed by one call, poisoned afterward so a stale reference throws by name instead of misbehaving silently.
63
+
64
+ ### Install
65
+
66
+ ```bash
67
+ npm i @zakkster/lite-signal-decorators @zakkster/lite-signal
68
+ ```
69
+
70
+ `@zakkster/lite-signal` (`>=1.5.0 <2.0.0`) is a **peer dependency**, and that is a correctness requirement, not a formality: your decorated instances, your raw signals, and the engine's node pool must live in ONE reactive graph. A second nested copy of the engine would silently split that graph. Install both at the top level.
71
+
72
+ ESM-only. Ships TypeScript definitions. Node >= 18. The `@` syntax needs a Stage-3 decorator toolchain (TypeScript 5 or Babel `2023-11` -- see [Compatibility](#compatibility)); [`defineReactive`](#definereactiveclass-spec---class) needs nothing.
73
+
74
+ ### Quick start
75
+
76
+ ```js
77
+ import {
78
+ reactive, derived, reactiveEffect, batched, reactiveHost, disposeReactive,
79
+ } from "@zakkster/lite-signal-decorators";
80
+
81
+ @reactiveHost
82
+ class Player {
83
+ @reactive accessor hp = 100;
84
+ @reactive accessor shield = 50;
85
+
86
+ @derived get effectiveHp() { return this.hp + this.shield * 0.5; }
87
+ @derived get status() { return this.effectiveHp > 60 ? "healthy" : "critical"; }
88
+
89
+ // Auto-runs as an effect once the instance is wired; re-runs only when
90
+ // `status` actually CHANGES -- the derived's equality cutoff absorbs the rest.
91
+ @reactiveEffect onStatus() { hud.textContent = this.status; }
92
+
93
+ // Both writes coalesce into ONE propagation flush.
94
+ @batched hit(dmg) {
95
+ const absorbed = Math.min(this.shield, dmg);
96
+ this.shield -= absorbed;
97
+ this.hp -= dmg - absorbed;
98
+ }
99
+ }
100
+
101
+ const p = new Player(); // exactly 2 + 2 + 1 + 1 = 6 pool nodes (P + D + E + 1)
102
+ p.hit(80); // shield 0, hp 70 -> status still "healthy": effect does NOT re-run
103
+ p.hit(20); // hp 50 -> "critical": effect runs once
104
+ disposeReactive(p); // effect stopped, deriveds + boxes disposed, slots poisoned
105
+ ```
106
+
107
+ No transpiler? The **buildless twin** takes a plain class and a spec, and routes through the *same* wiring core (shared by function identity, not merely behaviorally alike) -- this exact form runs in stock Node:
108
+
109
+ ```js
110
+ import { batch } from "@zakkster/lite-signal";
111
+ import { defineReactive, disposeReactive } from "@zakkster/lite-signal-decorators";
112
+
113
+ class Player {
114
+ hit(dmg) {
115
+ batch(() => {
116
+ const absorbed = Math.min(this.shield, dmg);
117
+ this.shield -= absorbed;
118
+ this.hp -= dmg - absorbed;
119
+ });
120
+ }
121
+ }
122
+
123
+ const ReactivePlayer = defineReactive(Player, {
124
+ signals: { hp: 100, shield: 50 },
125
+ deriveds: {
126
+ effectiveHp: (self) => self.hp + self.shield * 0.5,
127
+ status: (self) => (self.effectiveHp > 60 ? "healthy" : "critical"),
128
+ },
129
+ effects: { onStatus: (self) => { hud.textContent = self.status; } },
130
+ });
131
+ ```
132
+
133
+ ---
134
+
135
+ ## What you get
136
+
137
+ - **`@reactive accessor x = v`** -- a per-instance signal box in a unique symbol slot. The read body is one slot load + one monomorphic box call: zero branches, zero allocation.
138
+ - **`@derived get y()`** -- a lazy computed owned by the instance's anchor, with optional custom `equals` for change-cutoff.
139
+ - **`@reactiveEffect m()`** -- a method that auto-runs as an effect after wiring. Manual calls are leak-guarded (a call inside a foreign tracking scope is untracked, so it records zero stray dependencies) and identity-guarded (a foreign receiver throws by name instead of running against garbage).
140
+ - **`@batched m()`** -- the method body inside one engine batch: N writes, one flush. Action-grade by design, with a measured per-call cost -- not a per-frame path.
141
+ - **`@reactiveHost`** -- the single wiring site. Its most-derived constructor builds the anchor, every derived, and every effect exactly once, after all fields of all classes in the chain initialize. `@reactiveHost({ registry })` binds the whole chain to an isolated lite-signal registry.
142
+ - **`defineReactive(Class, spec)`** -- the complete feature set with zero decorator syntax.
143
+ - **One deterministic teardown** -- `disposeReactive(vm)` (or a `using` block): anchor cascade, box disposal, poison swap, idempotent, allocation-free on the success path.
144
+ - **Fail-closed everything** -- statics, private `#` members, unknown options, duplicate keys, orphaned members, invalid registries, half-valid specs: all named throws at decoration time, with a nearest-key did-you-mean where a typo is likely.
145
+ - **Interop that stays raw** -- `boxOf(vm, key)` hands you the live engine box; `rootOf(vm)` hands the anchor descriptor to `forEachOwned` / lite-devtools. Decorated and hand-written signals share one graph.
146
+
147
+ ---
148
+
149
+ ## How it works
150
+
151
+ ```mermaid
152
+ flowchart TB
153
+ A["new Player()"] --> B["field init: each @reactive creates its<br/>signal box in a symbol slot<br/>(scratch-frame tracked)"]
154
+ B --> C["most-derived wrapper wires ONCE:<br/>anchor root -> every @derived -> every @reactiveEffect"]
155
+ C --> D["LIVE: reads track, writes propagate,<br/>effects re-run on real changes"]
156
+ D --> E["disposeReactive(p) / end of using block"]
157
+ E --> F["anchor cascade: effects + deriveds die"]
158
+ F --> G["each signal box disposed -> nodes return to the pool"]
159
+ G --> H["every slot swapped for POISON:<br/>any later touch throws ReactiveDisposedError"]
160
+ B -. "throw at ANY point" .-> R["frame drained LIFO:<br/>zero nodes leak (atomic construction)"]
161
+ C -. "throw at ANY point" .-> R
162
+ ```
163
+
164
+ Decoration happens once per class: each decorator validates its placement (fail closed) and registers the member in the class's wiring plan. Construction happens per instance: field initializers create the signal boxes, then the most-derived `@reactiveHost` wrapper -- and only it, so inheritance chains wire exactly once -- creates one anchor root and, under that owner, every derived and every effect in declaration order. Disposal reverses it: the anchor cascade takes the deriveds and effects, the boxes are disposed explicitly, and every slot is replaced by a poison handle.
165
+
166
+ <details>
167
+ <summary><strong>Deep-dive: the core surface, mechanically</strong></summary>
168
+
169
+ **The hot path is canon.** The accessor bodies are frozen as:
170
+
171
+ ```js
172
+ function makeGet(slot) { return function () { return this[slot].get(); }; }
173
+ function makeSet(slot) { return function (v) { this[slot].set(v); }; }
174
+ ```
175
+
176
+ One symbol-slot load, one monomorphic call, no branch, no closure state beyond the slot. The zero-GC torture lane diffs behavior against this shape on every change; the storage layout (a unique per-property `Symbol` on the instance) was chosen over a private-backing or dictionary layout on measured fleet behavior and poison-mechanism consistency ([decisions/0003](decisions/0003-storage.md)).
177
+
178
+ **Ownership is asymmetric on purpose.** The anchor (one `createRoot` per instance) owns the deriveds and effects, so one cascade kills them all in the engine. Signal boxes are created *bare* -- not adopted by the anchor -- and disposed explicitly by `disposeReactive`. That split is what makes the accessor read body branch-free: a box that can never be half-owned needs no liveness check on the hot path; the poison swap provides the post-dispose throw instead.
179
+
180
+ **Poison is a slot swap, not a flag.** After dispose, `instance[SLOT]` holds a poison handle whose `get`/`set` throw `ReactiveDisposedError` naming `Class.key`. The read body stays the unbranched canon -- disposal changes what the slot *holds*, never what the accessor *does*.
181
+
182
+ **Effects wire last and guard their manual door.** The auto-effect wraps the original method and starts at wiring, after every field and every derived of the whole chain exists. The public method you call by hand is a guard: inside a foreign tracking scope it runs the body `untrack`-ed (zero stray edges); on a receiver that was never wired to this member's class family it throws by name (byKey identity, so a subclass instance calling a base-declared method passes).
183
+
184
+ **Construction is atomic at every capacity point.** Signal boxes are born during `super()` field initialization -- *before* any try/catch of the wiring phase could exist. A module-level scratch frame records each box as it is created; the frame is owned by the most-derived wrapper only, so a `CapacityError` (or any throw) at ANY point -- K-th box, the anchor, m-th derived, m-th effect, on either construction path -- drains the frame LIFO through the bound registry and rethrows. `new` either returns a fully-wired instance or leaves the pool exactly as it found it. Nested constructions (a field initializer building another reactive VM) unwind correctly because the frame marker is a stack index. ([decisions/0002](decisions/0002-ownership-and-lifecycle.md), D-2h -- including the two hardenings review and torture forced.)
185
+
186
+ </details>
187
+
188
+ ---
189
+
190
+ ## The half-alive view-model trap
191
+
192
+ The failure mode this package is built against is not "reactivity doesn't work" -- it is the **half-alive instance**: an object that looks disposed from one side and alive from the other. It has three classic doors, and each one is closed and torture-pinned:
193
+
194
+ **1. The dangling-teardown door.** In ad-hoc class reactivity you collect dispose functions by hand; miss one and a dead panel keeps recomputing forever. Here the instance's whole graph hangs off one anchor plus a box list the *package* maintains, `disposeReactive` is idempotent, and -- the part hand-rolled layers never do -- every slot is **poisoned** afterward. A stale captured reference doesn't quietly read a frozen value or resurrect an effect; it throws `ReactiveDisposedError` with the class and key in hand. A scripted resurrection storm (every post-dispose call sequence: captured-box writes, `using` re-entry, double dispose, cross-instance writes) must produce zero effect executions and zero derived recomputes, on every release.
195
+
196
+ **2. The cross-registry door.** lite-signal's default `dispose` is documented as a *silent no-op* across registries -- the engine's one fail-open corner. A class layer that creates boxes on a custom registry but disposes through top-level helpers would leak every node while returning normally. So the package refuses the door entirely: `@reactiveHost({ registry })` binds the WHOLE host chain, every engine call routes through the bound registry, and a subclass trying to bind a *different* registry is a named throw. One chain, one graph, no silent mismatch.
197
+
198
+ **3. The mid-construction door.** A capacity overflow while the K-th of P boxes is being created would classically leak boxes 1..K-1 -- `new` throws, nobody owns the orphans, conservation is off by K-1 forever. The scratch-frame protocol ([deep-dive above](#how-it-works)) makes construction transactional: any throw at any creation point tears down exactly what was built, LIFO, and rethrows. The torture suite primes a registry to N-epsilon and drives the overflow into *every* failure point on *both* construction paths, asserting node-exact conservation and that the identical construction succeeds once headroom returns.
199
+
200
+ ---
201
+
202
+ ## API reference
203
+
204
+ ### Member decorators
205
+
206
+ All four take a bare form and a factory form (`@reactive` and `@reactive({...})` both work).
207
+
208
+ | Decorator | Placement | Options | Behavior |
209
+ |---|---|---|---|
210
+ | `@reactive` | `accessor x = v` | `equals(a, b)` | Per-instance signal box in a symbol slot. Read tracks; write propagates; custom `equals` suppresses no-op writes. |
211
+ | `@derived` | `get y()` | `equals(a, b)` | Lazy computed owned by the anchor. Recomputes on dependency change; `equals` cuts propagation when the result is unchanged. |
212
+ | `@reactiveEffect` | `m()` | `scheduler(run)` | Auto-runs as an effect at wiring, re-runs on tracked changes. `scheduler` defers re-runs (frame coalescing etc.). Manual calls: leak-guarded + identity-guarded. |
213
+ | `@batched` | `m()` | -- | Runs the body inside one engine batch: all writes flush once, at close. Nesting flushes at the outermost close. Action-grade -- see [the numbers](#the-numbers). |
214
+
215
+ ### Class decorator
216
+
217
+ | Form | Behavior |
218
+ |---|---|
219
+ | `@reactiveHost` | The single wiring site; wraps the class so the anchor + deriveds + effects are built exactly once, after all fields of the whole chain initialize. A subclass of a host may itself be a host (inheriting or repeating the chain's registry) -- however many hosts a chain carries, only the most-derived one wires. A decorated member in a hierarchy that never gets a host is the orphan throw. |
220
+ | `@reactiveHost({ registry })` | Same, with every engine call routed through a `Registry` from lite-signal's `createRegistry()`. The value is duck-checked (all 11 engine methods); one registry per host chain -- a subclass may repeat the same object or omit it, never substitute another. |
221
+
222
+ ### `defineReactive(Class, spec) -> Class'`
223
+
224
+ The buildless twin. Installs the members on `Class.prototype`, wraps the class through the same host step, returns the wrapped class.
225
+
226
+ | Spec key | Shape | Notes |
227
+ |---|---|---|
228
+ | `signals` | `["a", "b"]` or `{ key: value \| { initial \| init \| equals } }` | A plain non-function value is the initial. `initial` is taken verbatim; `init(self)` computes per instance; a bare function is a named throw (ambiguous -- wrap it). |
229
+ | `deriveds` | `{ key: (self) => value \| { get, equals } }` | |
230
+ | `effects` | `{ key: (self) => void \| { run, scheduler } }` | Map only. |
231
+ | `host` | `{ registry }` or omitted | Same validation as `@reactiveHost`. |
232
+
233
+ Symbol keys work (`Reflect.ownKeys`). A spec key colliding with an own property of `Class.prototype` is a named throw. The class's own constructor runs *before* wiring, so it must not touch spec-declared members (a prewired handle turns any such touch into a named error) -- put initials in the spec. There is no `batched` section: buildless callers use `batch`/`registry.batch` directly. Post-construction behavior is in full parity with the decorator path -- a 300-seed fuzzer holds both forms in lockstep to prove it.
234
+
235
+ ### Lifecycle & interop
236
+
237
+ | Export | Signature | Behavior |
238
+ |---|---|---|
239
+ | `disposeReactive` | `(vm) => boolean` | Cascade + poison teardown. `true` on the first call, `false` after (idempotent). Also wired to `Symbol.dispose`, so `using vm = new Player()` disposes at block exit. Refuses a frozen instance up front (named throw, nothing half-done). |
240
+ | `boxOf` | `(vm, key) => SignalBox \| ComputedBox` | The live engine box behind a `@reactive`/`@derived` member -- `.peek()`, `.subscribe()`, raw interop. Unknown key: named throw with a did-you-mean. After dispose: `ReactiveDisposedError`. |
241
+ | `rootOf` | `(vm) => NodeDescriptor` | The instance's anchor descriptor -- feeds `forEachOwned` and lite-devtools. Throws `ReactiveDisposedError` after dispose. |
242
+
243
+ ### Errors & constants
244
+
245
+ | Export | Value |
246
+ |---|---|
247
+ | `ReactiveDisposedError` | `extends Error`; `name: "ReactiveDisposedError"`; fields `className`, `key`. Thrown on ANY touch of a disposed instance's surface. |
248
+ | `VERSION` | `"0.2.0"` |
249
+
250
+ ### The rejection matrix
251
+
252
+ Everything below is a **named throw at decoration/definition time** -- never a silent downgrade: legacy (experimental) decorator emit; wrong decorator kind; static members; private `#` members; unknown option keys (nearest-key did-you-mean); non-function `equals`/`scheduler`; double `@reactiveHost`; unknown host options; orphaned members (a decorated member whose class never gets a host); duplicate keys (subclass redeclaration or two stacked package decorators on one member); an invalid or partial `registry`; a heterogeneous registry chain; and every malformed `defineReactive` spec shape.
253
+
254
+ ---
255
+
256
+ ## Composability
257
+
258
+ Decorated instances, raw engine code, an isolated registry, and devtools introspection -- one graph, end to end. This block is buildless, so it runs verbatim in stock Node:
259
+
260
+ ```js
261
+ import { createRegistry } from "@zakkster/lite-signal";
262
+ import { defineReactive, disposeReactive, boxOf, rootOf } from "@zakkster/lite-signal-decorators";
263
+
264
+ // An isolated world: its own node pool, its own graph.
265
+ const world = createRegistry({ maxNodes: 256, onCapacityExceeded: "throw" });
266
+
267
+ class Ship {}
268
+ const ReactiveShip = defineReactive(Ship, {
269
+ signals: { hull: 100 },
270
+ deriveds: { dead: (self) => self.hull <= 0 },
271
+ host: { registry: world },
272
+ });
273
+
274
+ const fleet = Array.from({ length: 3 }, () => new ReactiveShip());
275
+
276
+ // A RAW engine effect in the same graph, reading a decorated member:
277
+ const stop = world.effect(() => {
278
+ console.log("dead ships:", fleet.filter((s) => s.dead).length);
279
+ }); // logs: dead ships: 0
280
+
281
+ fleet[1].hull = 0; // decorated write -> raw effect: dead ships: 1
282
+
283
+ // boxOf hands the live box -- raw writes flow back the other way:
284
+ boxOf(fleet[0], "hull").set(-5); // raw write -> decorated derived: dead ships: 2
285
+
286
+ // rootOf feeds the registry's ownership walk (and lite-devtools):
287
+ world.forEachOwned(rootOf(fleet[2]), (d) => console.log("owned node:", d.kind));
288
+
289
+ // Deterministic shutdown, conservation-exact:
290
+ world.dispose(stop);
291
+ for (const s of fleet) disposeReactive(s);
292
+ console.log(world.stats().activeNodes); // 0 -- every node returned to the pool
293
+ ```
294
+
295
+ The default registry never notices any of it: bound-registry churn leaves outside `stats()` frozen (torture-pinned). And because `boxOf` returns the *engine's* box, everything lite-signal composes with -- subscriptions, `peek`, batch, untrack, lite-raf frame effects -- composes with decorated members too.
296
+
297
+ ---
298
+
299
+ ## The numbers
300
+
301
+ Measured, not asserted: every figure below comes from a committed probe you can re-run, and the repository's gates fail if the zero-allocation claims drift. Rig for the figures quoted: Node v26.3.1, arm64 (Apple M4 Pro), lite-signal 1.5.0, K=10 cold child processes, inner median-of-5, anti-DCE sink. The ratios are what is stable across machines, not the absolute nanoseconds.
302
+
303
+ ### The honest read cost (`spikes/storage-bench.mjs`)
304
+
305
+ The fair baseline for a decorated property is a hand-written instance field (`this.bx.get()`) -- not a module-level signal, which no one writes in a class:
306
+
307
+ | Read | vs module-const box | vs instance-field box | 10k-instance fleet (ns/op) |
308
+ |---|---:|---:|---:|
309
+ | Module-const `signalBox` (floor) | 1.000x | 0.464x | 5.22 |
310
+ | Hand-written instance field | 2.13x | 1.000x | 6.57 |
311
+ | **Decorated accessor (this package)** | **2.12x** | **~1.0x** (median 1.01x, worst cold-vs-cold 1.08x) | **7.81** |
312
+ | String-keyed dict layout (rejected) | 2.19x | 1.03x | 9.93 |
313
+
314
+ A decorated reactive property costs **~1.0x a hand-written instance-field signal read**; both are ~2x a module-level signal because that is the cost of per-instance storage, paid either way -- an engine indirection, not a decorator tax. Writes are ~2.5-3x a module-const read across all instance layouts (box `.set` propagation dominates; inherent to any reactive write). The rejected dictionary layout is the one that *degrades at fleet scale* (cross-instance IC megamorphism) -- the hazard only a class-shaped benchmark exposes, and the reason this package doesn't use one.
315
+
316
+ ### `@batched` per call (`spikes/batched-cost.mjs`)
317
+
318
+ | Path | ns/op |
319
+ |---|---:|
320
+ | Plain unbatched method (2 writes) | 11.67 |
321
+ | Raw `batch(fn)` | 15.25 |
322
+ | `@batched` method | 22.12 |
323
+
324
+ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocates per call -- **by design**. `@batched` is action-grade (one call per user intent), deliberately excluded from the zero-GC gates; per-frame hot lanes stay on plain accessor writes.
325
+
326
+ ### Per instance
327
+
328
+ `P + D + E + 1` pool nodes -- one per signal, per derived, per effect, plus the anchor. All of them return to the pool on dispose: conservation is node-exact (`activeNodes` to baseline, zero pool growths, allocations minus disposals reconciled) over 4096-cycle churn and a wall-clock soak.
329
+
330
+ <details>
331
+ <summary><strong>Zero-GC design notes: the allocation table + the gates</strong></summary>
332
+
333
+ | Operation | Allocates | Notes |
334
+ |---|---|---|
335
+ | `vm.x` read | none | `this[SLOT].get()` -- allocation-free within measurement resolution (<= the 0.589 B/op noise-floor control; `gc.major === 0` in every lane) |
336
+ | `vm.x = v` write | none | `this[SLOT].set(v)`; propagation runs on engine pool nodes, not fresh heap |
337
+ | `@derived get` read | none | lazy computed read |
338
+ | Effect re-run | none retained | gated: zero major GC across the read/write torture lanes |
339
+ | `@batched m()` call | 1 thunk + 1 rest array | the documented, measured exception (+7 ns vs raw batch); action-grade only |
340
+ | `new Host()` | P + D + E + 1 pool nodes | plus the instance itself; nodes recycle on dispose (F-0 conservation) |
341
+ | `disposeReactive(vm)` | none | allocation-free success path; poison handles are prebuilt per member at decoration time |
342
+ | `boxOf` / `rootOf` / any throw | cold path | introspection and failure paths may allocate; never on the hot path |
343
+
344
+ The gates that hold it (run on every change, all green at 0.2.0):
345
+
346
+ - `npm test` / `npm run test:gc` -- **171/171** on both lanes.
347
+ - Suite gate (lite-leak + lite-gc-profiler): `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | ok`.
348
+ - Torture: **12/12 scenarios** (zero-GC read/write lanes at `maxMajor 0, maxPauseMs 4`; 4096-cycle leak gate at 0 live / 0 findings / 0 warnings; capacity atomicity at every overflow point; a 300-seed x 20k-op oracle with zero divergences) -- plus **12/12 sabotage controls** proving each gate can actually fail.
349
+ - `churn-soak`: sustained construct/use/dispose for a wall-clock budget; pools at floor and retained heap flat at every sample.
350
+
351
+ The cross-framework churn benchmark (vs MobX-style layers and signal-utils, with the same anti-DCE rig) is the 0.3.0 milestone; the numbers above are the package's own committed probes.
352
+
353
+ </details>
354
+
355
+ ---
356
+
357
+ ## Design decisions worth knowing
358
+
359
+ Full rationale lives in [`decisions/`](decisions/) -- each is a numbered, dated record with its measured evidence. The ones that shape daily use:
360
+
361
+ - **One registry per host chain.** `@reactiveHost({ registry })` binds everything; a descendant passing a *different* registry is a named throw. This is what closes the engine's silent cross-registry dispose no-op. `setDefaultRegistry` mid-life is out of contract (the facade captures import-time bindings).
362
+ - **Derived getters must be pure -- effects may self-dispose.** `disposeReactive(this)` from inside your own `@derived` computation throws by name (it would silently drop the derivation's value). From inside an *owned* `@reactiveEffect` it is allowed with pinned semantics: the current run completes, later decorated touches in that body throw `ReactiveDisposedError`, no re-runs follow, conservation stays exact.
363
+ - **Two documented limits of the derived guard** (pinned in torture so drift is loud): an indirect self-dispose routed through an intermediate *raw* computed is not catchable with the 1.5.0 engine surface, and an explicit `untrack()` wrap bypasses the guard -- the escape hatch working as intended.
364
+ - **Manual effect/batched calls are guarded twice.** Inside a foreign tracking scope the body runs untracked (zero stray dependency edges); on a foreign/null/primitive/cross-class receiver it throws by name. Subclass instances calling base-declared methods pass (byKey identity).
365
+ - **Frozen instances refuse disposal up front.** `Object.freeze(vm)` makes the poison swap impossible, so `disposeReactive` throws by name *before* touching anything -- no half-dispose. `seal`/`preventExtensions` are fine.
366
+ - **Symbol-slot storage.** Chosen over the emitter's private backing (emitter-dependent codegen) and a dict (fleet megamorphism, measured) -- and it is the same mechanism poison uses, so storage, dispose, and poison are one design.
367
+ - **Statics and `#` privates are rejected, not half-supported.** A module-level signal belongs to raw lite-signal; a private member can't be reached by the wiring protocol -- both are named decoration-time throws.
368
+
369
+ ---
370
+
371
+ ## Testing (for clients & QA)
372
+
373
+ ```bash
374
+ npm test # node --test, 171 tests
375
+ npm run test:gc # the same 171 with --expose-gc (enables the allocation assertions)
376
+ ```
377
+
378
+ **171 tests** across eleven files, all green at 0.2.0. The decorator protocol is tested three times over: against a mock Stage-3 emitter *and* against committed real TypeScript 5 and Babel `2023-11` emits, so both toolchains' codegen is pinned, not assumed.
379
+
380
+ | File | Tests | Covers |
381
+ |---|---:|---|
382
+ | `01-protocol-mock` | 30 | Decorator protocol on the mock Stage-3 emitter: wiring, values, options, rejection matrix |
383
+ | `02-fixtures-ts` | 19 | The same laws on real TypeScript 5 emit (committed fixtures) |
384
+ | `03-fixtures-babel` | 19 | The same laws on real Babel `2023-11` emit |
385
+ | `04-fixture-freshness` | 1 | Fixture hashes match the sources (stale-emit guard) |
386
+ | `05-wiring` | 5 | Anchor creation, wiring order, leaf-wires-once |
387
+ | `06-dispose` | 6 | Cascade, idempotency, poison, `using` |
388
+ | `07-qa-boundary` | 13 | S1 adversarial boundary pins |
389
+ | `08-effects` | 20 | `@reactiveEffect`/`@batched`: auto-run timing, untracking, scheduler, self-dispose, inheritance, registries |
390
+ | `09-buildless` | 16 | `defineReactive` parity + the spec rejection matrix |
391
+ | `10-qa-s2a-boundary` | 34 | Adversarial pins: identity guard, frozen dispose, registry heterogeneity, stacking |
392
+ | `11-qa-s2b-boundary` | 8 | Construction-throw boundaries: init-phase drain, chain-base throws, overflow storms |
393
+
394
+ ### The torture suite (dev-side, never shipped)
395
+
396
+ Process-isolated stress scenarios built on `@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`:
397
+
398
+ ```bash
399
+ npm run torture # all 12 scenarios
400
+ npm run torture:semantic # the correctness lane (CI)
401
+ npm run torture:soak # the wall-clock churn soak
402
+ npm run torture:controls # sabotage self-test: every scenario must FAIL when broken
403
+ ```
404
+
405
+ Twelve scenarios: emit-matrix, ordering, lifecycle, pool-conservation, zero-GC lanes, capacity atomicity (every overflow point x both construction paths), the full disposed-poison surface + resurrection storms, a 4096-cycle lite-leak gate, a **300-seed x 20k-op oracle fuzzer** (decorated vs hand-wired raw twin in lockstep: every derived value, every effect fire count, every graph opcode tally), raw/decorated interop + cross-registry + `registry.destroy()` contracts, batch/untrack semantics, and the churn soak. Every scenario carries a `TORTURE_BREAK` sabotage control that must exit non-zero -- a gate that cannot fail is not a gate. Seeded lanes replay exactly via `TORTURE_SEED`.
406
+
407
+ ---
408
+
409
+ ## Compatibility
410
+
411
+ | Path | Requirement |
412
+ |---|---|
413
+ | `@` decorator syntax | A Stage-3 (2023-11) decorator toolchain: **TypeScript >= 5.0** (standard decorators -- leave `experimentalDecorators` unset/false) or **Babel** with `["@babel/plugin-proposal-decorators", { "version": "2023-11" }]`. Both emits are first-class: the suite pins each with committed fixtures. |
414
+ | `defineReactive` | Nothing. Any ESM runtime. |
415
+ | Runtime | Node >= 18 (ESM-only, `sideEffects: false`); browsers via any ESM bundler or native modules. No DOM dependency anywhere in the package. |
416
+ | Peer | `@zakkster/lite-signal` `>=1.5.0 <2.0.0`, installed at the top level (one engine instance, one graph). |
417
+
418
+ Native (untranspiled) `@` decorators in engines: not shipped anywhere yet -- until they are, the decorator syntax is toolchain-only and `defineReactive` is the no-build door.
419
+
420
+ ---
421
+
422
+ ## What this is not
423
+
424
+ - **Not a home for module-level or global signals.** That is raw `lite-signal` territory; `static` members are rejected by design.
425
+ - **Not a per-frame action system.** `@batched` costs a measured thunk per call -- fine for "one call per user intent", wrong inside a render loop. Per-frame hot lanes stay on plain accessor writes (and frame *scheduling* belongs to `lite-raf`).
426
+ - **Not a framework, renderer, or component model.** It ends at the reactive view-model; DOM binding is `lite-signal-dom`'s job.
427
+ - **Not a MobX API shim.** No `makeObservable`, no administration objects, no proxy magic -- and no GC-based cleanup: disposal is explicit, deterministic, and verified, because "the collector will get it eventually" is not a lifecycle.
428
+ - **Not usable as `@` syntax without a toolchain** -- that is exactly what `defineReactive` exists for.
429
+
430
+ ---
431
+
432
+ ## Ecosystem
433
+
434
+ | Package | Relation |
435
+ |---|---|
436
+ | [`@zakkster/lite-signal`](https://www.npmjs.com/package/@zakkster/lite-signal) | The engine underneath -- pooled fine-grained signals. The one peer dependency. |
437
+ | `@zakkster/lite-signal-dom` | DOM bindings for the same engine -- where a view-model meets actual elements. |
438
+ | `@zakkster/lite-raf` | Frame-rate scheduling for the same graph; the frame-coalescing pattern the `scheduler` option on `@reactiveEffect` exists to plug into. |
439
+ | `@zakkster/lite-devtools` | Graph inspection; `rootOf(vm)` + `forEachOwned` is the hook it walks. |
440
+ | `@zakkster/lite-leak` + `@zakkster/lite-gc-profiler` | The dev-side harness that proves every retention and allocation claim in this README. Never shipped to consumers. |
441
+
442
+ ---
443
+
444
+ ## FAQ
445
+
446
+ **Why the `accessor` keyword?**
447
+ It is the Stage-3 mechanism that gives a decorator both an `init` hook (create the box during field initialization -- eagerly, so the getter carries no `if (!box)` lazy branch) and replaceable get/set bodies, without per-instance `defineProperty` calls or a base class. `@reactive` on a plain field is a named throw pointing you to `accessor`.
448
+
449
+ **How is this different from MobX's `@observable`?**
450
+ Philosophy: MobX trades allocation and administration overhead for maximal transparency; this package trades a little syntax (`accessor`, explicit dispose) for zero hot-path allocation, a node-exact instance cost, and a teardown you can prove. There is no proxy, no administration object per instance, and nothing is left for the GC to find "eventually" -- which is precisely what makes 10k-instance fleets and long sessions flat.
451
+
452
+ **Why explicit dispose? Can't the GC handle it?**
453
+ Reactive nodes live in lite-signal's pre-allocated pool and effects are *reachable from the graph* -- a forgotten view-model is not garbage, it is a live subscriber that keeps firing. `FinalizationRegistry` is non-deterministic and unobservable in torture terms. Explicit `disposeReactive` (or `using`) is one call, idempotent, allocation-free -- and the poison swap turns any lifecycle bug into a named, debuggable throw instead of a silent leak.
454
+
455
+ **What happens if the pool fills up mid-`new`?**
456
+ The engine's `CapacityError` propagates from the constructor and construction is **atomic**: everything already created for that instance is rolled back, node-exact -- no leak, and the same construction succeeds once headroom exists. If you need more headroom, size the registry:
457
+
458
+ ```js
459
+ import { createRegistry } from "@zakkster/lite-signal";
460
+ const world = createRegistry({ maxNodes: 8192, onCapacityExceeded: "grow" });
461
+
462
+ @reactiveHost({ registry: world })
463
+ class Entity { /* ... */ }
464
+ ```
465
+
466
+ **Can decorated instances and raw lite-signal code share a graph?**
467
+ Yes -- that is a torture-pinned contract, both directions: raw effects reading decorated members, decorated deriveds reading raw boxes, subscriptions through `boxOf` handles. Decorated members ARE engine boxes; there is no wrapper layer to cross.
468
+
469
+ **Why is passing a different registry to a subclass an error?**
470
+ Because an instance whose base-class boxes live in one pool and whose subclass boxes live in another cannot be disposed correctly by anyone -- the engine's cross-registry dispose is a silent no-op. One chain, one registry is the only shape with a provable teardown, so any other shape throws at decoration time.
471
+
472
+ **Is `@batched` free?**
473
+ No, and the README says so with numbers: 22.12 ns/op vs 15.25 raw vs 11.67 unbatched on the reference rig -- a thunk + rest-array per call. Use it for actions; keep per-frame writes on plain accessors.
28
474
 
29
- Requires `@zakkster/lite-signal` `>=1.5.0 <2.0.0` as a peer dependency, and a
30
- Stage-3 decorator toolchain (TS 5 with `experimentalDecorators: false`, or Babel
31
- `2023-11`).
475
+ **Where are `costOf`, labels, the audit hook, private members?**
476
+ Not in 0.2.0 -- the runtime surface is complete and frozen for 0.x, and the introspection layer is roadmapped behind the 0.3.0 benchmark. The `llms.txt` scope note tracks exactly what is and isn't included.
32
477
 
33
- The full README -- positioning, deep-dives, API reference, composability
34
- pipeline, zero-GC design notes, and gated quality numbers -- lands at 1.0.0.
478
+ ---
35
479
 
36
480
  ## License
37
481