@zakkster/lite-signal-decorators 1.1.0 → 1.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,157 @@ 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
+ ## [1.2.0] - 2026-08-30
8
+
9
+ The flagship of the decisions/0013 strategic-admission track: a story-grade,
10
+ release-after-release ladder (S8 `@localTo` -> S9 `snapshotOf` -> S10
11
+ `costOfInstance` -> S11 fleet helpers), and this is its first rung. `@localTo`
12
+ clears strategic criterion (a) -- impossible to compose correctly on the shipped
13
+ surface: PD-51 measured the effect-based recipe clobbering a user write one tick
14
+ late, so glitch-free reset needs in-package compare-on-read machinery. The one
15
+ release nobody else can copy from a recipe.
16
+
17
+ ### Added
18
+
19
+ - **`@localTo(source, { equals? })`** -- upstream-keyed resettable local state
20
+ (19th export; the 18-member surface grows by exactly one). A field FOLLOWS
21
+ `source(self)` until someone writes it, a write OVERRIDES, and a changed
22
+ upstream RESETS it -- decided by compare-on-read, so it is glitch-free,
23
+ synchronous, and PURE (no box write on the read path; legal inside any
24
+ `@derived`). `source` is a REQUIRED tracked `(self) => value` fn read inline
25
+ (no extra node). `equals` (default `Object.is`) governs the upstream compare
26
+ ONLY -- the write path never compares.
27
+ - **The `locals` buildless section** -- `defineReactive(Class, { ..., locals })`
28
+ with `locals: { key: { source, equals?, initial? } }`, the buildless twin of
29
+ `@localTo` (fail closed on a missing/non-fn source), full parity with the
30
+ decorator path on both emit lanes.
31
+ - **The initial-value unification rule** (decisions/0014): a declared
32
+ initializer means the member STARTS there and resets on the first upstream
33
+ move (the `@trackedReset` flavor); an OMITTED initializer means the initial is
34
+ the source evaluated once at wiring and the member follows upstream from the
35
+ first read (the `@localCopy` flavor). One decorator, both field semantics,
36
+ selected by the natural syntax.
37
+ - **The ABA contract, stated plainly** (decisions/0014, shipped + asserted): no
38
+ public revision counter exists (NodeDescriptor is `{id, kind, value}`), so the
39
+ upstream compare is VALUE-based. Upstream A -> local write X -> upstream B ->
40
+ upstream back to an equals-A value leaves the read showing the STALE LOCAL X --
41
+ the reset requires upstream to change relative to the last adoption, not to
42
+ have moved transitively. tracked-toolbox's `@localCopy` has the same property;
43
+ it is documented in README/llms.txt, pinned in torture (S8-A6), never softened.
44
+ - **The `localto-torture` lane** (`test/torture/localto-torture.mjs`, scenario
45
+ 13 of 17) with its own `TORTURE_BREAK` sabotage control: the zero-alloc
46
+ read/write storm, the ABA-stale write/reset interleave asserted AS the
47
+ contract, pooled park/reinit box+seen reset, and tracking-edge + pure-compute
48
+ pins.
49
+ - **`test/17-localto.test.mjs`** (34 cases) -- the full lattice on both emit
50
+ lanes plus buildless `locals`: read/write/reset, both initial flavors, the ABA
51
+ contract, `equals` override survival, park/reinit reset, `costOf` accounting,
52
+ source-throw fail-closed, and the option/source rejection matrix.
53
+ - Emit fixtures extended: `fixture.src.ts` gains a `@localTo` member; the two
54
+ compiled outs + the source hash regenerate (no new fixture row or file).
55
+
56
+ ### Changed
57
+
58
+ - **The per-instance cost formula is now `P + L + D + E + 1`** (was `P + D + E +
59
+ 1`): one signal box per local plus the anchor; the plain per-instance seen-slot
60
+ is never a node (+0). `costOf` returns the `L` term; `capacityFor` sizes it.
61
+ Reflected everywhere the formula appears in README/llms.txt.
62
+ - The shipped `SignalDecorators.d.ts` header de-staged to emitter-named
63
+ phrasing (PD-49) -- a 1.1.1 zero-grep escapee (the sweep listed the `.js`
64
+ but not the `.d.ts`), caught by the S8 review.
65
+ - **The export surface: 18 -> 19** -- an additive MINOR under the 1.0.0 semver
66
+ promise (new exports are minors). The 1.0.0 hot canon
67
+ (`makeGet`/`makeSet`/`makeDerivedGet`) stays byte-identical: `@localTo` ships
68
+ its own accessor bodies and pays its own measured cost.
69
+ - Three-place version sync to 1.2.0 (`package.json`, the `VERSION` const,
70
+ `llms.txt`); the `test/15` surface-freeze recount 18 -> 19.
71
+
72
+ ### Measured (rig: Node v26.3.1, arm64 Apple M4 Pro, lite-signal 1.5.0)
73
+
74
+ - A `@localTo` read measures **1.69x** a plain decorated read (two tracked reads
75
+ + a compare, versus one box read) -- documented as-is, not softened.
76
+ - Read AND write storms at N and 8N: **0.000 B/op** (control-relative +2 B), gc
77
+ major **0**, `maxPauseMs <= 0.08`; a derived over one local holds EXACTLY 2
78
+ source edges (upstream + box), 0 extra nodes over 1e5 reads.
79
+ - 4096 park/reinit cycles: `tracker.size()` 0, findings 0, warnings 0,
80
+ `activeNodes` to exact baseline, zero pool growths; release frees exactly
81
+ `P + L + D + E + 1` nodes.
82
+
83
+ Records: decisions/0013 (strategic-admission track), decisions/0014 (the localTo
84
+ contract + the ratified spike numbers), `spikes/localto-contract.mjs` (Q1..Q6,
85
+ EXIT A green against peer 1.5.0).
86
+
87
+ ### Gate output (section-10 chain, archived verbatim)
88
+
89
+ ```
90
+ fixtures OK exit 0 -- emit fixtures regenerated
91
+ test OK exit 0 -- 291 pass / 0 fail
92
+ test:gc OK exit 0 -- 291 pass / 0 fail
93
+ torture OK exit 0 -- 15 passed, 2 skipped, 0 warned, 0 failed in 33.5s
94
+ torture:controls OK exit 0 -- 17 passed, 0 skipped, 0 warned, 0 failed in 2.8s
95
+ torture:peer-preview REPORTED NON-BLOCKING -- lane completed (exit 0) [preview 1.9.0-preview.6 SUITE-GREEN 17/0/0/0; canary 1.9.0-canary.1 SUITE-GREEN 17/0/0/0]
96
+ bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
97
+ cookbook OK exit 0/0 -- corpus 18/18 companions ok in 2.1s; controls 8/8 controls fail correctly in 5.1s
98
+ pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
99
+ ----------------------------------------------------------------------
100
+ GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
101
+ ```
102
+
103
+ ## [1.1.1] - 2026-08-30
104
+
105
+ A docs-accuracy patch. No runtime, fixture, or emit-matrix byte changed; the
106
+ accessor canon and every export are byte-identical to 1.1.0. The only test
107
+ changes are `test/15`'s cookbook ground-truth recount for wave 2 and two
108
+ comment-only lines in the gate's step description -- no budget moved anywhere.
109
+ Surface stays at 18 exports; pack stays the same 7-file set.
110
+
111
+ ### Changed
112
+
113
+ - Standards phrasing pass across README, llms.txt, package.json, the main-file
114
+ header, and the catalog card. TC39 lists the decorators proposal (and
115
+ Decorator Metadata) at Stage 2.7 since the 2026-05 plenary, down from Stage 3;
116
+ the emitters are unchanged -- TypeScript 5.x standard emit and Babel `2023-11`
117
+ remain the only real-world paths, no native engine ships decorators. Each doc
118
+ now names the fact once and refers to the protocol by its emitters everywhere
119
+ else, since stage labels drift and emitter names do not. Only the stage LABEL
120
+ moved: the emit matrix, the committed TS/Babel fixtures, and every test are
121
+ byte-untouched by this pass. Recorded in `research/feature-gap-2026-08-30.md` section 1 and
122
+ the `decisions/0011` addendum. Three-place version sync to 1.1.1
123
+ (`package.json`, the `VERSION` const, `llms.txt`).
124
+
125
+ ### Added
126
+
127
+ - Cookbook wave 2: six recipes appended, `r12`..`r17`.
128
+ - `r12` -- Wait for a condition, as a Promise.
129
+ - `r13` -- React to a computed value, not every write (GATED).
130
+ - `r14` -- Tie teardown to an AbortSignal.
131
+ - `r15` -- Async state without async in the graph.
132
+ - `r16` -- Read without subscribing.
133
+ - `r17` -- Start the resource when someone is watching (GATED).
134
+ - Cookbook gated set grows 6 -> 8 (adds `r13`, `r17`).
135
+ - Publications refresh (P3): the outward drafts restamped to the 1.1.x story;
136
+ files stay git-untracked.
137
+ - Bench chart artifact (closes ROADMAP open question 5): `bench/chart.mjs`
138
+ renders `bench/results-chart.svg` -- the CHURN lane (ops/s + heapMed per
139
+ adapter) parsed verbatim from the stamped `bench/results.txt`, deterministic
140
+ and dev-side only (never in `files[]`); wired into the README numbers section.
141
+
142
+ ### Gate output (section-10 chain, archived verbatim)
143
+
144
+ ```
145
+ fixtures OK exit 0 -- emit fixtures regenerated
146
+ test OK exit 0 -- 257 pass / 0 fail
147
+ test:gc OK exit 0 -- 257 pass / 0 fail
148
+ torture OK exit 0 -- 14 passed, 2 skipped, 0 warned, 0 failed in 33.1s
149
+ torture:controls OK exit 0 -- 16 passed, 0 skipped, 0 warned, 0 failed in 2.0s
150
+ torture:peer-preview REPORTED NON-BLOCKING -- lane completed (exit 0) [preview 1.9.0-preview.6 SUITE-GREEN 16 passed, 0 skipped, 0 warned, 0 failed; canary 1.9.0-canary.1 SUITE-GREEN 16 passed, 0 skipped, 0 warned, 0 failed]
151
+ bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
152
+ cookbook OK exit 0/0 -- corpus 18/18 companions ok in 2.1s; controls 8/8 controls fail correctly in 5.0s
153
+ pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
154
+ ----------------------------------------------------------------------
155
+ GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
156
+ ```
157
+
7
158
  ## [1.1.0] - 2026-08-30
8
159
 
9
160
  The pooled-lifetime release: an instance is no longer single-lifetime. The
package/README.md CHANGED
@@ -10,11 +10,12 @@
10
10
  ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational?style=for-the-badge)
11
11
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
12
12
 
13
- > Stage-3 decorators that turn a plain class into a reactive view-model with a
14
- > measured per-property cost, one deterministic teardown, and poison-on-dispose
15
- > safety -- built on @zakkster/lite-signal.
13
+ > Standard decorators (TC39 decorators proposal, Stage 2.7 since 2026-05; TS
14
+ > 5.x / Babel 2023-11 emit unchanged) that turn a plain class into a reactive
15
+ > view-model with a measured per-property cost, one deterministic teardown, and
16
+ > poison-on-dispose safety -- built on @zakkster/lite-signal.
16
17
 
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
+ **`@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 + L + 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
19
 
19
20
  ```js
20
21
  import { reactive, derived, reactiveHost, disposeReactive } from "@zakkster/lite-signal-decorators";
@@ -70,7 +71,7 @@ npm i @zakkster/lite-signal-decorators @zakkster/lite-signal
70
71
 
71
72
  `@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.
72
73
 
73
- 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.
74
+ ESM-only. Ships TypeScript definitions. Node >= 18. The `@` syntax needs a standard-decorators toolchain (TypeScript 5 or Babel `2023-11` -- see [Compatibility](#compatibility)); [`defineReactive`](#definereactiveclass-spec---class) needs nothing.
74
75
 
75
76
  ### Quick start
76
77
 
@@ -99,7 +100,7 @@ class Player {
99
100
  }
100
101
  }
101
102
 
102
- const p = new Player(); // exactly 2 + 2 + 1 + 1 = 6 pool nodes (P + D + E + 1)
103
+ const p = new Player(); // exactly 2 + 0 + 2 + 1 + 1 = 6 pool nodes (P + L + D + E + 1)
103
104
  p.hit(80); // shield 0, hp 70 -> status still "healthy": effect does NOT re-run
104
105
  p.hit(20); // hp 50 -> "critical": effect runs once
105
106
  disposeReactive(p); // effect stopped, deriveds + boxes disposed, slots poisoned
@@ -137,6 +138,7 @@ const ReactivePlayer = defineReactive(Player, {
137
138
 
138
139
  - **`@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.
139
140
  - **`@derived get y()`** -- a lazy computed owned by the instance's anchor, with optional custom `equals` for change-cutoff.
141
+ - **`@localTo(source) accessor x = v`** -- upstream-keyed resettable local state: reads follow `source(self)` until you write, a write overrides, and a changed upstream resets it -- glitch-free by compare-on-read, no effect, no extra tick. With an initializer the field starts there and resets on the first upstream move; without one it follows upstream from wiring. One signal box + one plain seen-slot per member; the read is pure, so it is legal inside any `@derived`.
140
142
  - **`@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).
141
143
  - **`@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.
142
144
  - **`@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.
@@ -204,7 +206,7 @@ The failure mode this package is built against is not "reactivity doesn't work"
204
206
 
205
207
  ### Member decorators
206
208
 
207
- All four take a bare form and a factory form (`@reactive` and `@reactive({...})` both work).
209
+ The first four take a bare form and a factory form (`@reactive` and `@reactive({...})` both work); `@localTo` is the exception -- it always takes a required `source` argument (detailed below the table).
208
210
 
209
211
  | Decorator | Placement | Options | Behavior |
210
212
  |---|---|---|---|
@@ -212,6 +214,24 @@ All four take a bare form and a factory form (`@reactive` and `@reactive({...})`
212
214
  | `@derived` | `get y()` | `equals(a, b)` | Lazy computed owned by the anchor. Recomputes on dependency change; `equals` cuts propagation when the result is unchanged. |
213
215
  | `@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. |
214
216
  | `@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). |
217
+ | `@localTo` | `accessor x = v` | `equals(a, b)` | Upstream-keyed resettable local state. Read follows `source(self)` until written, a write overrides, a changed upstream resets. Compare-on-read (pure); `equals` governs the upstream compare only. Takes a REQUIRED `source` argument -- see below. |
218
+
219
+ ### `localTo(source, { equals? }?)`
220
+
221
+ `@localTo(source)` declares a field that **follows an upstream value until someone writes it, then resets when upstream changes** -- the "local copy you can edit, that re-syncs on a real update" pattern, done glitch-free with no effect and no extra tick. `source` is a REQUIRED tracked `(self) => value` function, read inline on every get (no extra node). The get is **pure** -- it compares `source(self)` to a per-instance last-seen slot and returns the local box when upstream is unchanged, else the upstream value; it never writes a box, so a `@localTo` read is legal inside any `@derived`. A write always overrides (the write path never compares). `{ equals }` (default `Object.is`) governs the **upstream** compare only.
222
+
223
+ Two field flavors, selected by the natural syntax (the [initial-value unification rule](decisions/0014-localto-contract.md)):
224
+
225
+ ```js
226
+ @reactiveHost
227
+ class Field {
228
+ @reactive accessor upstream = "server";
229
+ @localTo((self) => self.upstream) accessor draft; // no initial: FOLLOWS upstream from wiring
230
+ @localTo((self) => self.upstream) accessor pinned = ""; // initial: STARTS "", resets on first upstream move
231
+ }
232
+ ```
233
+
234
+ **The ABA contract (honest, shipped, never softened).** The upstream compare is VALUE-based -- lite-signal exposes no public revision counter, and reaching for a private one would be impure. So the reset triggers when upstream *changes relative to the last adoption*, not when it has moved transitively: upstream `A` -> local write `X` -> upstream `B` -> upstream back to an equals-`A` value leaves the read showing the **stale local `X`**. tracked-toolbox's `@localCopy` has the same property. A coarse custom `equals` widens override survival on purpose. See the [compare-on-read design bullet](#design-decisions-worth-knowing).
215
235
 
216
236
  ### Class decorator
217
237
 
@@ -228,6 +248,7 @@ The buildless twin. Installs the members on `Class.prototype`, wraps the class t
228
248
  |---|---|---|
229
249
  | `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). |
230
250
  | `deriveds` | `{ key: (self) => value \| { get, equals } }` | |
251
+ | `locals` | `{ key: { source, equals?, initial? } }` | Map only. The buildless twin of `@localTo`. `source` REQUIRED and a `(self) => value` fn (missing/non-fn is a named throw); `equals` governs the upstream compare; `initial` (verbatim) selects the reset-from flavor, its absence the follow-from-wiring flavor. |
231
252
  | `effects` | `{ key: (self) => void \| { run, scheduler } }` | Map only. |
232
253
  | `host` | `{ registry }` or omitted | Same validation as `@reactiveHost`. |
233
254
 
@@ -247,7 +268,7 @@ Symbol keys work (`Reflect.ownKeys`). A spec key colliding with an own property
247
268
 
248
269
  | Export | Signature | Behavior |
249
270
  |---|---|---|
250
- | `costOf` | `(Factory) => { nodes, links, signals, deriveds, effects }` | The measured, settled per-instance cost, probed on the class's bound registry (frozen result, cached per class). Double-probed: an inconclusive or polluted probe THROWS -- never a guess. `nodes` is exactly P + D + E + 1; `links` is the first-full-read link count. |
271
+ | `costOf` | `(Factory) => { nodes, links, signals, deriveds, effects }` | The measured, settled per-instance cost, probed on the class's bound registry (frozen result, cached per class). Double-probed: an inconclusive or polluted probe THROWS -- never a guess. `nodes` is exactly P + L + D + E + 1; `links` is the first-full-read link count. |
251
272
  | `capacityFor` | `(inventory, { headroom }?) => RegistryConfig` | Sizes a `createRegistry` config from `[Factory, count]` pairs: nodes exact, links x `headroom` (floored at the engine minimum of 1), `prealloc: "eager"`, `onCapacityExceeded: "throw"`. Fail-closed inventory and options validation. Link policy + caveats: [decisions/0007](decisions/0007-capacity-policy.md). |
252
273
  | `enableLabels` / `labelOf` | `(on)` / `(idOrHandle, registry?) => string \| undefined` | Opt-in devtools identity (default OFF): while on, wiring registers per-registry `nodeId -> "Class.prop"` / `"Class#method"` / `"Class@anchor"`; dispose unregisters. `labelOf` misses return `undefined`, never throw. |
253
274
  | `auditReactive` | `(on)` | Opt-in leak auditor (default OFF): a lazily-created `FinalizationRegistry` reports any instance collected WITHOUT `disposeReactive`, naming class and shape. Holds no instance references itself; zero cost and zero registrations while off. |
@@ -259,7 +280,7 @@ With labels and audit off, the zero-GC budgets are byte-identical to 0.3.0 -- th
259
280
  | Export | Value |
260
281
  |---|---|
261
282
  | `ReactiveDisposedError` | `extends Error`; `name: "ReactiveDisposedError"`; fields `className`, `key`. Thrown on ANY touch of a disposed instance's surface. |
262
- | `VERSION` | `"1.1.0"` |
283
+ | `VERSION` | `"1.2.0"` |
263
284
 
264
285
  ### The rejection matrix
265
286
 
@@ -348,6 +369,8 @@ The fair baseline for a decorated property is a hand-written instance field (`th
348
369
 
349
370
  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.
350
371
 
372
+ A `@localTo` read measures **1.69x** a plain decorated read -- two tracked reads (upstream `source` + the local box) plus a value compare, versus the one box read of `@reactive` -- and it pays that cost with **0.000 B/op** under the read/write storms (`gc.major 0`, observed `maxPauseMs` 0.07-0.08 against the 4.0 gate); the compare-on-read is honest arithmetic, not free.
373
+
351
374
  ### `@batched` per call (`spikes/batched-cost.mjs`)
352
375
 
353
376
  | Path | ns/op |
@@ -360,7 +383,7 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
360
383
 
361
384
  ### Per instance
362
385
 
363
- `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.
386
+ `P + L + D + E + 1` pool nodes -- one per signal, per local, 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.
364
387
 
365
388
  <details>
366
389
  <summary><strong>Zero-GC design notes: the allocation table + the gates</strong></summary>
@@ -372,19 +395,23 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
372
395
  | `@derived get` read | none | lazy computed read |
373
396
  | Effect re-run | none retained | gated: zero major GC across the read/write torture lanes |
374
397
  | `@batched m()` call | 1 thunk + 1 rest array | the documented, measured exception (+7 ns vs raw batch); action-grade only |
375
- | `new Host()` | P + D + E + 1 pool nodes | plus the instance itself; nodes recycle on dispose (F-0 conservation) |
398
+ | `new Host()` | P + L + D + E + 1 pool nodes | plus the instance itself; nodes recycle on dispose (F-0 conservation) |
376
399
  | `disposeReactive(vm)` | none | allocation-free success path; poison handles are prebuilt per member at decoration time |
377
400
  | `boxOf` / `rootOf` / any throw | cold path | introspection and failure paths may allocate; never on the hot path |
378
401
 
379
- The gates that hold it (run on every change, all green at 1.1.0):
402
+ The gates that hold it (run on every change, all green at 1.2.0):
380
403
 
381
- - `npm test` / `npm run test:gc` -- **257/257** on both lanes.
404
+ - `npm test` / `npm run test:gc` -- **291/291** on both lanes.
382
405
  - Suite gate (lite-leak + lite-gc-profiler): `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | ok`.
383
- - Torture: **16 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; the `reinit-torture` acquire/release gate) -- **14 run + 2 that skip correctly below their peer floors** (`scope-adoption` needs 1.6.0, `using-dispose` needs 1.9.0; the installed peer is 1.5.0). A skip *below* a floor is the forward-compat design working; a skip *at or above* it is a FAIL (run.mjs enforces floor-escalation). Every scenario carries a `TORTURE_BREAK` sabotage control that must exit non-zero -- **16/16 controls** prove each gate can actually fail.
406
+ - Torture: **17 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; the `reinit-torture` acquire/release gate; the `localto-torture` zero-alloc read/write storm + ABA-stale interleave lattice + pooled park/reinit) -- **15 run + 2 that skip correctly below their peer floors** (`scope-adoption` needs 1.6.0, `using-dispose` needs 1.9.0; the installed peer is 1.5.0). A skip *below* a floor is the forward-compat design working; a skip *at or above* it is a FAIL (run.mjs enforces floor-escalation). Every scenario carries a `TORTURE_BREAK` sabotage control that must exit non-zero -- **17/17 controls** prove each gate can actually fail.
384
407
  - `churn-soak` + `fleet-soak`: sustained construct/use/dispose and a 10s 2k-VM fleet tick; pools at floor and retained heap flat at every sample.
385
408
 
386
409
  The cross-framework matrix lives in `bench/` (private, never shipped): six engines -- both our tiers, the hand-written `lite-raw-boxes` baseline, MobX 7, signal-utils/signal-polyfill, and a hand-rolled alien-signals class -- across eight class-shaped scenarios (including the `churn-reuse` acquire/release lane, where the lite tiers pool with zero retained growth and MobX/signal-utils/alien-class are structurally `unsupported` -- no disposable instance lifecycle to pool), checksum-verified for identical work, stamped into `bench/results.txt`. The formal verdicts are in [`decisions/0006-kill-criteria.md`](decisions/0006-kill-criteria.md): the decorated path measured **0.94x** the hand-written baseline on vm-write and **1.10x** on a 10k-instance fleet read (the 2.0x kill line cleared with margin), and **0 major + 0 minor GC over 4096 construct/use/dispose cycles** with pools at floor -- while emitting ~12.6x less transient garbage per churn run than the hand-rolled class it replaces.
387
410
 
411
+ ![CHURN benchmark: ops/s and transient heap per adapter](https://raw.githubusercontent.com/PeshoVurtoleta/lite-signal-decorators/main/bench/results-chart.svg)
412
+
413
+ The chart above is generated from the stamped `bench/results.txt` by `bench/chart.mjs` (`node bench/chart.mjs`) -- it plots the CHURN lane for every adapter at full scale, including `alien-class`, the hand-rolled reference that has no disposable instance lifecycle to pool.
414
+
388
415
  </details>
389
416
 
390
417
  ---
@@ -401,22 +428,23 @@ Full rationale lives in [`decisions/`](decisions/) -- each is a numbered, dated
401
428
  - **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.
402
429
  - **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.
403
430
  - **Pooled reinit is an identity-stable arena tool, not a speed shortcut (1.1.0).** `releaseReactive(vm)` parks a live instance to the engine pool and `reinitReactive(vm, initials?)` revives it -- a three-state lattice (live / parked / disposed) over the same instance, so consumers keep the object reference across turnover. It holds its gate: over 4096 acquire/release cycles at the churn shape (P=4, D=2, E=1) it measures **0 major GC**, retained delta-heap **at or below the in-process zero-alloc control**, and exact pool conservation (`activeNodes` back to baseline, zero pool growths, a parked instance holding 0 engine nodes). The honest throughput number, same shape, 2026-08-30 stamp (module 1.1.0): plain construct/dispose CHURN is *faster* -- **1323K ops/s** vs reuse's **1159K** -- because construction is already allocation-light and pool-conserving, so reinit is not a per-op win. Reach for it when you need identity-stable pooled instances under sustained turnover with zero retained growth (an arena/fleet primitive), not when you want raw op speed. MobX has no equivalent lifecycle at all: its instances are never disposable, so there is no release/reinit cycle to pool ([decisions/0010](decisions/0010-reinit-contract.md), [0011](decisions/0011-reinit-api.md)).
431
+ - **`@localTo` resets by compare-on-read, and its ABA limit is stated, not hidden (1.2.0).** The reset is decided *on the read* -- `source(self)` compared to a per-instance last-seen slot -- so it is glitch-free, synchronous, and pure (no box write on read, legal inside a `@derived`). The rejected alternative was an effect that watches upstream and clears the local: PD-51 measured that recipe clobbering a user's write one tick late, which is the whole reason the feature lives in-package instead of a cookbook recipe. Because the compare is value-based (lite-signal exposes no public revision counter and a private one would be impure), the honest limit is ABA: upstream `A` -> local write -> upstream `B` -> upstream back to an equals-`A` value shows the stale local -- the same property tracked-toolbox's `@localCopy` ships, documented rather than papered over ([decisions/0013](decisions/0013-strategic-admission-track.md), [0014](decisions/0014-localto-contract.md)).
404
432
 
405
433
  ---
406
434
 
407
435
  ## Testing (for clients & QA)
408
436
 
409
437
  ```bash
410
- npm test # node --test, 257 tests
411
- npm run test:gc # the same 257 with --expose-gc (enables the allocation assertions)
438
+ npm test # node --test, 291 tests
439
+ npm run test:gc # the same 291 with --expose-gc (enables the allocation assertions)
412
440
  npm run gate # the full pre-publish chain (section 10): fixtures -> test -> test:gc -> torture -> controls -> peer-preview (non-blocking) -> bench selftest -> cookbook -> pack
413
441
  ```
414
442
 
415
- **257 tests** across sixteen files, all green at 1.1.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.
443
+ **291 tests** across seventeen files, all green at 1.2.0. The decorator protocol is tested three times over: against a mock standard-decorators emitter *and* against committed real TypeScript 5 and Babel `2023-11` emits, so both toolchains' codegen is pinned, not assumed.
416
444
 
417
445
  | File | Tests | Covers |
418
446
  |---|---:|---|
419
- | `01-protocol-mock` | 30 | Decorator protocol on the mock Stage-3 emitter: wiring, values, options, rejection matrix |
447
+ | `01-protocol-mock` | 30 | Decorator protocol on the mock standard-decorators emitter: wiring, values, options, rejection matrix |
420
448
  | `02-fixtures-ts` | 19 | The same laws on real TypeScript 5 emit (committed fixtures) |
421
449
  | `03-fixtures-babel` | 19 | The same laws on real Babel `2023-11` emit |
422
450
  | `04-fixture-freshness` | 2 | Fixture hashes match the sources (stale-emit guard) + the README emit-matrix block matches its generator |
@@ -430,26 +458,27 @@ npm run gate # the full pre-publish chain (section 10): fixtures -> test
430
458
  | `12-accounting` | 11 | `costOf` node/link/shape grid (double-probe, frozen + cached, fail-closed) + `capacityFor` budget sizing |
431
459
  | `13-labels-audit` | 10 | `enableLabels`/`labelOf` per-registry identity + `auditReactive` leak reporting, both opt-in and default-OFF |
432
460
  | `14-qa-s4-boundary` | 21 | S4 adversarial edges: stats-less facade closure, signals-only capacity floor, label/audit boundary matrix |
433
- | `15-cookbook` | 14 | [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) drift/parity: each fenced block byte-compared against its tagged companion `#region` (both directions + both-way coverage), surface freeze (exactly 18 exports), citation allowlist, link law, static-cost probe |
461
+ | `15-cookbook` | 14 | [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) drift/parity: each fenced block byte-compared against its tagged companion `#region` (both directions + both-way coverage), surface freeze (exactly 19 exports), citation allowlist, link law, static-cost probe |
434
462
  | `16-reinit` | 29 | Pooled-reinit lattice on both emit lanes: park/reinit/dispose transitions, the five `reinitReactive` fail-closed states, parked-touch throws by name, `initials` boundary matrix (0..N+1 keys, null/undefined, NaN/-0 verbatim), `Symbol.dispose` on a parked instance, accessor descriptors byte-identical across reinit, self-release re-entrancy, ledger conservation |
463
+ | `17-localto` | 34 | `@localTo` on both emit lanes + buildless `locals`: the read/write/upstream-reset lattice, both initial flavors (follow-from-wiring vs reset-from-initial), the ABA stale-local contract, `equals` override survival, park/reinit box+seen reset, `costOf` = P+L+D+E+1, source-throw fail-closed, and the fail-closed option/source matrix |
435
464
 
436
465
  ### Emit-support matrix
437
466
 
438
- Three fixture sources, two Stage-3 emitters, both emit lanes -- every cell below is a committed, hash-pinned fixture (the `04-fixture-freshness` guard above). The table is generated from the fixture manifest, so a re-emit that changes a byte is loud, not silent:
467
+ Three fixture sources, two standard-decorators emitters, both emit lanes -- every cell below is a committed, hash-pinned fixture (the `04-fixture-freshness` guard above). The table is generated from the fixture manifest, so a re-emit that changes a byte is loud, not silent:
439
468
 
440
469
  <!-- EMIT-MATRIX:START -->
441
470
  Generated by `node test/fixtures/emit-matrix.mjs` from `test/fixtures/hashes.json` -- do not hand-edit. Toolchain pinned by the committed fixtures: **TypeScript 5.9.3**, **@babel/core 7.29.7** + **@babel/plugin-proposal-decorators 7.29.7** (`version: 2023-11`). Each `sha256` is the first 12 hex of the committed emit; `npm run fixtures` regenerates and `test/04-fixture-freshness` fails loudly on any drift.
442
471
 
443
472
  | Source | Emitter | Emit lane | Compiled output | sha256 | At decoration time |
444
473
  |---|---|---|---|---|---|
445
- | `fixture.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/fixture.src.js` | `1a3fc0f943bf` | accepted -- full decorator surface wired + pinned green |
446
- | `fixture.src.ts` | Babel | standard 2023-11 | `babel-out/fixture.src.js` | `eb9dfb5939b1` | accepted -- full decorator surface wired + pinned green |
474
+ | `fixture.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/fixture.src.js` | `5d6837710b34` | accepted -- full decorator surface wired + pinned green |
475
+ | `fixture.src.ts` | Babel | standard 2023-11 | `babel-out/fixture.src.js` | `f7d8b3e5ed19` | accepted -- full decorator surface wired + pinned green |
447
476
  | `static.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/static.src.js` | `d2a03e3d5f70` | rejected -- static member is a named throw at decoration time |
448
477
  | `static.src.ts` | Babel | standard 2023-11 | `babel-out/static.src.js` | `dc936c5aa235` | rejected -- static member is a named throw at decoration time |
449
478
  | `legacy.src.ts` | TypeScript 5 | legacy (experimental) | `ts-legacy-out/legacy.src.js` | `c1059b1d37b1` | rejected -- legacy emit -> named rejection at decoration time |
450
479
  | `legacy.src.ts` | Babel | legacy (experimental) | `babel-legacy-out/legacy.src.js` | `1d35a02c57ce` | rejected -- legacy emit -> named rejection at decoration time |
451
480
 
452
- Source hashes: `fixture.src.ts` `fb492a396340`, `static.src.ts` `81fb649965e6`, `legacy.src.ts` `30ac3dabaf7c`.
481
+ Source hashes: `fixture.src.ts` `339c40148a70`, `static.src.ts` `81fb649965e6`, `legacy.src.ts` `30ac3dabaf7c`.
453
482
  <!-- EMIT-MATRIX:END -->
454
483
 
455
484
  ### The torture suite (dev-side, never shipped)
@@ -457,13 +486,13 @@ Source hashes: `fixture.src.ts` `fb492a396340`, `static.src.ts` `81fb649965e6`,
457
486
  Process-isolated stress scenarios built on `@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`:
458
487
 
459
488
  ```bash
460
- npm run torture # all 16 scenarios (14 run + 2 floor-gated skips)
489
+ npm run torture # all 17 scenarios (15 run + 2 floor-gated skips)
461
490
  npm run torture:semantic # the correctness lane (CI)
462
491
  npm run torture:soak # the wall-clock churn + fleet soaks
463
492
  npm run torture:controls # sabotage self-test: every scenario must FAIL when broken
464
493
  ```
465
494
 
466
- Sixteen 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, the `reinit-torture` acquire/release gate (4096 pooled cycles: `maxMajor 0`, retained delta-heap at/below the in-process zero-alloc control, exact pool conservation), the wall-clock churn soak, and a 10s 2k-VM fleet soak -- plus two forward-compat scenarios (`scope-adoption`, `using-dispose`) that **skip correctly** while the installed peer sits below their per-feature floors (1.6.0 `createScope`, 1.9.0 `Symbol.dispose`). A skip below a floor is the design working; a skip at or above it is a FAIL. On the installed 1.5.0 peer: 14 pass, 2 skip. 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`.
495
+ Seventeen 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, the `reinit-torture` acquire/release gate (4096 pooled cycles: `maxMajor 0`, retained delta-heap at/below the in-process zero-alloc control, exact pool conservation), the `localto-torture` gate (zero-alloc `@localTo` read/write storm at `maxMajor 0`, the ABA-stale write/reset interleave asserted AS the shipped contract, pooled park/reinit box+seen reset, tracking-edge and pure-compute-read pins), the wall-clock churn soak, and a 10s 2k-VM fleet soak -- plus two forward-compat scenarios (`scope-adoption`, `using-dispose`) that **skip correctly** while the installed peer sits below their per-feature floors (1.6.0 `createScope`, 1.9.0 `Symbol.dispose`). A skip below a floor is the design working; a skip at or above it is a FAIL. On the installed 1.5.0 peer: 15 pass, 2 skip. 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`.
467
496
 
468
497
  ### The cookbook lane (dev-side, never shipped)
469
498
 
@@ -496,7 +525,7 @@ The `demo/` directory is dev-only -- it never enters `package.json` `files[]` an
496
525
 
497
526
  | Path | Requirement |
498
527
  |---|---|
499
- | `@` 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. |
528
+ | `@` decorator syntax | A standard-decorators (2023-11) 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. |
500
529
  | `defineReactive` | Nothing. Any ESM runtime. |
501
530
  | Runtime | Node >= 18 (ESM-only, `sideEffects: false`); browsers via any ESM bundler or native modules. No DOM dependency anywhere in the package. |
502
531
  | Peer | `@zakkster/lite-signal` `>=1.5.0 <2.0.0`, installed at the top level (one engine instance, one graph). |
@@ -529,7 +558,7 @@ Verified against the installed `signal-utils@0.21.1`: `@signal` (on accessors or
529
558
  | `@signal accessor x` (or `@signal get x`) | `@reactive accessor x` |
530
559
  | `@cached get y()` | `@derived get y()` |
531
560
  | no disposal API at all | `disposeReactive(vm)` -- **and it disposes**: cascade teardown, poison swap, node-exact conservation |
532
- | Stage-3 build required | `defineReactive(Class, spec)` -- the buildless door signal-utils has no equivalent for |
561
+ | Standard-decorators build required | `defineReactive(Class, spec)` -- the buildless door signal-utils has no equivalent for |
533
562
 
534
563
  The cross-framework numbers behind this table are stamped in [`decisions/0006-kill-criteria.md`](decisions/0006-kill-criteria.md) (both engines measured through their documented class APIs at checksum-identical work).
535
564
 
@@ -541,7 +570,7 @@ The cross-framework numbers behind this table are stamped in [`decisions/0006-ki
541
570
  - **Not a deep/proxy observation layer.** No `observable.deep`, no wrapped Arrays/Maps/Sets, no proxy magic -- the reactive unit is a declared member, not a traversed object graph. Collections are `@zakkster/lite-project` territory.
542
571
  - **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`).
543
572
  - **Not a framework, renderer, or component model.** It ends at the reactive view-model; DOM binding is `lite-signal-dom`'s job.
544
- - **Not a general meta-programming kit.** Five decorators, one wiring law -- not an open decorator toolbox. It does one thing: turn a class into a reactive view-model with a provable lifetime.
573
+ - **Not a general meta-programming kit.** Six decorators, one wiring law -- not an open decorator toolbox. It does one thing: turn a class into a reactive view-model with a provable lifetime.
545
574
  - **Not a MobX API shim.** No `makeObservable`, no administration objects -- and no GC-based cleanup: disposal is explicit, deterministic, and verified, because "the collector will get it eventually" is not a lifecycle.
546
575
  - **Not a legacy-decorators consumer.** TypeScript `experimentalDecorators` emit is detected by call shape at decoration time and rejected with a named error -- never "works differently under legacy".
547
576
  - **Not usable as `@` syntax without a toolchain** -- that is exactly what `defineReactive` exists for.
@@ -561,14 +590,14 @@ The cross-framework numbers behind this table are stamped in [`decisions/0006-ki
561
590
 
562
591
  ### The cookbook
563
592
 
564
- [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) collects twelve composition recipes over the frozen 16-export surface -- how to build the things this package deliberately does not ship a decorator for, by composing the ones it does. Its headline is the **MobX-parity-by-composition matrix**, mapping each remaining MobX construct (`observable.array`, `observable.map`, `observable.deep`, `toJS`, `when`, `runInAction`, `observe`/`intercept`) to a decorator, a suite member, or a recipe -- extending the migration tables above to the rest of MobX with the honest note per row. It walks the **two-plane fleet** (a sim plane of arena columns written raw per frame beside a reactive plane of a handful of committed members), the reactive-collection-without-a-node-per-element pattern, and the **lite-store boundary** where document state meets class state -- stated plainly as the one path that is *not* zero-GC, and why. Every code block is byte-verified against a runnable, GC-gated companion in `cookbook/` (`npm run cookbook`), so a quoted recipe cannot drift from working code. It is delivered GitHub-only -- the installed tarball stays the lean 7-file runtime surface (decisions/0009).
593
+ [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) collects eighteen composition recipes over the frozen 19-export surface -- how to build the things this package deliberately does not ship a decorator for, by composing the ones it does. Its headline is the **MobX-parity-by-composition matrix**, mapping each remaining MobX construct (`observable.array`, `observable.map`, `observable.deep`, `toJS`, `when`, `runInAction`, `observe`/`intercept`) to a decorator, a suite member, or a recipe -- extending the migration tables above to the rest of MobX with the honest note per row. It walks the **two-plane fleet** (a sim plane of arena columns written raw per frame beside a reactive plane of a handful of committed members), the reactive-collection-without-a-node-per-element pattern, and the **lite-store boundary** where document state meets class state -- stated plainly as the one path that is *not* zero-GC, and why. Every code block is byte-verified against a runnable, GC-gated companion in `cookbook/` (`npm run cookbook`), so a quoted recipe cannot drift from working code. It is delivered GitHub-only -- the installed tarball stays the lean 7-file runtime surface (decisions/0009).
565
594
 
566
595
  ---
567
596
 
568
597
  ## FAQ
569
598
 
570
599
  **Why the `accessor` keyword?**
571
- 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`.
600
+ It is the standard-decorators protocol 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`.
572
601
 
573
602
  **How is this different from MobX's `@observable`?**
574
603
  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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @zakkster/lite-signal-decorators -- Stage-3 decorator layer over
2
+ * @zakkster/lite-signal-decorators -- Standard-decorators layer over
3
3
  * @zakkster/lite-signal.
4
4
  *
5
5
  * Public type surface for the JavaScript implementation in
@@ -30,6 +30,16 @@ export interface DerivedOptions<V> {
30
30
  equals?: (a: V, b: V) => boolean;
31
31
  }
32
32
 
33
+ /** Options for a `@localTo` member. `equals` governs the UPSTREAM compare only. */
34
+ export interface LocalToOptions<V> {
35
+ /**
36
+ * Custom equality predicate for the UPSTREAM compare (source vs the last-seen
37
+ * value). A coarse predicate widens how long a local override survives an
38
+ * upstream move. Default: `Object.is`. The write path never compares.
39
+ */
40
+ equals?: (a: V, b: V) => boolean;
41
+ }
42
+
33
43
  /** Options for a `@reactiveEffect` method. */
34
44
  export interface ReactiveEffectOptions {
35
45
  /** Optional scheduler forwarded straight to the underlying `effect(fn, { scheduler })`. */
@@ -101,6 +111,37 @@ export function derived<V>(
101
111
  ctx: ClassGetterDecoratorContext<This, V>,
102
112
  ) => (this: This) => V;
103
113
 
114
+ // --- localTo ------------------------------------------------------------------
115
+
116
+ /**
117
+ * `@localTo(source) accessor x = v` -- upstream-keyed resettable local state.
118
+ * Each read compares the tracked `source(self)` against a per-instance last-seen
119
+ * slot: an unchanged upstream yields the local override, a changed upstream
120
+ * resets to the upstream value (no write on read -- pure). A write always
121
+ * overrides. With an initializer the member STARTS there and resets on the first
122
+ * upstream move (the `@trackedReset` flavor); without one it FOLLOWS upstream
123
+ * from wiring (the `@localCopy` flavor). `source` is REQUIRED. `equals` governs
124
+ * the upstream compare only.
125
+ *
126
+ * The ABA contract (shipped, documented): the reset requires the upstream to
127
+ * change relative to the last adoption, not to have merely moved -- upstream
128
+ * A -> local write X -> upstream B -> upstream back to an equals-A value shows
129
+ * the STALE local X.
130
+ *
131
+ * @example
132
+ * class Editor {
133
+ * `@reactive` accessor saved = "";
134
+ * `@localTo`((self) => self.saved) accessor draft = "";
135
+ * }
136
+ */
137
+ export function localTo<This, V>(
138
+ source: (this: This, self: This) => V,
139
+ options?: LocalToOptions<V>,
140
+ ): <T>(
141
+ target: ClassAccessorDecoratorTarget<T, V>,
142
+ ctx: ClassAccessorDecoratorContext<T, V>,
143
+ ) => ClassAccessorDecoratorResult<T, V>;
144
+
104
145
  // --- reactiveHost -------------------------------------------------------------
105
146
 
106
147
  /**
@@ -177,6 +218,20 @@ export interface SignalSpec<This = unknown, V = unknown> {
177
218
  equals?: (a: V, b: V) => boolean;
178
219
  }
179
220
 
221
+ /** Local descriptor for a `defineReactive` `locals` map entry (`@localTo` twin). */
222
+ export interface LocalSpec<This = unknown, V = unknown> {
223
+ /** The tracked upstream read `(self) => value`. REQUIRED. */
224
+ source: (this: This, self: This) => V;
225
+ /** Custom equality predicate for the upstream compare. Default: `Object.is`. */
226
+ equals?: (a: V, b: V) => boolean;
227
+ /**
228
+ * The initial value. Present -> the member starts here and resets to upstream
229
+ * on the first upstream move (`@trackedReset`). Absent -> the member follows
230
+ * upstream from wiring (`@localCopy`).
231
+ */
232
+ initial?: V;
233
+ }
234
+
180
235
  /** Derived descriptor for a `defineReactive` `deriveds` map entry. */
181
236
  export interface DerivedSpec<This = unknown, V = unknown> {
182
237
  /** The compute body `(self) => value`. */
@@ -204,6 +259,8 @@ export interface DefineReactiveSpec<This = any> {
204
259
  * throw (ambiguous -- use `{ initial: fn }` or `{ init: (self) => value }`).
205
260
  */
206
261
  signals?: PropertyKey[] | Record<PropertyKey, unknown | SignalSpec<This>>;
262
+ /** Upstream-keyed locals (`@localTo` twin): a map of `key -> LocalSpec`. */
263
+ locals?: Record<PropertyKey, LocalSpec<This>>;
207
264
  /** Lazy deriveds: a map of `key -> (self) => value | DerivedSpec`. */
208
265
  deriveds?: Record<PropertyKey, ((this: This, self: This) => unknown) | DerivedSpec<This>>;
209
266
  /** Auto-effects: a map of `key -> (self) => void | EffectSpec`. */
@@ -289,12 +346,14 @@ export function rootOf(vm: object): NodeDescriptor;
289
346
 
290
347
  /** The measured per-instance cost of a reactive class, returned by {@link costOf}. */
291
348
  export interface ReactiveCost {
292
- /** Total nodes = `signals + deriveds + effects + 1` (the anchor). */
349
+ /** Total nodes = `signals + locals + deriveds + effects + 1` (the anchor). */
293
350
  nodes: number;
294
351
  /** Dependency links held after every `@derived` has been read once (0007). */
295
352
  links: number;
296
353
  /** Count of `@reactive` members. */
297
354
  signals: number;
355
+ /** Count of `@localTo` members (each one box node; its seen slot is a plain field). */
356
+ locals: number;
298
357
  /** Count of `@derived` members. */
299
358
  deriveds: number;
300
359
  /** Count of `@reactiveEffect` members. */
@@ -1,7 +1,7 @@
1
1
  /**
2
- * @zakkster/lite-signal-decorators v1.1.0
2
+ * @zakkster/lite-signal-decorators v1.2.0
3
3
  * --------------------
4
- * Stage-3 decorator layer over @zakkster/lite-signal. Turns a plain class into
4
+ * Standard-decorators layer over @zakkster/lite-signal. Turns a plain class into
5
5
  * a reactive view-model with measured per-instance cost and deterministic
6
6
  * teardown:
7
7
  * - `@reactive accessor x = v` -- a per-instance signal box, stored in a
@@ -96,6 +96,13 @@ const CLOSURES = Symbol("lite-signal-decorators.closures");
96
96
  // wins. Keyed by the frozen signal rec (bounded by the class member count).
97
97
  const SIG_INITIAL = new WeakMap();
98
98
 
99
+ // S8/PD-58: decorator-local field-initial for reinit reset. A @localTo member's
100
+ // field-initializer value is captured (per member, first-seen) in makeLocalInit --
101
+ // undefined means "no initializer" (the @localCopy flavor: reinit reseeds the box
102
+ // from the current upstream). Buildless locals carry hasInitial + initFn on the
103
+ // rec instead. Keyed by the frozen local rec (bounded by the class member count).
104
+ const LOCAL_INITIAL = new WeakMap();
105
+
99
106
  // Scratch-frame stack (D-2h): decorator signal boxes are created in accessor
100
107
  // `init` during super()'s field initialization -- BEFORE wireInstance's
101
108
  // try/catch exists. Each init pushes its box here; the wrapper constructor
@@ -139,6 +146,7 @@ let AUDIT_FR = null;
139
146
 
140
147
  // Known option keys per decorator (unknown-key did-you-mean sets, PD-8/PD-11).
141
148
  const KNOWN_OPTION_KEYS = ["equals"];
149
+ const LOCAL_OPTION_KEYS = ["equals"];
142
150
  const EFFECT_OPTION_KEYS = ["scheduler"];
143
151
  const HOST_OPTION_KEYS = ["registry"];
144
152
  const CAP_OPTION_KEYS = ["headroom"];
@@ -285,6 +293,12 @@ function throwBadScheduler(what) {
285
293
  );
286
294
  }
287
295
 
296
+ function throwLocalSource() {
297
+ throw new TypeError(
298
+ `${ERR}localTo requires a source function: write \`@localTo((self) => self.upstream) accessor x = ...\`. \`source\` is a tracked (self) -> value read, not an option.`,
299
+ );
300
+ }
301
+
288
302
  function throwUnknownOption(what, key, known) {
289
303
  const near = nearestKey(String(key), known);
290
304
  throw new TypeError(
@@ -423,9 +437,10 @@ function throwReinitInitials(ctorName) {
423
437
  function throwReinitInitialsKey(ctorName, key, plan) {
424
438
  const avail = [];
425
439
  for (let i = 0; i < plan.signals.length; i++) avail.push(keyLabel(plan.signals[i].key));
440
+ for (let i = 0; i < plan.locals.length; i++) avail.push(keyLabel(plan.locals[i].key));
426
441
  const near = nearestKey(keyLabel(key), avail);
427
442
  throw new Error(
428
- `${ERR}reinitReactive(${ctorName}) initials carries key \`${keyLabel(key)}\` that is not a @reactive signal${near ? ` -- did you mean \`${near}\`?` : ""} Signals: ${avail.join(", ")}.`,
443
+ `${ERR}reinitReactive(${ctorName}) initials carries key \`${keyLabel(key)}\` that is not a @reactive signal or @localTo member${near ? ` -- did you mean \`${near}\`?` : ""} Resettable keys: ${avail.join(", ")}.`,
429
444
  );
430
445
  }
431
446
 
@@ -451,9 +466,9 @@ function throwDefineSpec() {
451
466
  }
452
467
 
453
468
  function throwUnknownSection(key) {
454
- const near = nearestKey(keyLabel(key), ["signals", "deriveds", "effects", "host"]);
469
+ const near = nearestKey(keyLabel(key), ["signals", "locals", "deriveds", "effects", "host"]);
455
470
  throw new TypeError(
456
- `${ERR}defineReactive spec got unknown section \`${keyLabel(key)}\`${near ? ` -- did you mean \`${near}\`?` : ""} Known sections: signals, deriveds, effects, host.`,
471
+ `${ERR}defineReactive spec got unknown section \`${keyLabel(key)}\`${near ? ` -- did you mean \`${near}\`?` : ""} Known sections: signals, locals, deriveds, effects, host.`,
457
472
  );
458
473
  }
459
474
 
@@ -538,6 +553,25 @@ function throwBadEffect(key) {
538
553
  );
539
554
  }
540
555
 
556
+ function throwSpecLocals() {
557
+ throw new TypeError(
558
+ `${ERR}defineReactive spec.locals must be a map of key -> { source, equals?, initial? }.`,
559
+ );
560
+ }
561
+
562
+ function throwUnknownLocalDescKey(key, dk) {
563
+ const near = nearestKey(keyLabel(dk), ["source", "equals", "initial"]);
564
+ throw new TypeError(
565
+ `${ERR}defineReactive local ${keyLabel(key)} got unknown descriptor key \`${keyLabel(dk)}\`${near ? ` -- did you mean \`${near}\`?` : ""} Known keys: source, equals, initial.`,
566
+ );
567
+ }
568
+
569
+ function throwLocalSourceNotFn(key) {
570
+ throw new TypeError(
571
+ `${ERR}defineReactive local ${keyLabel(key)} \`source\` must be a function (self) -> value.`,
572
+ );
573
+ }
574
+
541
575
  function throwSpecCollision(className, key) {
542
576
  throw new TypeError(
543
577
  `${ERR}defineReactive spec declares ${keyLabel(key)}, but ${className}.prototype already owns that member -- a spec-declared member cannot collide with a hand-written one.`,
@@ -550,6 +584,49 @@ function makeGet(slot) { return function () { return this[slot].get(); }; }
550
584
  function makeSet(slot) { return function (v) { this[slot].set(v); }; }
551
585
  function makeDerivedGet(slot) { return function () { return this[slot].get(); }; }
552
586
 
587
+ // S8 hot bodies (0014): the @localTo accessor pair. NEW bodies -- the 1.0.0 canon
588
+ // above stays byte-identical (S8-A5); @localTo pays its own measured cost. Both
589
+ // are prebuilt ONCE per member and close over the member's slots + source + equals,
590
+ // so a read/write allocates nothing (the same prebuilt-closure discipline as
591
+ // makeGet/makeSet). Two per-instance stores back a local: the box slot (a signal
592
+ // node, the local value) and the seen slot (a PLAIN field, the last-adopted
593
+ // upstream value). makeLocalGet is PURE (0014 read law): it calls the tracked
594
+ // source, equals-compares it against the seen slot, and returns the local box on
595
+ // an UNCHANGED upstream, else the upstream value -- never writing any box on the
596
+ // read path, so a localTo read is legal inside any @derived compute.
597
+ function makeLocalGet(rec) {
598
+ const source = rec.source;
599
+ const eq = rec.eq;
600
+ const boxSlot = rec.slot;
601
+ const seenSlot = rec.seenSlot;
602
+ return function () {
603
+ const up = source.call(this, this);
604
+ if (eq(up, this[seenSlot])) return this[boxSlot].get();
605
+ return up;
606
+ };
607
+ }
608
+ // makeLocalSet: box.set (a write always overrides -- PD-56 never compares) + seen
609
+ // slot = upstream-at-write. The seen capture must NOT subscribe: a write inside an
610
+ // effect body runs under that effect's tracking scope, so a tracked source read
611
+ // there would silently link the effect to the upstream (a fail-open dep leak,
612
+ // measured: reg.isTracking() -> a bare source read re-fires the effect on an
613
+ // upstream move). The isTracking() gate untracks ONLY under an active scope --
614
+ // the same idiom makeEffectPublic uses (D-4b) -- so the plain-code write path
615
+ // (the hot path) stays a zero-alloc source.call, and only the rare in-effect write
616
+ // pays the untrack thunk. reg comes from the frozen rec.plan (set at claimPlan).
617
+ function makeLocalSet(rec) {
618
+ const source = rec.source;
619
+ const boxSlot = rec.slot;
620
+ const seenSlot = rec.seenSlot;
621
+ return function (v) {
622
+ this[boxSlot].set(v);
623
+ const reg = rec.plan.reg;
624
+ this[seenSlot] = reg.isTracking()
625
+ ? reg.untrack(() => source.call(this, this))
626
+ : source.call(this, this);
627
+ };
628
+ }
629
+
553
630
  function makeInit(rec) {
554
631
  return function (v) {
555
632
  if (rec.plan === null) throwMissingHost(rec);
@@ -563,6 +640,69 @@ function makeInit(rec) {
563
640
  };
564
641
  }
565
642
 
643
+ // S8 (cold): seed one @localTo member's two stores on an instance. seen = the
644
+ // source read at wiring, UNTRACKED (wiring/reinit must register no dependency);
645
+ // the box starts at the declared initial when present, else at that same upstream
646
+ // value (the initial-value unification rule, 0014: an initializer -> @trackedReset
647
+ // flavor; no initializer -> @localCopy flavor). Returns the box for SCRATCH
648
+ // rollback. Shared by the decorator init, the buildless wire loop, and reinit.
649
+ function seedLocal(inst, rec, reg, hasInitial, initialValue) {
650
+ const source = rec.source;
651
+ const seen = reg.isTracking()
652
+ ? reg.untrack(() => source.call(inst, inst))
653
+ : source.call(inst, inst);
654
+ // The box is created WITHOUT the equals opts: {equals} governs the UPSTREAM
655
+ // compare only (PD-56); the box uses default equals so a local write always
656
+ // propagates (a write overrides, never suppresses).
657
+ const box = reg.signalBox(hasInitial ? initialValue : seen);
658
+ inst[rec.slot] = box;
659
+ inst[rec.seenSlot] = seen;
660
+ return box;
661
+ }
662
+
663
+ // S8 (cold): the frozen local rec, shared by the decorator (@localTo) and buildless
664
+ // (spec.locals) paths. eq defaults to Object.is (0014 read law). initFn is null for
665
+ // the decorator path (the box is born at field-init time, like a decorator signal)
666
+ // and a per-instance factory for the buildless path (born in wireInstance).
667
+ function makeLocalRec(key, slot, seenSlot, source, opts, initFn, hasInitial) {
668
+ const eq = opts !== undefined && opts.equals !== undefined ? opts.equals : Object.is;
669
+ const rec = {
670
+ kind: "local",
671
+ key,
672
+ slot,
673
+ seenSlot,
674
+ source,
675
+ eq,
676
+ opts,
677
+ get: null,
678
+ set: null,
679
+ fn: null,
680
+ plan: null,
681
+ poison: null,
682
+ prewired: null,
683
+ parked: null,
684
+ initFn,
685
+ hasInitial,
686
+ };
687
+ rec.get = makeLocalGet(rec);
688
+ rec.set = makeLocalSet(rec);
689
+ return rec;
690
+ }
691
+
692
+ // S8 (cold): the decorator @localTo init -- mirrors makeInit. The box + seen are
693
+ // born during super()'s field initialization; the box joins the SCRATCH frame for
694
+ // init-phase rollback. The field-initial value is captured per member (undefined
695
+ // means "no initializer" -> the @localCopy reset flavor) for reinit.
696
+ function makeLocalInit(rec) {
697
+ return function (v) {
698
+ if (rec.plan === null) throwMissingHost(rec);
699
+ const box = seedLocal(this, rec, rec.plan.reg, v !== undefined, v);
700
+ SCRATCH.push(box); // D-2h: track for init-phase rollback
701
+ if (!LOCAL_INITIAL.has(rec)) LOCAL_INITIAL.set(rec, v);
702
+ return v; // emitter backing store, unused
703
+ };
704
+ }
705
+
566
706
  // --- Option validation (cold) -------------------------------------------------
567
707
 
568
708
  function isStandardContext(c) {
@@ -583,6 +723,21 @@ function validateOptions(what, opts) {
583
723
  return Object.freeze({ equals: opts.equals });
584
724
  }
585
725
 
726
+ function validateLocalOptions(opts) {
727
+ // localTo's OWN key set (equals only) -- NOT the shared reactive/derived
728
+ // validator: admitting `source` there would silently accept @derived({source})
729
+ // (fail-open, PLAN-S8 spelling call). Returns a frozen { equals } or undefined.
730
+ if (opts === undefined || opts === null) return undefined;
731
+ if (typeof opts !== "object") throwUsage("localTo");
732
+ const keys = Object.keys(opts);
733
+ for (let i = 0; i < keys.length; i++) {
734
+ if (keys[i] !== "equals") throwUnknownOption("localTo", keys[i], LOCAL_OPTION_KEYS);
735
+ }
736
+ if ("equals" in opts && opts.equals !== undefined && typeof opts.equals !== "function") throwBadEquals("localTo");
737
+ if (opts.equals === undefined) return undefined;
738
+ return Object.freeze({ equals: opts.equals });
739
+ }
740
+
586
741
  function validateEffectOptions(opts) {
587
742
  // Returns a frozen { scheduler } copy, or undefined for the bare form.
588
743
  if (opts === undefined || opts === null) return undefined;
@@ -668,6 +823,36 @@ export function reactive(target, ctx) {
668
823
  return function (t, c) { return applyReactive(t, c, opts); };
669
824
  }
670
825
 
826
+ // --- localTo (S8, 0014) -------------------------------------------------------
827
+
828
+ function applyLocalTo(target, ctx, source, opts) {
829
+ if (!isStandardContext(ctx)) throwLegacyEmit("localTo");
830
+ if (ctx.kind !== "accessor") {
831
+ throwWrongKind("localTo", "accessor", ctx.kind, "write `@localTo(source) accessor x = ...`.");
832
+ }
833
+ if (ctx.static === true) throwStatic("localTo", ctx.name);
834
+ if (ctx.private === true) throwPrivate("localTo", ctx.name);
835
+ const nm = typeof ctx.name === "symbol" ? "local" : "local:" + String(ctx.name);
836
+ const rec = makeLocalRec(ctx.name, Symbol(nm), Symbol(nm + ":seen"), source, opts, null, false);
837
+ PENDING.push(rec);
838
+ return { get: rec.get, set: rec.set, init: makeLocalInit(rec) };
839
+ }
840
+
841
+ /**
842
+ * `@localTo(source) accessor x = v` -- upstream-keyed resettable local state
843
+ * (0014). Reads compare the tracked `source(self)` against a per-instance
844
+ * last-seen slot: an unchanged upstream yields the local override, a changed
845
+ * upstream resets to it. A write always overrides. With an initializer the member
846
+ * STARTS there and resets on the first upstream move; without one it FOLLOWS
847
+ * upstream from wiring. `@localTo(source)` or `@localTo(source, { equals })` --
848
+ * `equals` governs the upstream compare ONLY. `source` is REQUIRED.
849
+ */
850
+ export function localTo(source, options) {
851
+ if (typeof source !== "function") throwLocalSource();
852
+ const opts = validateLocalOptions(options);
853
+ return function (t, c) { return applyLocalTo(t, c, source, opts); };
854
+ }
855
+
671
856
  // --- derived ------------------------------------------------------------------
672
857
 
673
858
  function applyDerived(value, ctx, opts) {
@@ -834,7 +1019,7 @@ function buildHandles(rec, ctorName) {
834
1019
  get() { throw new ReactiveDisposedError(ctorName, key); },
835
1020
  set(v) { throw new ReactiveDisposedError(ctorName, key); },
836
1021
  });
837
- const msg = rec.kind === "signal"
1022
+ const msg = rec.kind === "signal" || rec.kind === "local"
838
1023
  ? `${ERR}${ctorName}.${keyLabel(key)} read/write before its initializer ran (declaration order).`
839
1024
  : `${ERR}${ctorName}.${keyLabel(key)} read before construction completed (deriveds are available after wiring).`;
840
1025
  rec.prewired = Object.freeze({
@@ -882,6 +1067,7 @@ function claimPlan(C, ctorName, registry) {
882
1067
  }
883
1068
 
884
1069
  const signals = [];
1070
+ const locals = [];
885
1071
  const deriveds = [];
886
1072
  const effects = [];
887
1073
  const byKey = new Map();
@@ -891,6 +1077,12 @@ function claimPlan(C, ctorName, registry) {
891
1077
  signals.push(r);
892
1078
  byKey.set(r.key, r);
893
1079
  }
1080
+ // PD-55: locals live in their OWN array; L is a first-class accounting term.
1081
+ for (let i = 0; i < ancestor.locals.length; i++) {
1082
+ const r = ancestor.locals[i];
1083
+ locals.push(r);
1084
+ byKey.set(r.key, r);
1085
+ }
894
1086
  for (let i = 0; i < ancestor.deriveds.length; i++) {
895
1087
  const r = ancestor.deriveds[i];
896
1088
  deriveds.push(r);
@@ -920,7 +1112,7 @@ function claimPlan(C, ctorName, registry) {
920
1112
  const rec = own[i];
921
1113
  const desc = Object.getOwnPropertyDescriptor(proto, rec.key);
922
1114
  let installed;
923
- if (rec.kind === "signal" || rec.kind === "derived") {
1115
+ if (rec.kind === "signal" || rec.kind === "derived" || rec.kind === "local") {
924
1116
  installed = desc !== undefined && desc.get === rec.get;
925
1117
  } else {
926
1118
  installed = desc !== undefined && desc.value === rec.pub;
@@ -931,8 +1123,9 @@ function claimPlan(C, ctorName, registry) {
931
1123
 
932
1124
  for (let i = 0; i < own.length; i++) {
933
1125
  const rec = own[i];
934
- if (rec.kind === "signal" || rec.kind === "derived") buildHandles(rec, ctorName);
1126
+ if (rec.kind === "signal" || rec.kind === "derived" || rec.kind === "local") buildHandles(rec, ctorName);
935
1127
  if (rec.kind === "signal") signals.push(rec);
1128
+ else if (rec.kind === "local") locals.push(rec);
936
1129
  else if (rec.kind === "derived") deriveds.push(rec);
937
1130
  else if (rec.kind === "effect") effects.push(rec);
938
1131
  // batched recs join byKey only (no node) for reg resolution + diagnostics.
@@ -943,6 +1136,7 @@ function claimPlan(C, ctorName, registry) {
943
1136
  ctorName,
944
1137
  reg,
945
1138
  signals: Object.freeze(signals),
1139
+ locals: Object.freeze(locals),
946
1140
  deriveds: Object.freeze(deriveds),
947
1141
  effects: Object.freeze(effects),
948
1142
  byKey,
@@ -1041,6 +1235,13 @@ function wireInstance(inst, plan) {
1041
1235
  const r = sigs[i];
1042
1236
  if (r.initFn !== null) inst[r.slot] = reg.signalBox(r.initFn(inst), r.opts);
1043
1237
  }
1238
+ // Buildless locals: seed box + seen in spec order BEFORE the anchor.
1239
+ // Decorator locals already exist from field-init time (initFn === null).
1240
+ const locs = plan.locals;
1241
+ for (let i = 0; i < locs.length; i++) {
1242
+ const r = locs[i];
1243
+ if (r.initFn !== null) seedLocal(inst, r, reg, r.hasInitial, r.initFn(inst));
1244
+ }
1044
1245
  let a;
1045
1246
  reg.createRoot(() => { reg.effect(() => { a = reg.getOwner(); }); }); // R-A anchor
1046
1247
  inst[ANCHOR] = a;
@@ -1090,6 +1291,16 @@ function disposeCore(inst, plan) { // assumes not already disposed
1090
1291
  if (box !== undefined && box[NONLIVE] === undefined) reg.dispose(box);
1091
1292
  inst[r.slot] = r.poison;
1092
1293
  }
1294
+ // S8: locals dispose exactly like signals -- dispose the box, poison the box
1295
+ // slot (a touch throws the named ReactiveDisposedError, 0014 dispose lattice).
1296
+ // The seen slot is a plain field; it is left as-is (the poisoned box guards it).
1297
+ const locs = plan.locals;
1298
+ for (let i = 0; i < locs.length; i++) {
1299
+ const r = locs[i];
1300
+ const box = inst[r.slot];
1301
+ if (box !== undefined && box[NONLIVE] === undefined) reg.dispose(box);
1302
+ inst[r.slot] = r.poison;
1303
+ }
1093
1304
  const ders = plan.deriveds;
1094
1305
  for (let i = 0; i < ders.length; i++) {
1095
1306
  const r = ders[i];
@@ -1120,6 +1331,17 @@ function applyReactiveHost(C, ctx, registry) {
1120
1331
  enumerable: false,
1121
1332
  });
1122
1333
  }
1334
+ // S8: a local's box slot gets the same prewired guard (the seen slot is a
1335
+ // plain field, undefined until init -- no proto guard needed).
1336
+ for (let i = 0; i < plan.locals.length; i++) {
1337
+ const r = plan.locals[i];
1338
+ Object.defineProperty(C.prototype, r.slot, {
1339
+ value: r.prewired,
1340
+ writable: true,
1341
+ configurable: true,
1342
+ enumerable: false,
1343
+ });
1344
+ }
1123
1345
  for (let i = 0; i < plan.deriveds.length; i++) {
1124
1346
  const r = plan.deriveds[i];
1125
1347
  Object.defineProperty(C.prototype, r.slot, {
@@ -1261,6 +1483,38 @@ function normalizeSignals(spec, recs) {
1261
1483
  }
1262
1484
  }
1263
1485
 
1486
+ // S8/PD-57: the buildless `locals` section -- { key: { source, equals?, initial? } }.
1487
+ // Fail closed on a missing/non-fn source (a local without a source is meaningless).
1488
+ // An `initial` present selects the @trackedReset flavor; absent, the @localCopy
1489
+ // flavor (initFn returns undefined, hasInitial false -> seedLocal reads upstream).
1490
+ function normalizeLocalEntry(key, entry) {
1491
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) throwLocalSourceNotFn(key);
1492
+ const dkeys = Reflect.ownKeys(entry);
1493
+ for (let i = 0; i < dkeys.length; i++) {
1494
+ const dk = dkeys[i];
1495
+ if (dk !== "source" && dk !== "equals" && dk !== "initial") throwUnknownLocalDescKey(key, dk);
1496
+ }
1497
+ const source = entry.source;
1498
+ if (typeof source !== "function") throwLocalSourceNotFn(key);
1499
+ const opts = normalizeEquals(entry, `defineReactive local ${keyLabel(key)}`);
1500
+ const hasInitial = Object.prototype.hasOwnProperty.call(entry, "initial");
1501
+ const initial = hasInitial ? entry.initial : undefined;
1502
+ const initFn = function () { return initial; };
1503
+ const slot = Symbol(typeof key === "symbol" ? "local" : "local:" + String(key));
1504
+ const seenSlot = Symbol(typeof key === "symbol" ? "local:seen" : "local:" + String(key) + ":seen");
1505
+ return makeLocalRec(key, slot, seenSlot, source, opts, initFn, hasInitial);
1506
+ }
1507
+
1508
+ function normalizeLocals(spec, recs) {
1509
+ const l = spec.locals;
1510
+ if (l === undefined) return;
1511
+ if (l === null || typeof l !== "object" || Array.isArray(l)) throwSpecLocals();
1512
+ const keys = Reflect.ownKeys(l);
1513
+ for (let i = 0; i < keys.length; i++) {
1514
+ recs.push(normalizeLocalEntry(keys[i], l[keys[i]]));
1515
+ }
1516
+ }
1517
+
1264
1518
  function makeDerivedRec(key, fn, opts) {
1265
1519
  const slot = Symbol(typeof key === "symbol" ? "derived" : "derived:" + String(key));
1266
1520
  return {
@@ -1354,6 +1608,13 @@ function installRec(rec, proto) {
1354
1608
  enumerable: true,
1355
1609
  configurable: true,
1356
1610
  });
1611
+ } else if (rec.kind === "local") {
1612
+ Object.defineProperty(proto, rec.key, {
1613
+ get: rec.get,
1614
+ set: rec.set,
1615
+ enumerable: true,
1616
+ configurable: true,
1617
+ });
1357
1618
  } else if (rec.kind === "derived") {
1358
1619
  Object.defineProperty(proto, rec.key, {
1359
1620
  get: rec.get,
@@ -1382,13 +1643,14 @@ export function defineReactive(Class, spec) {
1382
1643
  const sections = Reflect.ownKeys(spec);
1383
1644
  for (let i = 0; i < sections.length; i++) {
1384
1645
  const k = sections[i];
1385
- if (k !== "signals" && k !== "deriveds" && k !== "effects" && k !== "host") {
1646
+ if (k !== "signals" && k !== "locals" && k !== "deriveds" && k !== "effects" && k !== "host") {
1386
1647
  throwUnknownSection(k);
1387
1648
  }
1388
1649
  }
1389
1650
 
1390
1651
  const recs = [];
1391
1652
  normalizeSignals(spec, recs); // signals first (spec/wire order)
1653
+ normalizeLocals(spec, recs); // locals second (PD-57)
1392
1654
  normalizeDeriveds(spec, recs);
1393
1655
  normalizeEffects(spec, recs);
1394
1656
  const registry = validateHostOptions(spec.host); // shared PD-11 validation
@@ -1487,6 +1749,16 @@ function releaseCore(inst, plan) { // assumes a LIVE instance
1487
1749
  if (box !== undefined && box[NONLIVE] === undefined) reg.dispose(box);
1488
1750
  inst[r.slot] = r.parked;
1489
1751
  }
1752
+ // S8: a parked local releases its box node (swap the box slot to the parked
1753
+ // handle) but RETAINS the seen slot as a plain record field (0014 park lattice);
1754
+ // reinit re-creates the box and resets both (PD-58).
1755
+ const locs = plan.locals;
1756
+ for (let i = 0; i < locs.length; i++) {
1757
+ const r = locs[i];
1758
+ const box = inst[r.slot];
1759
+ if (box !== undefined && box[NONLIVE] === undefined) reg.dispose(box);
1760
+ inst[r.slot] = r.parked;
1761
+ }
1490
1762
  const ders = plan.deriveds;
1491
1763
  for (let i = 0; i < ders.length; i++) {
1492
1764
  inst[ders[i].slot] = ders[i].parked; // cboxes already cascaded
@@ -1562,7 +1834,10 @@ export function reinitReactive(vm, initials) {
1562
1834
  const ikeys = Reflect.ownKeys(initials);
1563
1835
  for (let i = 0; i < ikeys.length; i++) {
1564
1836
  const rec = plan.byKey.get(ikeys[i]);
1565
- if (rec === undefined || rec.kind !== "signal") throwReinitInitialsKey(plan.ctorName, ikeys[i], plan);
1837
+ // PD-58: initials[] accepts @reactive signal keys AND @localTo keys.
1838
+ if (rec === undefined || (rec.kind !== "signal" && rec.kind !== "local")) {
1839
+ throwReinitInitialsKey(plan.ctorName, ikeys[i], plan);
1840
+ }
1566
1841
  }
1567
1842
  }
1568
1843
  const reg = plan.reg;
@@ -1585,6 +1860,30 @@ export function reinitReactive(vm, initials) {
1585
1860
  }
1586
1861
  vm[r.slot] = reg.signalBox(v, r.opts);
1587
1862
  }
1863
+ // PD-58: each local resets its box -> initial AND its seen slot -> the
1864
+ // CURRENT upstream (seedLocal reads source untracked). Precedence mirrors
1865
+ // the signal loop: caller override, then the buildless plan initFn (with its
1866
+ // hasInitial flag), then the decorator field-initial captured in
1867
+ // makeLocalInit (undefined -> the @localCopy flavor: reseed the box from
1868
+ // the current upstream). Locals rebuild BEFORE buildGraph so deriveds/effects
1869
+ // see them on the first synchronous run (D-4a), same as construction.
1870
+ const locs = plan.locals;
1871
+ for (let i = 0; i < locs.length; i++) {
1872
+ const r = locs[i];
1873
+ let hasInitial;
1874
+ let v;
1875
+ if (initials !== undefined && Object.prototype.hasOwnProperty.call(initials, r.key)) {
1876
+ hasInitial = true;
1877
+ v = initials[r.key];
1878
+ } else if (r.initFn !== null) {
1879
+ hasInitial = r.hasInitial;
1880
+ v = r.initFn(vm);
1881
+ } else {
1882
+ v = LOCAL_INITIAL.get(r);
1883
+ hasInitial = v !== undefined;
1884
+ }
1885
+ seedLocal(vm, r, reg, hasInitial, v);
1886
+ }
1588
1887
  buildGraph(vm, plan, closures);
1589
1888
  } catch (e) {
1590
1889
  disposeCore(vm, plan); // failed revival is terminal -> DISPOSED
@@ -1656,7 +1955,7 @@ function throwCostInconclusive(name, a, b) {
1656
1955
 
1657
1956
  function throwCostNodeMismatch(name, got, want) {
1658
1957
  throw new Error(
1659
- `${ERR}costOf(${name}) -- probed node count ${got} != P+D+E+1 (${want}); the bound registry was not quiet during the probe.`,
1958
+ `${ERR}costOf(${name}) -- probed node count ${got} != P+L+D+E+1 (${want}); the bound registry was not quiet during the probe.`,
1660
1959
  );
1661
1960
  }
1662
1961
 
@@ -1688,9 +1987,9 @@ function probeCost(Factory, plan, reg) {
1688
1987
  * Measure the settled per-instance cost of a reactive class on its bound
1689
1988
  * registry: construct, read every `@derived` once (forcing the lazy links),
1690
1989
  * snapshot, dispose, verify the floor -- twice, requiring identical deltas.
1691
- * Returns a frozen `{ nodes, links, signals, deriveds, effects }`; `nodes`
1692
- * equals P+D+E+1. Cached per class. Throws (never guesses) on an inconclusive
1693
- * or polluted probe. Constructs the probe instance with no arguments.
1990
+ * Returns a frozen `{ nodes, links, signals, locals, deriveds, effects }`;
1991
+ * `nodes` equals P+L+D+E+1. Cached per class. Throws (never guesses) on an
1992
+ * inconclusive or polluted probe. Constructs the probe instance with no arguments.
1694
1993
  */
1695
1994
  export function costOf(Factory) {
1696
1995
  if (typeof Factory !== "function") throwCostFactory();
@@ -1709,14 +2008,18 @@ export function costOf(Factory) {
1709
2008
  throwCostInconclusive(plan.ctorName, first, second);
1710
2009
  }
1711
2010
  const sig = plan.signals.length;
2011
+ const loc = plan.locals.length;
1712
2012
  const der = plan.deriveds.length;
1713
2013
  const eff = plan.effects.length;
1714
- const expected = sig + der + eff + 1;
2014
+ // S8: each @localTo member is exactly 1 box node (its seen slot is a plain
2015
+ // field, 0 nodes), so the node formula is P + L + D + E + 1 (0014 cost law).
2016
+ const expected = sig + loc + der + eff + 1;
1715
2017
  if (first.nodes !== expected) throwCostNodeMismatch(plan.ctorName, first.nodes, expected);
1716
2018
  const result = Object.freeze({
1717
2019
  nodes: first.nodes,
1718
2020
  links: first.links,
1719
2021
  signals: sig,
2022
+ locals: loc,
1720
2023
  deriveds: der,
1721
2024
  effects: eff,
1722
2025
  });
@@ -1820,11 +2123,13 @@ function labelStringsFor(plan) {
1820
2123
  const name = plan.ctorName;
1821
2124
  const sig = [];
1822
2125
  for (let i = 0; i < plan.signals.length; i++) sig.push(`${name}.${keyLabel(plan.signals[i].key)}`);
2126
+ const loc = [];
2127
+ for (let i = 0; i < plan.locals.length; i++) loc.push(`${name}.${keyLabel(plan.locals[i].key)}`);
1823
2128
  const der = [];
1824
2129
  for (let i = 0; i < plan.deriveds.length; i++) der.push(`${name}.${keyLabel(plan.deriveds[i].key)}`);
1825
2130
  const eff = [];
1826
2131
  for (let i = 0; i < plan.effects.length; i++) eff.push(`${name}#${keyLabel(plan.effects[i].key)}`);
1827
- s = { anchor: `${name}@anchor`, signals: sig, deriveds: der, effects: eff };
2132
+ s = { anchor: `${name}@anchor`, signals: sig, locals: loc, deriveds: der, effects: eff };
1828
2133
  LABEL_STRINGS.set(plan, s);
1829
2134
  return s;
1830
2135
  }
@@ -1844,6 +2149,11 @@ function registerLabels(inst, plan, reg, effHandles) {
1844
2149
  const id = reg.nodeId(inst[sigs[i].slot]);
1845
2150
  if (id !== undefined) { map.set(id, strings.signals[i]); ids.push(id); }
1846
2151
  }
2152
+ const locs = plan.locals;
2153
+ for (let i = 0; i < locs.length; i++) {
2154
+ const id = reg.nodeId(inst[locs[i].slot]);
2155
+ if (id !== undefined) { map.set(id, strings.locals[i]); ids.push(id); }
2156
+ }
1847
2157
  const ders = plan.deriveds;
1848
2158
  for (let i = 0; i < ders.length; i++) {
1849
2159
  const id = reg.nodeId(inst[ders[i].slot]);
@@ -1944,4 +2254,4 @@ export function auditReactive(on) {
1944
2254
  // --- Version ------------------------------------------------------------------
1945
2255
 
1946
2256
  /** Package version. Kept in lockstep with package.json and llms.txt. */
1947
- export const VERSION = "1.1.0";
2257
+ export const VERSION = "1.2.0";
package/llms.txt CHANGED
@@ -1,8 +1,10 @@
1
1
  # @zakkster/lite-signal-decorators
2
2
 
3
- VERSION 1.1.0
3
+ VERSION 1.2.0
4
4
 
5
- > Stage-3 decorator layer over @zakkster/lite-signal. Turns a plain class into a
5
+ > Standard-decorators layer over @zakkster/lite-signal, built on the TC39
6
+ > decorators proposal (Stage 2.7 since 2026-05; TS 5.x / Babel 2023-11 emit
7
+ > unchanged). Turns a plain class into a
6
8
  > reactive view-model where each instance has a measured per-property cost, a
7
9
  > single deterministic teardown, and poison-on-dispose safety. ESM-only, zero
8
10
  > runtime dependencies beyond the peer. The accessor read/write bodies carry one
@@ -20,12 +22,34 @@ zero decorator syntax, sharing the SAME core by function identity.
20
22
  slot for a poison handle, so any later read/write throws a named
21
23
  `ReactiveDisposedError`.
22
24
 
23
- ## Exports (18)
25
+ ## Exports (19)
24
26
 
25
27
  - `reactive` -- `@reactive accessor x = v` (bare) or `@reactive({ equals })`
26
28
  (factory). Declares a per-instance signal.
27
29
  - `derived` -- `@derived get y()` (bare) or `@derived({ equals })` (factory).
28
30
  Declares a lazy computed derived from other reactive members.
31
+ - `localTo` -- `@localTo(source) accessor x = v` or
32
+ `@localTo(source, { equals? })`. Upstream-keyed resettable local state
33
+ (decisions/0014). `source` is a REQUIRED tracked `(self) => value` fn, called
34
+ inline in the read body (no extra node, PD-54). The read is PURE (never writes
35
+ a box): it calls `source`, compares the value to a per-instance last-seen slot
36
+ via `equals` (default `Object.is`), and returns the local box `.get()` when
37
+ upstream is unchanged, else the upstream value (a changed upstream resets to
38
+ it). A write always overrides -- the write path never compares (PD-56).
39
+ Initial-value unification (0014): with an initializer the member STARTS at that
40
+ value and resets on the first upstream move (@trackedReset flavor); with NO
41
+ initializer the initial is the source evaluated once at wiring and the member
42
+ FOLLOWS upstream from the first read (@localCopy flavor) -- one decorator, both
43
+ semantics, selected by the natural syntax. `equals` governs the UPSTREAM
44
+ compare ONLY. A throwing source propagates from the read, mutating nothing
45
+ (fail closed). ABA contract (shipped, honest, never softened): no public epoch
46
+ exists (NodeDescriptor is {id, kind, value}), so the compare is VALUE-based --
47
+ upstream A -> local write X -> upstream B -> upstream back to an equals-A value
48
+ -> the read shows the STALE LOCAL X (the reset needs upstream to change
49
+ relative to the last adoption, not to have moved transitively). tracked-toolbox
50
+ @localCopy has the same property. Storage per member: ONE signal box + ONE
51
+ plain per-instance seen-slot (never reactive); cost delta +1 node, +0 for the
52
+ slot -- the per-instance formula becomes P+L+D+E+1.
29
53
  - `reactiveEffect` -- `@reactiveEffect m()` (bare) or
30
54
  `@reactiveEffect({ scheduler })` (factory). A method that auto-runs as an
31
55
  effect once the instance is wired. The auto-effect tracks; the public method is
@@ -43,7 +67,7 @@ slot for a poison handle, so any later read/write throws a named
43
67
  (factory). The single wiring site; wraps the class. A `registry` isolates the
44
68
  whole host chain (see the registry law).
45
69
  - `defineReactive(Class, spec) -> Class'` -- the buildless twin. `spec` is
46
- `{ signals, deriveds, effects, host }`; it installs the members on
70
+ `{ signals, deriveds, locals, effects, host }`; it installs the members on
47
71
  `Class.prototype` and wraps the class through the same host step. See the
48
72
  buildless contract.
49
73
  - `disposeReactive(vm) -> boolean` -- cascade + poison teardown. Idempotent: a
@@ -68,7 +92,7 @@ slot for a poison handle, so any later read/write throws a named
68
92
  - `costOf(Factory) -> { nodes, links, signals, deriveds, effects }` -- the
69
93
  measured settled per-instance cost on the class's bound registry (frozen,
70
94
  cached). Double-probed: an inconclusive or polluted probe throws, never
71
- guesses. `nodes` = P+D+E+1; `links` = the first-full-read link count. See
95
+ guesses. `nodes` = P+L+D+E+1; `links` = the first-full-read link count. See
72
96
  "Introspection & audit".
73
97
  - `capacityFor(inventory, { headroom }?) -> RegistryConfig` -- size a
74
98
  `createRegistry` config from `[Factory, count]` pairs. Nodes exact, links x
@@ -80,7 +104,7 @@ slot for a poison handle, so any later read/write throws a named
80
104
  `FinalizationRegistry` reports any instance GC'd without `disposeReactive`.
81
105
  - `ReactiveDisposedError` -- `extends Error`, `name` `"ReactiveDisposedError"`,
82
106
  fields `className` and `key`.
83
- - `VERSION` -- `"1.1.0"`.
107
+ - `VERSION` -- `"1.2.0"`.
84
108
 
85
109
  ## Registry law (one registry per host chain)
86
110
 
@@ -123,7 +147,8 @@ no node leaks, on the decorator path (init-phase) and the buildless path alike.
123
147
 
124
148
  ## Buildless contract (defineReactive)
125
149
 
126
- `defineReactive(Class, spec)` is for consumers without a Stage-3 transpiler. The
150
+ `defineReactive(Class, spec)` is for consumers without a standard-decorators
151
+ (2023-11) transpiler. The
127
152
  spec normalizes (fail closed; `Reflect.ownKeys`, so symbol keys work):
128
153
 
129
154
  - `signals`: an array of keys (each initial `undefined`) OR a map
@@ -133,6 +158,12 @@ spec normalizes (fail closed; `Reflect.ownKeys`, so symbol keys work):
133
158
  throw (ambiguous -- wrap it in `{ initial: fn }` or `{ init }`).
134
159
  - `deriveds`: a map `{ key: (self) => value | { get, equals } }`.
135
160
  - `effects`: a MAP ONLY `{ key: (self) => void | { run, scheduler } }`.
161
+ - `locals`: a MAP ONLY `{ key: { source, equals?, initial? } }` -- the buildless
162
+ twin of `@localTo` (PD-57). `source` is REQUIRED and must be a `(self) => value`
163
+ function (a missing or non-function source is a named throw, fail closed);
164
+ `equals` governs the upstream compare only; `initial` (verbatim, when present)
165
+ selects the @trackedReset flavor, its absence the @localCopy flavor. Same
166
+ read/write laws and ABA contract as the decorator.
136
167
  - `host`: `{ registry }` or omitted -- the same validation as `@reactiveHost`.
137
168
 
138
169
  Spec-declared members that collide with an own property of `Class.prototype` are
@@ -150,7 +181,7 @@ probe on its bound registry: construct, read every `@derived` once (forcing the
150
181
  lazy links), snapshot, dispose, verify the floor -- run TWICE, requiring
151
182
  identical deltas. An inconclusive probe (a data-dependent derived read, or a
152
183
  registry mutated mid-probe) or a floor-verify failure THROWS a named error;
153
- costOf never returns a guess. `nodes` equals P+D+E+1 (the R-A law); `links` is
184
+ costOf never returns a guess. `nodes` equals P+L+D+E+1 (the R-A law); `links` is
154
185
  the first-full-read count. The result is frozen and cached per class. costOf
155
186
  constructs the probe instance with NO constructor arguments.
156
187
 
@@ -211,7 +242,10 @@ runtime requirement -- the shipped surface runs on 1.5.0.
211
242
  `reinitReactive` (an additive MINOR under that promise) -> 18 exports: the 13
212
243
  runtime exports plus `costOf`, `capacityFor`, `enableLabels`, `labelOf`, and
213
244
  `auditReactive` (all cold / opt-in; the hot accessor canon is byte-identical to
214
- 0.3.0). The semver promise from here: any change to an existing export's
245
+ 0.3.0). 1.2.0 adds `localTo` (decisions/0013 strategic track, 0014 contract) as
246
+ an additive MINOR -> 19 exports; the 1.0.0 canon (`makeGet`/`makeSet`/
247
+ `makeDerivedGet`) stays byte-identical -- @localTo ships its own accessor bodies
248
+ and pays its own measured cost. The semver promise from here: any change to an existing export's
215
249
  signature or behavior is a MAJOR, recorded in a decision file; new exports are
216
250
  minors; the hot accessor canon (`makeGet`/`makeSet`) does not move without a
217
251
  major. Also present since
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zakkster/lite-signal-decorators",
3
- "version": "1.1.0",
4
- "description": "Stage-3 decorator layer over @zakkster/lite-signal. The reactive class layer where an instance has a measured cost, deterministic teardown, and a churn benchmark.",
3
+ "version": "1.2.0",
4
+ "description": "Standard-decorators layer over @zakkster/lite-signal (TC39 decorators proposal, Stage 2.7 since 2026-05; TS 5.x / Babel 2023-11 emit unchanged). The reactive class layer where an instance has a measured cost, deterministic teardown, and a churn benchmark.",
5
5
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -39,7 +39,7 @@
39
39
  "reactive",
40
40
  "reactivity",
41
41
  "decorators",
42
- "stage-3",
42
+ "standard-decorators",
43
43
  "computed",
44
44
  "derived",
45
45
  "class",