@zakkster/lite-signal-decorators 1.2.0 → 1.3.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,97 @@ 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.3.0] - 2026-08-30
8
+
9
+ The introspection/migration rung of the decisions/0013 strategic-admission
10
+ ladder, and the release that proves criterion (c): `snapshotOf` ships as the
11
+ MobX-`toJS`-parity export nobody could reach cleanly by composition, and it is
12
+ the REAL, NAMED in-package consumer that finally admits `forEachReactive` under
13
+ the ORIGINAL 0009 bar (a new export needs a named consumer, and a recipe is not
14
+ one). decisions/0009 recorded both as candidates 2 and 3 with the absence
15
+ stated plainly; this release admits them together -- `forEachReactive` because
16
+ `snapshotOf` is built ON it, `snapshotOf` because it is the MobX-`toJS` parity a
17
+ recipe cannot honestly serve. The recorded absence in 0009 is preserved as
18
+ pre-admission history, not rewritten. Surface 19 -> 21.
19
+
20
+ ### Added
21
+
22
+ - **`forEachReactive(vm, fn, arg) -> count`** (20th export) -- a cold walk over
23
+ every value-bearing member. Calls `fn(key, box, kind, arg)` once per member
24
+ and returns the visit count. `kind` is `"signal" | "local" | "derived"`;
25
+ `@reactiveEffect`/`@batched` are EXCLUDED (non-value-bearing -- `boxOf`
26
+ refuses them). Order is PLAN order: signals, then locals, then deriveds, each
27
+ declaration-ordered and ancestor-first (never `Reflect.ownKeys`, so it is
28
+ stable across reinit). Four scalar args, no descriptor object, and the `arg`
29
+ pass-through kills the caller's closure -- a gated zero-alloc walk. Symbol
30
+ keys are visited. Fails closed on non-reactive/unwired/parked/disposed with the
31
+ same named errors as `rootOf`.
32
+ - **`snapshotOf(vm) -> object`** (21st export) -- a shallow plain-object copy of
33
+ every value-bearing member, keyed by member key, built ON `forEachReactive`.
34
+ Values are read through the ACCESSOR `vm[key]`, NOT `box.get`, so a `@localTo`
35
+ compare-on-read resets honestly and a `@derived` computes on read (PD-62). The
36
+ whole walk runs under ONE `untrack` when the caller is tracking, so a
37
+ `snapshotOf` inside an effect subscribes to nothing (PD-63). SHALLOW by design
38
+ (PD-64): a nested VM is copied by reference, recursion deferred to a named
39
+ consumer. Symbol keys included (`Reflect.ownKeys` law). Fails closed on
40
+ parked/disposed (`ReactiveDisposedError`, parked vs disposed flavor) and
41
+ non-reactive values. It ALLOCATES the returned object by design -- reported,
42
+ never gated; the walk under it stays zero-alloc.
43
+ - **`test/18-introspection.test.mjs`** (22 cases) -- both emit lanes + buildless:
44
+ plan-order walk, symbol keys, kind tags, effect/batched exclusion, count
45
+ return + `arg` pass-through, the untracked-read law (a `snapshotOf` inside an
46
+ effect fires ONCE, then never as every member is written), r7
47
+ `{name,hp,mp,alive}` parity, the PD-62 accessor-read reset honesty, and the
48
+ fail-closed non-reactive/unwired/parked/disposed matrix.
49
+ - **The `introspection-torture` lane** (scenario 18 of 18) with its own
50
+ `TORTURE_BREAK` sabotage control: 1e6 hoisted-callback `forEachReactive` walks
51
+ at `maxMajor 0` with control-relative minors, plus 1e5 `snapshotOf` cycles
52
+ whose bytes/op are REPORTED in the summary line, never gated -- the snapshot
53
+ allocates by design and the harness says so out loud.
54
+
55
+ ### Changed
56
+
57
+ - **The export surface: 19 -> 21** -- an additive MINOR under the 1.0.0 semver
58
+ promise (new exports are minors). The 1.0.0 hot canon
59
+ (`makeGet`/`makeSet`/`makeDerivedGet`) stays byte-identical: both new exports
60
+ are cold and neither moves an accessor byte.
61
+ - Three-place version sync to 1.3.0 (`package.json`, the `VERSION` const,
62
+ `llms.txt`); the `test/15` surface-freeze recount 19 -> 21.
63
+ - `throwNoBox` message widened to name `@localTo`: `boxOf` serves `@reactive`,
64
+ `@localTo`, and `@derived` members only (locals pass `boxOf`; the message had
65
+ listed only `@reactive`/`@derived`).
66
+
67
+ ### Measured (rig: Node v26.3.1, arm64 Apple M4 Pro, lite-signal 1.5.0)
68
+
69
+ - 1e6 hoisted-callback `forEachReactive` walks: **0.002 B/walk** (vs the 0.000
70
+ B/op zero-alloc control, within a +2-byte limit), gc major **0**,
71
+ `maxPauseMs <= 4.0`.
72
+ - 1e5 `snapshotOf` cycles: **95.8 B/op** -- the returned object, reported as
73
+ "allocates by design", never gated.
74
+ - 1e5 construct -> snapshot -> dispose cycles: `tracker.size()` 0, findings 0,
75
+ warnings 0, `activeNodes`/`nodes` back to exact pre-loop baseline; snapshots
76
+ hold no box reference.
77
+
78
+ Records: decisions/0013 (strategic-admission track, criterion (c)),
79
+ decisions/0009 (candidates 2 + 3, now stamped ADMITTED with the pre-admission
80
+ absence preserved).
81
+
82
+ ### Gate output (section-10 chain, archived verbatim)
83
+
84
+ ```
85
+ fixtures OK exit 0 -- emit fixtures regenerated
86
+ test OK exit 0 -- 313 pass / 0 fail
87
+ test:gc OK exit 0 -- 313 pass / 0 fail
88
+ torture OK exit 0 -- 16 passed, 2 skipped, 0 warned, 0 failed in 34.9s
89
+ torture:controls OK exit 0 -- 18 passed, 0 skipped, 0 warned, 0 failed in 3.7s
90
+ torture:peer-preview REPORTED NON-BLOCKING -- lane completed (exit 0)
91
+ bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
92
+ cookbook OK exit 0/0 -- corpus 18/18 companions ok; controls 8/8 fail correctly
93
+ pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
94
+ ----------------------------------------------------------------------
95
+ GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
96
+ ```
97
+
7
98
  ## [1.2.0] - 2026-08-30
8
99
 
9
100
  The flagship of the decisions/0013 strategic-admission track: a story-grade,
package/README.md CHANGED
@@ -146,6 +146,7 @@ const ReactivePlayer = defineReactive(Player, {
146
146
  - **One deterministic teardown** -- `disposeReactive(vm)` (or a `using` block): anchor cascade, box disposal, poison swap, idempotent, allocation-free on the success path.
147
147
  - **Fail-closed everything** -- statics, private `#` members, unknown options, duplicate keys, orphaned members, invalid registries, half-valid specs: all named throws at decoration time, with a nearest-key did-you-mean where a typo is likely.
148
148
  - **Interop that stays raw** -- `boxOf(vm, key)` hands you the live engine box; `rootOf(vm)` hands the anchor descriptor to `forEachOwned` / lite-devtools. Decorated and hand-written signals share one graph.
149
+ - **Introspection & migration (1.3.0)** -- `forEachReactive(vm, fn, arg)` walks every value-bearing member in plan order (`signal`/`local`/`derived`, effects excluded) with a zero-alloc `fn(key, box, kind, arg)` callback; `snapshotOf(vm)` returns a shallow plain-object copy read through the accessors under one `untrack` -- the native `toJS` this package now ships, safe to call inside an effect.
149
150
 
150
151
  ---
151
152
 
@@ -275,12 +276,19 @@ Symbol keys work (`Reflect.ownKeys`). A spec key colliding with an own property
275
276
 
276
277
  With labels and audit off, the zero-GC budgets are byte-identical to 0.3.0 -- the hot accessor canon is untouched by all four (review-diffed against the published 0.3.0 tarball).
277
278
 
279
+ ### Introspection walk & snapshot (1.3.0)
280
+
281
+ | Export | Signature | Behavior |
282
+ |---|---|---|
283
+ | `forEachReactive` | `(vm, fn, arg) => count` | Cold value-member walk. Calls `fn(key, box, kind, arg)` once per value-bearing member and returns the visit count. `kind` is `"signal" \| "local" \| "derived"`; `@reactiveEffect`/`@batched` are EXCLUDED (non-value-bearing). Order is PLAN order -- signals, then locals, then deriveds, each declaration-ordered and ancestor-first (never `Reflect.ownKeys`, so it is stable across reinit). Four scalar args, zero descriptor object, and the `arg` pass-through kills the caller's closure: the walk is a gated zero-alloc body. Symbol keys are visited. Fails closed on a non-reactive, unwired, parked, or disposed value with the same named errors as `rootOf`. |
284
+ | `snapshotOf` | `(vm) => object` | A shallow plain-object copy of every value-bearing member, keyed by member key. Values are read through the ACCESSOR `vm[key]`, NOT `box.get`, so a `@localTo` compare-on-read resets honestly and a `@derived` computes on read (PD-62: reading the box directly would show a stale local after an untracked upstream move -- the accessor is the documented read). The whole walk runs under ONE `untrack` when the caller is tracking, so `snapshotOf` inside an effect subscribes to nothing. SHALLOW by design: a nested VM is copied by reference, not recursed. Symbol keys included. Fails closed on parked/disposed (`ReactiveDisposedError`, parked vs disposed flavor) and non-reactive values. This export ALLOCATES the returned object by design (~96 B/op measured) -- reported, never gated; the walk under it stays zero-alloc. |
285
+
278
286
  ### Errors & constants
279
287
 
280
288
  | Export | Value |
281
289
  |---|---|
282
290
  | `ReactiveDisposedError` | `extends Error`; `name: "ReactiveDisposedError"`; fields `className`, `key`. Thrown on ANY touch of a disposed instance's surface. |
283
- | `VERSION` | `"1.2.0"` |
291
+ | `VERSION` | `"1.3.0"` |
284
292
 
285
293
  ### The rejection matrix
286
294
 
@@ -398,12 +406,14 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
398
406
  | `new Host()` | P + L + D + E + 1 pool nodes | plus the instance itself; nodes recycle on dispose (F-0 conservation) |
399
407
  | `disposeReactive(vm)` | none | allocation-free success path; poison handles are prebuilt per member at decoration time |
400
408
  | `boxOf` / `rootOf` / any throw | cold path | introspection and failure paths may allocate; never on the hot path |
409
+ | `forEachReactive` walk | none | gated: 1e6 hoisted-callback walks measure **0.002 B/walk** (vs the 0.000 B/op zero-alloc control -- within a +2-byte limit), `gc.major === 0`; the 4-scalar `fn(key, box, kind, arg)` carries no descriptor object and the `arg` pass-through kills the caller's closure |
410
+ | `snapshotOf(vm)` | 1 plain object | **by design** -- the returned copy allocates (**95.8 B/op measured**, 1e5 cycles); REPORTED in the torture summary line, never gated. The walk *under* it stays zero-alloc; cold, off any frame path |
401
411
 
402
- The gates that hold it (run on every change, all green at 1.2.0):
412
+ The gates that hold it (run on every change, all green at 1.3.0):
403
413
 
404
- - `npm test` / `npm run test:gc` -- **291/291** on both lanes.
414
+ - `npm test` / `npm run test:gc` -- **313/313** on both lanes.
405
415
  - Suite gate (lite-leak + lite-gc-profiler): `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | ok`.
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.
416
+ - Torture: **18 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; the `introspection-torture` 1e6 hoisted-callback `forEachReactive` walk at `maxMajor 0` with the snapshot-allocates figure reported, never gated) -- **16 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 -- **18/18 controls** prove each gate can actually fail.
407
417
  - `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.
408
418
 
409
419
  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.
@@ -435,12 +445,12 @@ Full rationale lives in [`decisions/`](decisions/) -- each is a numbered, dated
435
445
  ## Testing (for clients & QA)
436
446
 
437
447
  ```bash
438
- npm test # node --test, 291 tests
439
- npm run test:gc # the same 291 with --expose-gc (enables the allocation assertions)
448
+ npm test # node --test, 313 tests
449
+ npm run test:gc # the same 313 with --expose-gc (enables the allocation assertions)
440
450
  npm run gate # the full pre-publish chain (section 10): fixtures -> test -> test:gc -> torture -> controls -> peer-preview (non-blocking) -> bench selftest -> cookbook -> pack
441
451
  ```
442
452
 
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.
453
+ **313 tests** across eighteen files, all green at 1.3.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.
444
454
 
445
455
  | File | Tests | Covers |
446
456
  |---|---:|---|
@@ -458,9 +468,10 @@ npm run gate # the full pre-publish chain (section 10): fixtures -> test
458
468
  | `12-accounting` | 11 | `costOf` node/link/shape grid (double-probe, frozen + cached, fail-closed) + `capacityFor` budget sizing |
459
469
  | `13-labels-audit` | 10 | `enableLabels`/`labelOf` per-registry identity + `auditReactive` leak reporting, both opt-in and default-OFF |
460
470
  | `14-qa-s4-boundary` | 21 | S4 adversarial edges: stats-less facade closure, signals-only capacity floor, label/audit boundary matrix |
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 |
471
+ | `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 21 exports), citation allowlist, link law, static-cost probe |
462
472
  | `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
473
  | `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 |
474
+ | `18-introspection` | 22 | `forEachReactive`/`snapshotOf` on both emit lanes + buildless: plan-order walk (signals, locals, deriveds; ancestor-first), symbol keys, the `signal`/`local`/`derived` kind tags, effect/batched exclusion, count return + `arg` pass-through, the untracked-read law (snapshotOf inside an effect fires once), r7 `{name,hp,mp,alive}` parity, the PD-62 accessor-read reset honesty, and the fail-closed non-reactive/unwired/parked/disposed matrix |
464
475
 
465
476
  ### Emit-support matrix
466
477
 
@@ -486,13 +497,13 @@ Source hashes: `fixture.src.ts` `339c40148a70`, `static.src.ts` `81fb649965e6`,
486
497
  Process-isolated stress scenarios built on `@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`:
487
498
 
488
499
  ```bash
489
- npm run torture # all 17 scenarios (15 run + 2 floor-gated skips)
500
+ npm run torture # all 18 scenarios (16 run + 2 floor-gated skips)
490
501
  npm run torture:semantic # the correctness lane (CI)
491
502
  npm run torture:soak # the wall-clock churn + fleet soaks
492
503
  npm run torture:controls # sabotage self-test: every scenario must FAIL when broken
493
504
  ```
494
505
 
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`.
506
+ Eighteen 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 `introspection-torture` gate (1e6 hoisted-callback `forEachReactive` walks at `maxMajor 0` with control-relative minors, plus 1e5 `snapshotOf` cycles whose bytes/op are REPORTED in the summary line, never gated -- the snapshot allocates by design), 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: 16 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`.
496
507
 
497
508
  ### The cookbook lane (dev-side, never shipped)
498
509
 
@@ -547,6 +558,7 @@ The decorator vocabulary maps almost one-to-one; what changes is the lifetime st
547
558
  | `@action m()` | `@batched m()` |
548
559
  | `makeObservable(this, {...})` | `@reactiveHost` -- one wiring site, no mirror object to keep in sync |
549
560
  | `reaction(...)` / `autorun(...)` | `@reactiveEffect m()` |
561
+ | `toJS(obj)` | `snapshotOf(vm)` -- a shallow plain-object copy of every member, read through the accessors under one `untrack` (safe inside an effect); nested VMs stay by reference (1.3.0) |
550
562
  | reaction disposers only; the instance itself is never disposable | **`disposeReactive(vm)` -- one call, idempotent, node-exact, and every later touch throws by name. MobX has no equivalent; its per-instance graph ends when the collector decides.** |
551
563
 
552
564
  ### From signal-utils
@@ -590,7 +602,7 @@ The cross-framework numbers behind this table are stamped in [`decisions/0006-ki
590
602
 
591
603
  ### The cookbook
592
604
 
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).
605
+ [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) collects eighteen composition recipes over the frozen 21-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).
594
606
 
595
607
  ---
596
608
 
@@ -342,6 +342,57 @@ export function boxOf<T = unknown>(vm: object, key: PropertyKey): SignalBox<T> |
342
342
  */
343
343
  export function rootOf(vm: object): NodeDescriptor;
344
344
 
345
+ // --- Reactive walk & snapshot (S9) --------------------------------------------
346
+
347
+ /**
348
+ * The literal kind tag {@link forEachReactive} passes for each visited member: a
349
+ * `@reactive` signal, a `@localTo` local, or a `@derived` computed. Effects and
350
+ * batched actions are non-value-bearing and never appear.
351
+ */
352
+ export type ReactiveKind = "signal" | "local" | "derived";
353
+
354
+ /**
355
+ * Visit every value-bearing reactive member of `vm` in PLAN order -- all signals,
356
+ * then all `@localTo` locals, then all deriveds; within each group declaration-
357
+ * ordered and ancestor-first (a subclass's own members follow its ancestors').
358
+ * `@reactiveEffect` and `@batched` members are EXCLUDED -- they back no box.
359
+ * `fn` receives the member key (symbol keys included), the live {@link SignalBox}
360
+ * / {@link ComputedBox} (exactly what {@link boxOf} returns), the
361
+ * {@link ReactiveKind} literal, and the pass-through `arg` -- which threads caller
362
+ * state without a closure, so the walk is zero-allocation per call and per visit.
363
+ * Returns the number of members visited.
364
+ *
365
+ * @throws {TypeError} if `fn` is not a function.
366
+ * @throws {ReactiveDisposedError} if the instance was disposed or parked.
367
+ * @throws if `vm` is not wired yet, or is not a reactive instance.
368
+ */
369
+ export function forEachReactive<A = unknown>(
370
+ vm: object,
371
+ fn: (
372
+ key: PropertyKey,
373
+ box: SignalBox<unknown> | ComputedBox<unknown>,
374
+ kind: ReactiveKind,
375
+ arg: A,
376
+ ) => void,
377
+ arg?: A,
378
+ ): number;
379
+
380
+ /**
381
+ * Return a plain object snapshot of every value-bearing reactive member of `vm`
382
+ * -- signals, `@localTo` locals, and deriveds -- keyed by member key (symbol keys
383
+ * included). Each value is read through the ACCESSOR `vm[key]`, NOT the raw box,
384
+ * so `@localTo` compare-on-read and derived compute stay honest. SHALLOW by
385
+ * design: a nested reactive VM is copied by reference, never recursed. The whole
386
+ * read pass runs under one untracked scope when a tracking context is active, so
387
+ * calling this inside an effect does NOT subscribe that effect to every member.
388
+ * The returned object allocates by design -- this is a cold introspection call,
389
+ * never a gated hot path.
390
+ *
391
+ * @throws {ReactiveDisposedError} if the instance was disposed or parked.
392
+ * @throws if `vm` is not wired yet, or is not a reactive instance.
393
+ */
394
+ export function snapshotOf(vm: object): Record<PropertyKey, unknown>;
395
+
345
396
  // --- Introspection & audit (S4) -----------------------------------------------
346
397
 
347
398
  /** The measured per-instance cost of a reactive class, returned by {@link costOf}. */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @zakkster/lite-signal-decorators v1.2.0
2
+ * @zakkster/lite-signal-decorators v1.3.0
3
3
  * --------------------
4
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
@@ -447,7 +447,7 @@ function throwReinitInitialsKey(ctorName, key, plan) {
447
447
  function throwNoBox(ctorName, key, kind) {
448
448
  const what = kind === "effect" ? "@reactiveEffect" : "@batched";
449
449
  throw new Error(
450
- `${ERR}boxOf(${ctorName}, ${keyLabel(key)}) -- ${keyLabel(key)} is a ${what} member and has no backing box; boxOf serves @reactive and @derived members only.`,
450
+ `${ERR}boxOf(${ctorName}, ${keyLabel(key)}) -- ${keyLabel(key)} is a ${what} member and has no backing box; boxOf serves @reactive, @localTo, and @derived members only.`,
451
451
  );
452
452
  }
453
453
 
@@ -1927,6 +1927,111 @@ export function rootOf(vm) {
1927
1927
  return a;
1928
1928
  }
1929
1929
 
1930
+ // --- Reactive walk & snapshot (S9; cold / opt-in) -----------------------------
1931
+
1932
+ // Hoisted kind literals -- passed by forEachReactive so a walk carries zero
1933
+ // per-visit bytes beyond its four scalar args (no per-member string allocation).
1934
+ const KIND_SIGNAL = "signal";
1935
+ const KIND_LOCAL = "local";
1936
+ const KIND_DERIVED = "derived";
1937
+
1938
+ function throwForEachFn() {
1939
+ throw new TypeError(
1940
+ `${ERR}forEachReactive(vm, fn) -- fn must be a function; it is called fn(key, box, kind, arg) once per reactive member.`,
1941
+ );
1942
+ }
1943
+
1944
+ /**
1945
+ * Visit every value-bearing reactive member of `vm` in PLAN order -- all signals,
1946
+ * then all @localTo locals, then all deriveds; each group declaration-ordered and
1947
+ * ancestor-first -- invoking `fn(key, box, kind, arg)` per member and returning
1948
+ * the visit count. `box` is the live SignalBox/ComputedBox (exactly what boxOf
1949
+ * returns); `kind` is the literal "signal" | "local" | "derived". @reactiveEffect
1950
+ * and @batched members are EXCLUDED (non-value-bearing; boxOf refuses them). The
1951
+ * `arg` pass-through threads caller state without a closure, so the walk is
1952
+ * zero-allocation per call and per visit.
1953
+ *
1954
+ * @throws {TypeError} if `fn` is not a function.
1955
+ * @throws {ReactiveDisposedError} if the instance was disposed or parked.
1956
+ * @throws if `vm` is not wired yet, or is not a reactive instance.
1957
+ */
1958
+ export function forEachReactive(vm, fn, arg) {
1959
+ if (typeof fn !== "function") throwForEachFn();
1960
+ const plan = planOf(vm);
1961
+ if (plan === undefined) throwNoPlan("forEachReactive");
1962
+ const a = vm[ANCHOR];
1963
+ if (a === undefined) throwNotWired("forEachReactive");
1964
+ if (a === DISPOSED) throw new ReactiveDisposedError(plan.ctorName, "<root>");
1965
+ if (a === PARKED) throw new ReactiveDisposedError(plan.ctorName, "<root>", true);
1966
+ let n = 0;
1967
+ const sigs = plan.signals;
1968
+ for (let i = 0; i < sigs.length; i++) {
1969
+ const r = sigs[i];
1970
+ const h = vm[r.slot];
1971
+ if (h !== undefined && h[NONLIVE] === "prewired") throwPrewiredMember(plan.ctorName, r.key);
1972
+ fn(r.key, h, KIND_SIGNAL, arg);
1973
+ n++;
1974
+ }
1975
+ const locs = plan.locals;
1976
+ for (let i = 0; i < locs.length; i++) {
1977
+ const r = locs[i];
1978
+ const h = vm[r.slot];
1979
+ if (h !== undefined && h[NONLIVE] === "prewired") throwPrewiredMember(plan.ctorName, r.key);
1980
+ fn(r.key, h, KIND_LOCAL, arg);
1981
+ n++;
1982
+ }
1983
+ const ders = plan.deriveds;
1984
+ for (let i = 0; i < ders.length; i++) {
1985
+ const r = ders[i];
1986
+ const h = vm[r.slot];
1987
+ if (h !== undefined && h[NONLIVE] === "prewired") throwPrewiredMember(plan.ctorName, r.key);
1988
+ fn(r.key, h, KIND_DERIVED, arg);
1989
+ n++;
1990
+ }
1991
+ return n;
1992
+ }
1993
+
1994
+ // snapshotOf's per-member visitor -- the forEachReactive walk contract
1995
+ // (key, box, kind, arg). snapshotOf IS forEachReactive's named in-package
1996
+ // consumer: the fill is ROUTED through the walk (0009 candidate 2 / 0013 (c)
1997
+ // admission ground), not a private duplicate. The carrier `arg` threads both the
1998
+ // instance and the output object so the visitor stays a hoisted, closure-free
1999
+ // function. The read is the ACCESSOR vm[key] (PD-62), NOT box.get -- so @localTo
2000
+ // compare-on-read and derived compute stay honest; `box`/`kind` are unused here,
2001
+ // which the walk contract permits (a consumer reads only the fields it needs).
2002
+ function snapshotVisit(key, box, kind, arg) {
2003
+ arg.out[key] = arg.vm[key];
2004
+ }
2005
+
2006
+ /**
2007
+ * Return a plain `{}` snapshot of every value-bearing reactive member of `vm` --
2008
+ * signals, @localTo locals, and deriveds -- keyed by member key (symbol keys
2009
+ * included), each value read through the ACCESSOR `vm[key]` (so @localTo
2010
+ * compare-on-read and derived compute stay honest). SHALLOW by design: a nested
2011
+ * reactive VM is copied by reference, never recursed. The whole read pass runs
2012
+ * under ONE untrack thunk when a tracking scope is active (the makeLocalSet
2013
+ * idiom), so calling snapshotOf inside an effect does NOT subscribe the effect to
2014
+ * every member. The returned object allocates by design -- this is a cold
2015
+ * introspection call, never a gated hot path.
2016
+ *
2017
+ * @throws {ReactiveDisposedError} if the instance was disposed or parked.
2018
+ * @throws if `vm` is not wired yet, or is not a reactive instance.
2019
+ */
2020
+ export function snapshotOf(vm) {
2021
+ const plan = planOf(vm);
2022
+ if (plan === undefined) throwNoPlan("snapshotOf");
2023
+ const a = vm[ANCHOR];
2024
+ if (a === undefined) throwNotWired("snapshotOf");
2025
+ if (a === DISPOSED) throw new ReactiveDisposedError(plan.ctorName, "<root>");
2026
+ if (a === PARKED) throw new ReactiveDisposedError(plan.ctorName, "<root>", true);
2027
+ const out = {};
2028
+ const carrier = { vm, out }; // allocates by design (cold call)
2029
+ const reg = plan.reg;
2030
+ if (reg.isTracking()) reg.untrack(() => forEachReactive(vm, snapshotVisit, carrier));
2031
+ else forEachReactive(vm, snapshotVisit, carrier);
2032
+ return out;
2033
+ }
2034
+
1930
2035
  // --- Introspection & audit (S4; all cold / opt-in) ----------------------------
1931
2036
 
1932
2037
  function throwCostFactory() {
@@ -2254,4 +2359,4 @@ export function auditReactive(on) {
2254
2359
  // --- Version ------------------------------------------------------------------
2255
2360
 
2256
2361
  /** Package version. Kept in lockstep with package.json and llms.txt. */
2257
- export const VERSION = "1.2.0";
2362
+ export const VERSION = "1.3.0";
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-signal-decorators
2
2
 
3
- VERSION 1.2.0
3
+ VERSION 1.3.0
4
4
 
5
5
  > Standard-decorators layer over @zakkster/lite-signal, built on the TC39
6
6
  > decorators proposal (Stage 2.7 since 2026-05; TS 5.x / Babel 2023-11 emit
@@ -22,7 +22,7 @@ zero decorator syntax, sharing the SAME core by function identity.
22
22
  slot for a poison handle, so any later read/write throws a named
23
23
  `ReactiveDisposedError`.
24
24
 
25
- ## Exports (19)
25
+ ## Exports (21)
26
26
 
27
27
  - `reactive` -- `@reactive accessor x = v` (bare) or `@reactive({ equals })`
28
28
  (factory). Declares a per-instance signal.
@@ -89,6 +89,28 @@ slot for a poison handle, so any later read/write throws a named
89
89
  dispose.
90
90
  - `rootOf(vm) -> NodeDescriptor` -- the instance's anchor descriptor; feeds
91
91
  `forEachOwned` / lite-devtools. Throws `ReactiveDisposedError` after dispose.
92
+ - `forEachReactive(vm, fn, arg) -> count` -- cold value-member walk. Calls
93
+ `fn(key, box, kind, arg)` once per value-bearing member and returns the visit
94
+ count. `kind` is `"signal" | "local" | "derived"`; `@reactiveEffect` and
95
+ `@batched` are EXCLUDED (non-value-bearing -- `boxOf` refuses them). Order is
96
+ PLAN order: signals, then locals, then deriveds, each declaration-ordered and
97
+ ancestor-first (never `Reflect.ownKeys`, so it is stable across reinit). Four
98
+ scalar args, zero descriptor object, and the `arg` pass-through kills the
99
+ caller's closure -- the walk is a gated zero-alloc body. Symbol-keyed members
100
+ are visited (`byKey` is a Map). Fails closed on a non-reactive, unwired,
101
+ parked, or disposed value with the same named errors as `rootOf`.
102
+ - `snapshotOf(vm) -> plain object` -- a shallow plain-object copy of every
103
+ value-bearing member (signals + locals + deriveds), keyed by member key.
104
+ Values are read through the ACCESSOR `vm[key]` (NOT `box.get`), so a `@localTo`
105
+ compare-on-read resets honestly and a `@derived` computes on read. The whole
106
+ walk runs under ONE `reg.untrack` thunk when `reg.isTracking()`, so calling it
107
+ inside an effect subscribes to NOTHING. SHALLOW by design: a nested VM is
108
+ copied by reference, not recursed (recursion needs a seen-set + cycle law,
109
+ deferred to a named consumer). Symbol keys are included
110
+ (`Reflect.ownKeys` law). Fails closed on parked/disposed (named
111
+ `ReactiveDisposedError`) and non-reactive values. This export ALLOCATES by
112
+ design (the returned object; ~96 B/op measured) and is reported, never gated --
113
+ the walk under it stays zero-alloc.
92
114
  - `costOf(Factory) -> { nodes, links, signals, deriveds, effects }` -- the
93
115
  measured settled per-instance cost on the class's bound registry (frozen,
94
116
  cached). Double-probed: an inconclusive or polluted probe throws, never
@@ -104,7 +126,7 @@ slot for a poison handle, so any later read/write throws a named
104
126
  `FinalizationRegistry` reports any instance GC'd without `disposeReactive`.
105
127
  - `ReactiveDisposedError` -- `extends Error`, `name` `"ReactiveDisposedError"`,
106
128
  fields `className` and `key`.
107
- - `VERSION` -- `"1.2.0"`.
129
+ - `VERSION` -- `"1.3.0"`.
108
130
 
109
131
  ## Registry law (one registry per host chain)
110
132
 
@@ -245,7 +267,10 @@ runtime exports plus `costOf`, `capacityFor`, `enableLabels`, `labelOf`, and
245
267
  0.3.0). 1.2.0 adds `localTo` (decisions/0013 strategic track, 0014 contract) as
246
268
  an additive MINOR -> 19 exports; the 1.0.0 canon (`makeGet`/`makeSet`/
247
269
  `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
270
+ and pays its own measured cost. 1.3.0 adds `forEachReactive` + `snapshotOf`
271
+ (decisions/0013 ladder) as an additive MINOR -> 21 exports; `snapshotOf` is the
272
+ named in-package consumer that admits `forEachReactive` under the 0009 bar, and
273
+ the 1.0.0 canon stays byte-identical. The semver promise from here: any change to an existing export's
249
274
  signature or behavior is a MAJOR, recorded in a decision file; new exports are
250
275
  minors; the hot accessor canon (`makeGet`/`makeSet`) does not move without a
251
276
  major. Also present since
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-signal-decorators",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
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",