@zakkster/lite-signal-decorators 1.2.0 → 1.4.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 +201 -0
- package/README.md +38 -11
- package/SignalDecorators.d.ts +80 -1
- package/SignalDecorators.js +234 -3
- package/llms.txt +47 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,207 @@ 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.4.0] - 2026-08-30
|
|
8
|
+
|
|
9
|
+
The measured-instance pillar -- decisions/0013 criterion (b). `costOf(Factory)`
|
|
10
|
+
answers "what will an instance of this class cost" by probing with NO ctor args,
|
|
11
|
+
so a ctor-arg-dependent shape needs a measurement-twin class to size it;
|
|
12
|
+
decisions/0009 candidate 4 recorded that absence plainly (r9 and the fleet demo
|
|
13
|
+
both paid it). `costOfInstance(vm)` closes it: it measures a LIVE, wired instance
|
|
14
|
+
by walking its OWN graph -- no probe, no twin, no registry pollution. The demo is
|
|
15
|
+
the NAMED consumer that admits it under the 0009 bar (a new export needs a named
|
|
16
|
+
consumer, and a recipe is not one): the console's shape-drift wall now measures a
|
|
17
|
+
real `Entity` and the HUD reports a live fleet member's cost per tick. The
|
|
18
|
+
`EntityShape` twin is honestly retained -- its remaining job is `capacityFor`
|
|
19
|
+
sizing only (the world must be sized before it exists). Surface 21 -> 22.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **`costOfInstance(vm) -> { nodes, links, signals, locals, deriveds, effects }`**
|
|
24
|
+
(22nd export) -- the LIVE per-instance cost, walked from the instance's own
|
|
25
|
+
graph: `nodes = 1 (anchor) + plan.signals.length + plan.locals.length +
|
|
26
|
+
forEachOwned(rootOf(vm))` (the deriveds and user effects the anchor adopted --
|
|
27
|
+
signal/local boxes are built pre-anchor and unadopted, so they are never
|
|
28
|
+
owned); `links` is the un-deduped sum of `forEachSource` over the anchor, every
|
|
29
|
+
owned node, and every signal/local box (one edge per observer, matching
|
|
30
|
+
`costOf`'s activeLinks delta); kind counts are read from the plan arrays, never
|
|
31
|
+
walked. THE LIVE-VS-PROBE CONTRACT is the feature: `costOf` forces every derived
|
|
32
|
+
to the constructed CEILING, `costOfInstance` reports what THIS instance costs
|
|
33
|
+
right now -- an unforced lazy derived or an untaken dynamic branch shows FEWER
|
|
34
|
+
links until the graph is exercised (`nodes` matches regardless; read every
|
|
35
|
+
derived once and the two agree exactly). UNCACHED (PD-70) -- a live graph
|
|
36
|
+
mutates, so a cached number would lie. Needs no `stats()` ledger (PD-72), so it
|
|
37
|
+
measures instances on hand-rolled registries where `costOf` fails closed.
|
|
38
|
+
Allocates its frozen result by design (one object per call, cold like
|
|
39
|
+
`snapshotOf`; PD-69, no out-param variant). Fails closed on a disposed/parked
|
|
40
|
+
instance with a NAMED `ReactiveDisposedError` (PD-71 -- a parked vm holds ZERO
|
|
41
|
+
nodes and a silent `{ nodes: 0 }` is indistinguishable from a bug) and on
|
|
42
|
+
unwired/no-plan/prewired-member values.
|
|
43
|
+
- **`test/19-cost-instance.test.mjs`** (22 cases) -- both emit lanes + buildless:
|
|
44
|
+
A1 parity-when-forced (=== `costOf`, nodes/links/every kind count), A2
|
|
45
|
+
delta-when-lazy (links strictly lower, then monotonic toward the forced
|
|
46
|
+
number), a `@localTo` member counted in locals contributing ZERO graph links,
|
|
47
|
+
the frozen `{nodes,links,signals,locals,deriveds,effects}` shape, A3
|
|
48
|
+
registry-untouched over 10000 calls, PD-72 bound-registry + stats-less-facade
|
|
49
|
+
measurement (where `costOf` fails closed), PD-70 uncached/live across a branch
|
|
50
|
+
flip, and the A6 fail-closed matrix (plain/unwired/parked/disposed each a NAMED
|
|
51
|
+
throw, never a `{nodes:0}` report).
|
|
52
|
+
- **The `introspection-torture` lane extended** with two `costOfInstance`
|
|
53
|
+
blocks: A4 -- 1e4 calls at `maxMajor 0`, `maxPauseMs <= 4.0`, the per-call
|
|
54
|
+
frozen result the only allocation (REPORTED, never gated); A5 -- 1000
|
|
55
|
+
wire/measure/park/reinit/dispose cycles with `tracker.size()` 0, `activeNodes`
|
|
56
|
+
to exact baseline, pool growths 0.
|
|
57
|
+
- **The demo consumer** (never cut -- the admission ground): the console's
|
|
58
|
+
shape-drift wall measures a real live `Entity` via `costOfInstance` (its node
|
|
59
|
+
count === the sizing twin's) and the HUD reports one live fleet member's
|
|
60
|
+
`costOfInstance` per HUD tick (never per frame), so the live-vs-forced delta is
|
|
61
|
+
visible on screen. Cold boot / HUD-tick paths only; zero frame-loop cost.
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
- **The export surface: 21 -> 22** -- 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: `costOfInstance`
|
|
68
|
+
is cold and moves no accessor byte.
|
|
69
|
+
- Version sync to 1.4.0 across FOUR sites now: `package.json`, the `VERSION`
|
|
70
|
+
const, `llms.txt` line 3, and the `SignalDecorators.d.ts` VERSION literal --
|
|
71
|
+
the last a NEW asserted sync site. The `.d.ts` literal had gone stale (it read
|
|
72
|
+
a prior version, escaping the three-place sweep since the d.ts VERSION line was
|
|
73
|
+
never gated); the owner caught it, so `test/15`'s VERSION-consistency test
|
|
74
|
+
gains a FOURTH leg that regexes the `.d.ts` literal and asserts it string-equals
|
|
75
|
+
`package.json`, killing that bug class.
|
|
76
|
+
- `test/15` surface-freeze recount 21 -> 22 (all CB-A2 sites).
|
|
77
|
+
|
|
78
|
+
### Measured (rig: Node v26.3.1, arm64 Apple M4 Pro, lite-signal 1.5.0)
|
|
79
|
+
|
|
80
|
+
- A1 parity (forced): `costOfInstance(vm)` === `costOf(Factory)` for the same
|
|
81
|
+
shape once every derived is read once -- demo `Entity` nodes **7**, links
|
|
82
|
+
**3** (P2/L1/D2/E1), both paths identical; kind counts identical.
|
|
83
|
+
- A2 delta (lazy): a fresh instance reads links **1 -> 2 -> 3** as its deriveds
|
|
84
|
+
are exercised, strictly below the forced number until the graph is exercised;
|
|
85
|
+
node counts equal throughout.
|
|
86
|
+
- A standalone `@localTo` member contributes **ZERO** graph links (the upstream
|
|
87
|
+
compare is a plain per-instance slot, not an edge) -- measured, not assumed; it
|
|
88
|
+
counts in `locals` only.
|
|
89
|
+
- 1e4 `costOfInstance` calls: **71.3 B/op** (the per-call frozen result), gc
|
|
90
|
+
major **0**, `maxPauseMs <= 4.0` -- REPORTED, never gated.
|
|
91
|
+
- A3: 10000 calls leave the registry `stats()` snapshot byte-identical
|
|
92
|
+
(`activeNodes`/`activeLinks`/`totalDisposals` unchanged) -- the walk never
|
|
93
|
+
mutates the registry.
|
|
94
|
+
- A5: 1000 wire/measure/park/reinit/dispose cycles -- `tracker.size()` 0,
|
|
95
|
+
`activeNodes` to exact baseline, pool growths 0.
|
|
96
|
+
|
|
97
|
+
Records: decisions/0013 (strategic-admission track, criterion (b)),
|
|
98
|
+
decisions/0009 (candidate 4, now stamped ADMITTED with the pre-admission absence
|
|
99
|
+
preserved).
|
|
100
|
+
|
|
101
|
+
### Gate output (section-10 chain, archived verbatim)
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
fixtures OK exit 0 -- emit fixtures regenerated
|
|
105
|
+
test OK exit 0 -- 335 pass / 0 fail
|
|
106
|
+
test:gc OK exit 0 -- 335 pass / 0 fail
|
|
107
|
+
torture OK exit 0 -- 16 passed, 2 skipped, 0 warned, 0 failed in 34.3s
|
|
108
|
+
torture:controls OK exit 0 -- 18 passed, 0 skipped, 0 warned, 0 failed in 3.6s
|
|
109
|
+
torture:peer-preview REPORTED NON-BLOCKING -- lane completed (exit 0) [preview 1.9.0-preview.6 SUITE-GREEN 18/0/0/0; canary 1.9.0-canary.1 SUITE-GREEN 18/0/0/0]
|
|
110
|
+
bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
|
|
111
|
+
cookbook OK exit 0/0 -- corpus 18/18 companions ok in 2.1s; controls 8/8 controls fail correctly in 5.1s
|
|
112
|
+
pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
|
|
113
|
+
----------------------------------------------------------------------
|
|
114
|
+
GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## [1.3.0] - 2026-08-30
|
|
118
|
+
|
|
119
|
+
The introspection/migration rung of the decisions/0013 strategic-admission
|
|
120
|
+
ladder, and the release that proves criterion (c): `snapshotOf` ships as the
|
|
121
|
+
MobX-`toJS`-parity export nobody could reach cleanly by composition, and it is
|
|
122
|
+
the REAL, NAMED in-package consumer that finally admits `forEachReactive` under
|
|
123
|
+
the ORIGINAL 0009 bar (a new export needs a named consumer, and a recipe is not
|
|
124
|
+
one). decisions/0009 recorded both as candidates 2 and 3 with the absence
|
|
125
|
+
stated plainly; this release admits them together -- `forEachReactive` because
|
|
126
|
+
`snapshotOf` is built ON it, `snapshotOf` because it is the MobX-`toJS` parity a
|
|
127
|
+
recipe cannot honestly serve. The recorded absence in 0009 is preserved as
|
|
128
|
+
pre-admission history, not rewritten. Surface 19 -> 21.
|
|
129
|
+
|
|
130
|
+
### Added
|
|
131
|
+
|
|
132
|
+
- **`forEachReactive(vm, fn, arg) -> count`** (20th export) -- a cold walk over
|
|
133
|
+
every value-bearing member. Calls `fn(key, box, kind, arg)` once per member
|
|
134
|
+
and returns the visit count. `kind` is `"signal" | "local" | "derived"`;
|
|
135
|
+
`@reactiveEffect`/`@batched` are EXCLUDED (non-value-bearing -- `boxOf`
|
|
136
|
+
refuses them). Order is PLAN order: signals, then locals, then deriveds, each
|
|
137
|
+
declaration-ordered and ancestor-first (never `Reflect.ownKeys`, so it is
|
|
138
|
+
stable across reinit). Four scalar args, no descriptor object, and the `arg`
|
|
139
|
+
pass-through kills the caller's closure -- a gated zero-alloc walk. Symbol
|
|
140
|
+
keys are visited. Fails closed on non-reactive/unwired/parked/disposed with the
|
|
141
|
+
same named errors as `rootOf`.
|
|
142
|
+
- **`snapshotOf(vm) -> object`** (21st export) -- a shallow plain-object copy of
|
|
143
|
+
every value-bearing member, keyed by member key, built ON `forEachReactive`.
|
|
144
|
+
Values are read through the ACCESSOR `vm[key]`, NOT `box.get`, so a `@localTo`
|
|
145
|
+
compare-on-read resets honestly and a `@derived` computes on read (PD-62). The
|
|
146
|
+
whole walk runs under ONE `untrack` when the caller is tracking, so a
|
|
147
|
+
`snapshotOf` inside an effect subscribes to nothing (PD-63). SHALLOW by design
|
|
148
|
+
(PD-64): a nested VM is copied by reference, recursion deferred to a named
|
|
149
|
+
consumer. Symbol keys included (`Reflect.ownKeys` law). Fails closed on
|
|
150
|
+
parked/disposed (`ReactiveDisposedError`, parked vs disposed flavor) and
|
|
151
|
+
non-reactive values. It ALLOCATES the returned object by design -- reported,
|
|
152
|
+
never gated; the walk under it stays zero-alloc.
|
|
153
|
+
- **`test/18-introspection.test.mjs`** (22 cases) -- both emit lanes + buildless:
|
|
154
|
+
plan-order walk, symbol keys, kind tags, effect/batched exclusion, count
|
|
155
|
+
return + `arg` pass-through, the untracked-read law (a `snapshotOf` inside an
|
|
156
|
+
effect fires ONCE, then never as every member is written), r7
|
|
157
|
+
`{name,hp,mp,alive}` parity, the PD-62 accessor-read reset honesty, and the
|
|
158
|
+
fail-closed non-reactive/unwired/parked/disposed matrix.
|
|
159
|
+
- **The `introspection-torture` lane** (scenario 18 of 18) with its own
|
|
160
|
+
`TORTURE_BREAK` sabotage control: 1e6 hoisted-callback `forEachReactive` walks
|
|
161
|
+
at `maxMajor 0` with control-relative minors, plus 1e5 `snapshotOf` cycles
|
|
162
|
+
whose bytes/op are REPORTED in the summary line, never gated -- the snapshot
|
|
163
|
+
allocates by design and the harness says so out loud.
|
|
164
|
+
|
|
165
|
+
### Changed
|
|
166
|
+
|
|
167
|
+
- **The export surface: 19 -> 21** -- an additive MINOR under the 1.0.0 semver
|
|
168
|
+
promise (new exports are minors). The 1.0.0 hot canon
|
|
169
|
+
(`makeGet`/`makeSet`/`makeDerivedGet`) stays byte-identical: both new exports
|
|
170
|
+
are cold and neither moves an accessor byte.
|
|
171
|
+
- Three-place version sync to 1.3.0 (`package.json`, the `VERSION` const,
|
|
172
|
+
`llms.txt`); the `test/15` surface-freeze recount 19 -> 21.
|
|
173
|
+
- `throwNoBox` message widened to name `@localTo`: `boxOf` serves `@reactive`,
|
|
174
|
+
`@localTo`, and `@derived` members only (locals pass `boxOf`; the message had
|
|
175
|
+
listed only `@reactive`/`@derived`).
|
|
176
|
+
|
|
177
|
+
### Measured (rig: Node v26.3.1, arm64 Apple M4 Pro, lite-signal 1.5.0)
|
|
178
|
+
|
|
179
|
+
- 1e6 hoisted-callback `forEachReactive` walks: **0.002 B/walk** (vs the 0.000
|
|
180
|
+
B/op zero-alloc control, within a +2-byte limit), gc major **0**,
|
|
181
|
+
`maxPauseMs <= 4.0`.
|
|
182
|
+
- 1e5 `snapshotOf` cycles: **95.8 B/op** -- the returned object, reported as
|
|
183
|
+
"allocates by design", never gated.
|
|
184
|
+
- 1e5 construct -> snapshot -> dispose cycles: `tracker.size()` 0, findings 0,
|
|
185
|
+
warnings 0, `activeNodes`/`nodes` back to exact pre-loop baseline; snapshots
|
|
186
|
+
hold no box reference.
|
|
187
|
+
|
|
188
|
+
Records: decisions/0013 (strategic-admission track, criterion (c)),
|
|
189
|
+
decisions/0009 (candidates 2 + 3, now stamped ADMITTED with the pre-admission
|
|
190
|
+
absence preserved).
|
|
191
|
+
|
|
192
|
+
### Gate output (section-10 chain, archived verbatim)
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
fixtures OK exit 0 -- emit fixtures regenerated
|
|
196
|
+
test OK exit 0 -- 313 pass / 0 fail
|
|
197
|
+
test:gc OK exit 0 -- 313 pass / 0 fail
|
|
198
|
+
torture OK exit 0 -- 16 passed, 2 skipped, 0 warned, 0 failed in 34.9s
|
|
199
|
+
torture:controls OK exit 0 -- 18 passed, 0 skipped, 0 warned, 0 failed in 3.7s
|
|
200
|
+
torture:peer-preview REPORTED NON-BLOCKING -- lane completed (exit 0)
|
|
201
|
+
bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
|
|
202
|
+
cookbook OK exit 0/0 -- corpus 18/18 companions ok; controls 8/8 fail correctly
|
|
203
|
+
pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
|
|
204
|
+
----------------------------------------------------------------------
|
|
205
|
+
GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
|
|
206
|
+
```
|
|
207
|
+
|
|
7
208
|
## [1.2.0] - 2026-08-30
|
|
8
209
|
|
|
9
210
|
The flagship of the decisions/0013 strategic-admission track: a story-grade,
|
package/README.md
CHANGED
|
@@ -146,6 +146,8 @@ 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.
|
|
150
|
+
- **Live per-instance cost (1.4.0)** -- `costOfInstance(vm)` walks one wired instance's own graph and reports what it costs RIGHT NOW: `costOf(Factory)` answers "what will an instance of this class cost" (it forces every derived to the constructed ceiling), `costOfInstance` answers "what does THIS instance cost" -- an unforced lazy derived or an untaken branch shows fewer links until the graph is exercised, and reading every derived once makes the two agree exactly. Twin-free: it needs no stats() ledger, so it measures instances on hand-rolled registries where `costOf` fails closed.
|
|
149
151
|
|
|
150
152
|
---
|
|
151
153
|
|
|
@@ -269,18 +271,35 @@ Symbol keys work (`Reflect.ownKeys`). A spec key colliding with an own property
|
|
|
269
271
|
| Export | Signature | Behavior |
|
|
270
272
|
|---|---|---|
|
|
271
273
|
| `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. |
|
|
274
|
+
| `costOfInstance` (1.4.0) | `(vm) => { nodes, links, signals, locals, deriveds, effects }` | The LIVE cost of one wired instance right now, walked from its own graph -- no probe, no ctor args, no registry pollution. The delta from `costOf` IS the feature: `costOf` forces every derived to the constructed CEILING ("what will an instance of this class cost"), `costOfInstance` reports "what does THIS instance cost right now" -- an unforced lazy derived or an untaken branch has formed no links, so `links` reads BELOW `costOf` until the graph is exercised (`nodes` matches regardless; read every derived once and the two agree exactly). UNCACHED -- a live graph mutates, so a cached number would lie. Needs no stats() ledger, so it measures instances on hand-rolled registries where `costOf` fails closed. Allocates its frozen result by design (one object per call, ~71 B/op -- reported, never gated). Fails closed on a disposed/parked instance with a NAMED throw (a parked vm holds zero nodes; a silent `{ nodes: 0 }` is indistinguishable from a bug) and on unwired/no-plan/prewired values. |
|
|
272
275
|
| `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). |
|
|
273
276
|
| `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. |
|
|
274
277
|
| `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. |
|
|
275
278
|
|
|
279
|
+
The `costOf`/`costOfInstance` split reads as a class-vs-instance pair:
|
|
280
|
+
|
|
281
|
+
```js
|
|
282
|
+
const cls = costOf(Enemy); // ceiling: every derived forced
|
|
283
|
+
const fresh = costOfInstance(inst); // fresh.links < cls.links (a lazy derived unread)
|
|
284
|
+
inst.threat; inst.range; // exercise the deriveds, then re-measure
|
|
285
|
+
costOfInstance(inst).links === cls.links; // now exact
|
|
286
|
+
```
|
|
287
|
+
|
|
276
288
|
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
289
|
|
|
290
|
+
### Introspection walk & snapshot (1.3.0)
|
|
291
|
+
|
|
292
|
+
| Export | Signature | Behavior |
|
|
293
|
+
|---|---|---|
|
|
294
|
+
| `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`. |
|
|
295
|
+
| `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. |
|
|
296
|
+
|
|
278
297
|
### Errors & constants
|
|
279
298
|
|
|
280
299
|
| Export | Value |
|
|
281
300
|
|---|---|
|
|
282
301
|
| `ReactiveDisposedError` | `extends Error`; `name: "ReactiveDisposedError"`; fields `className`, `key`. Thrown on ANY touch of a disposed instance's surface. |
|
|
283
|
-
| `VERSION` | `"1.
|
|
302
|
+
| `VERSION` | `"1.4.0"` |
|
|
284
303
|
|
|
285
304
|
### The rejection matrix
|
|
286
305
|
|
|
@@ -398,12 +417,15 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
|
|
|
398
417
|
| `new Host()` | P + L + D + E + 1 pool nodes | plus the instance itself; nodes recycle on dispose (F-0 conservation) |
|
|
399
418
|
| `disposeReactive(vm)` | none | allocation-free success path; poison handles are prebuilt per member at decoration time |
|
|
400
419
|
| `boxOf` / `rootOf` / any throw | cold path | introspection and failure paths may allocate; never on the hot path |
|
|
420
|
+
| `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 |
|
|
421
|
+
| `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 |
|
|
422
|
+
| `costOfInstance(vm)` | 1 frozen object | **by design** -- the per-call frozen result allocates (**71.3 B/op measured**, 1e4 calls); the measurement itself is `gc.major === 0` over those 1e4 calls -- REPORTED, never gated. The graph walk *under* it allocates nothing (module-slot visitors, no per-call closure); cold, off any frame path |
|
|
401
423
|
|
|
402
|
-
The gates that hold it (run on every change, all green at 1.
|
|
424
|
+
The gates that hold it (run on every change, all green at 1.4.0):
|
|
403
425
|
|
|
404
|
-
- `npm test` / `npm run test:gc` -- **
|
|
426
|
+
- `npm test` / `npm run test:gc` -- **335/335** on both lanes.
|
|
405
427
|
- 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: **
|
|
428
|
+
- 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
429
|
- `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
430
|
|
|
409
431
|
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 +457,12 @@ Full rationale lives in [`decisions/`](decisions/) -- each is a numbered, dated
|
|
|
435
457
|
## Testing (for clients & QA)
|
|
436
458
|
|
|
437
459
|
```bash
|
|
438
|
-
npm test # node --test,
|
|
439
|
-
npm run test:gc # the same
|
|
460
|
+
npm test # node --test, 335 tests
|
|
461
|
+
npm run test:gc # the same 335 with --expose-gc (enables the allocation assertions)
|
|
440
462
|
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
463
|
```
|
|
442
464
|
|
|
443
|
-
**
|
|
465
|
+
**335 tests** across nineteen files, all green at 1.4.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
466
|
|
|
445
467
|
| File | Tests | Covers |
|
|
446
468
|
|---|---:|---|
|
|
@@ -458,9 +480,11 @@ npm run gate # the full pre-publish chain (section 10): fixtures -> test
|
|
|
458
480
|
| `12-accounting` | 11 | `costOf` node/link/shape grid (double-probe, frozen + cached, fail-closed) + `capacityFor` budget sizing |
|
|
459
481
|
| `13-labels-audit` | 10 | `enableLabels`/`labelOf` per-registry identity + `auditReactive` leak reporting, both opt-in and default-OFF |
|
|
460
482
|
| `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
|
|
483
|
+
| `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 22 exports), citation allowlist, link law, static-cost probe, and the four-place VERSION sync (module const === package.json === llms.txt === `SignalDecorators.d.ts` literal) |
|
|
462
484
|
| `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
485
|
| `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 |
|
|
486
|
+
| `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 |
|
|
487
|
+
| `19-cost-instance` | 22 | `costOfInstance` on both emit lanes + buildless: A1 parity-when-forced (=== `costOf`, nodes/links/every kind count), A2 delta-when-lazy (links strictly lower, then monotonic toward the forced number), `@localTo` counted in locals with zero graph links, the frozen `{nodes,links,signals,locals,deriveds,effects}` shape, A3 registry-untouched over 10000 calls, PD-72 bound-registry + stats-less-facade measurement (where `costOf` fails closed), PD-70 uncached/live across a branch flip, and the A6 fail-closed matrix (plain/unwired/parked/disposed each a NAMED throw, never a `{nodes:0}` report) |
|
|
464
488
|
|
|
465
489
|
### Emit-support matrix
|
|
466
490
|
|
|
@@ -486,13 +510,13 @@ Source hashes: `fixture.src.ts` `339c40148a70`, `static.src.ts` `81fb649965e6`,
|
|
|
486
510
|
Process-isolated stress scenarios built on `@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`:
|
|
487
511
|
|
|
488
512
|
```bash
|
|
489
|
-
npm run torture # all
|
|
513
|
+
npm run torture # all 18 scenarios (16 run + 2 floor-gated skips)
|
|
490
514
|
npm run torture:semantic # the correctness lane (CI)
|
|
491
515
|
npm run torture:soak # the wall-clock churn + fleet soaks
|
|
492
516
|
npm run torture:controls # sabotage self-test: every scenario must FAIL when broken
|
|
493
517
|
```
|
|
494
518
|
|
|
495
|
-
|
|
519
|
+
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
520
|
|
|
497
521
|
### The cookbook lane (dev-side, never shipped)
|
|
498
522
|
|
|
@@ -517,6 +541,8 @@ npm run demo:gc # headless GC-budget lane over the fleet core (maxMa
|
|
|
517
541
|
npm run demo:storm # headless dispose-storm retention lane (lite-leak, size 0)
|
|
518
542
|
```
|
|
519
543
|
|
|
544
|
+
Since 1.4.0 the console is the named consumer of `costOfInstance`: its shape-drift wall measures a real live `Entity` (its node count === the `EntityShape` sizing twin's) and the HUD reports one live fleet member's `costOfInstance` per HUD tick, so the live-vs-`costOf` delta -- a forced ceiling against the lazy live cost -- is visible on screen. The `EntityShape` twin stays for `capacityFor` sizing only (the world must be sized before it exists).
|
|
545
|
+
|
|
520
546
|
The `demo/` directory is dev-only -- it never enters `package.json` `files[]` and never ships to consumers.
|
|
521
547
|
|
|
522
548
|
---
|
|
@@ -547,6 +573,7 @@ The decorator vocabulary maps almost one-to-one; what changes is the lifetime st
|
|
|
547
573
|
| `@action m()` | `@batched m()` |
|
|
548
574
|
| `makeObservable(this, {...})` | `@reactiveHost` -- one wiring site, no mirror object to keep in sync |
|
|
549
575
|
| `reaction(...)` / `autorun(...)` | `@reactiveEffect m()` |
|
|
576
|
+
| `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
577
|
| 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
578
|
|
|
552
579
|
### From signal-utils
|
|
@@ -590,7 +617,7 @@ The cross-framework numbers behind this table are stamped in [`decisions/0006-ki
|
|
|
590
617
|
|
|
591
618
|
### The cookbook
|
|
592
619
|
|
|
593
|
-
[`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) collects eighteen composition recipes over the frozen
|
|
620
|
+
[`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) collects eighteen composition recipes over the frozen 22-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
621
|
|
|
595
622
|
---
|
|
596
623
|
|
package/SignalDecorators.d.ts
CHANGED
|
@@ -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}. */
|
|
@@ -374,6 +425,34 @@ export interface ReactiveCost {
|
|
|
374
425
|
*/
|
|
375
426
|
export function costOf(Factory: new (...args: any[]) => any): Readonly<ReactiveCost>;
|
|
376
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Measure the cost of ONE live, wired instance right now -- no probe, no
|
|
430
|
+
* construction, no ctor args, no registry pollution. Returns a per-call frozen
|
|
431
|
+
* `ReactiveCost` in costOf's exact shape, WALKED from the live graph:
|
|
432
|
+
* `nodes = 1 + signals + locals + forEachOwned(rootOf(vm))` (the deriveds and
|
|
433
|
+
* user effects the anchor adopted), and `links` is the un-deduped sum of
|
|
434
|
+
* forEachSource over the anchor, every owned node, and every signal/local box.
|
|
435
|
+
*
|
|
436
|
+
* THE LIVE-VS-PROBE CONTRACT. This number is the truth NOW. costOf forces every
|
|
437
|
+
* derived to report the constructed CEILING; costOfInstance reports what THIS
|
|
438
|
+
* instance costs at this moment, so an unforced lazy derived or an untaken
|
|
439
|
+
* dynamic branch shows FEWER links than costOf for the same shape until the graph
|
|
440
|
+
* is exercised. `nodes` matches regardless. Read every derived once and the two
|
|
441
|
+
* agree exactly. The delta is the feature, not a bug.
|
|
442
|
+
*
|
|
443
|
+
* The frozen result allocates by design, one object per call (UNCACHED -- a live
|
|
444
|
+
* graph mutates, so a cached number would lie). The walk needs no stats() ledger,
|
|
445
|
+
* so costOfInstance measures instances on registries where costOf fails closed.
|
|
446
|
+
*
|
|
447
|
+
* @param vm a live, wired reactive instance.
|
|
448
|
+
* @throws {ReactiveDisposedError} if `vm` was disposed or parked (a parked vm
|
|
449
|
+
* holds zero nodes; a silent `{ nodes: 0 }` would be indistinguishable from a
|
|
450
|
+
* bug, so both fail closed).
|
|
451
|
+
* @throws if `vm` is not wired yet, has no reactive plan, or exposes a prewired
|
|
452
|
+
* member slot.
|
|
453
|
+
*/
|
|
454
|
+
export function costOfInstance(vm: object): Readonly<ReactiveCost>;
|
|
455
|
+
|
|
377
456
|
/** A `[Factory, count]` pair for {@link capacityFor}. */
|
|
378
457
|
export type InventoryEntry = [new (...args: any[]) => any, number];
|
|
379
458
|
|
|
@@ -452,4 +531,4 @@ export class ReactiveDisposedError extends Error {
|
|
|
452
531
|
// --- Version ------------------------------------------------------------------
|
|
453
532
|
|
|
454
533
|
/** Package version. Kept in lockstep with package.json and llms.txt. */
|
|
455
|
-
export const VERSION: "1.
|
|
534
|
+
export const VERSION: "1.4.0";
|
package/SignalDecorators.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zakkster/lite-signal-decorators v1.
|
|
2
|
+
* @zakkster/lite-signal-decorators v1.4.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
|
|
@@ -45,6 +45,8 @@ import {
|
|
|
45
45
|
batch,
|
|
46
46
|
untrack,
|
|
47
47
|
stats,
|
|
48
|
+
forEachOwned,
|
|
49
|
+
forEachSource,
|
|
48
50
|
} from "@zakkster/lite-signal";
|
|
49
51
|
|
|
50
52
|
// --- Module state -------------------------------------------------------------
|
|
@@ -172,6 +174,14 @@ const DEFAULT_REG = Object.freeze({
|
|
|
172
174
|
// wiring/dispose paths use); costOf reads it here for the default registry,
|
|
173
175
|
// and every custom Registry from createRegistry() exposes it natively.
|
|
174
176
|
stats,
|
|
177
|
+
// `forEachOwned`/`forEachSource` join the facade the same way (S10): each
|
|
178
|
+
// registry owns its NODE_PTR symbol, so a handle is walkable ONLY by the
|
|
179
|
+
// registry that minted it. costOfInstance routes its walk through a plan's
|
|
180
|
+
// own `reg`, so the default-registry path needs these two here; a custom
|
|
181
|
+
// Registry from createRegistry() carries them natively. Not in REG_METHODS
|
|
182
|
+
// (the wiring/dispose paths never walk).
|
|
183
|
+
forEachOwned,
|
|
184
|
+
forEachSource,
|
|
175
185
|
});
|
|
176
186
|
|
|
177
187
|
// The 11 method names a valid Registry must expose (duck-check set, PD-11).
|
|
@@ -447,7 +457,7 @@ function throwReinitInitialsKey(ctorName, key, plan) {
|
|
|
447
457
|
function throwNoBox(ctorName, key, kind) {
|
|
448
458
|
const what = kind === "effect" ? "@reactiveEffect" : "@batched";
|
|
449
459
|
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.`,
|
|
460
|
+
`${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
461
|
);
|
|
452
462
|
}
|
|
453
463
|
|
|
@@ -1927,6 +1937,111 @@ export function rootOf(vm) {
|
|
|
1927
1937
|
return a;
|
|
1928
1938
|
}
|
|
1929
1939
|
|
|
1940
|
+
// --- Reactive walk & snapshot (S9; cold / opt-in) -----------------------------
|
|
1941
|
+
|
|
1942
|
+
// Hoisted kind literals -- passed by forEachReactive so a walk carries zero
|
|
1943
|
+
// per-visit bytes beyond its four scalar args (no per-member string allocation).
|
|
1944
|
+
const KIND_SIGNAL = "signal";
|
|
1945
|
+
const KIND_LOCAL = "local";
|
|
1946
|
+
const KIND_DERIVED = "derived";
|
|
1947
|
+
|
|
1948
|
+
function throwForEachFn() {
|
|
1949
|
+
throw new TypeError(
|
|
1950
|
+
`${ERR}forEachReactive(vm, fn) -- fn must be a function; it is called fn(key, box, kind, arg) once per reactive member.`,
|
|
1951
|
+
);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/**
|
|
1955
|
+
* Visit every value-bearing reactive member of `vm` in PLAN order -- all signals,
|
|
1956
|
+
* then all @localTo locals, then all deriveds; each group declaration-ordered and
|
|
1957
|
+
* ancestor-first -- invoking `fn(key, box, kind, arg)` per member and returning
|
|
1958
|
+
* the visit count. `box` is the live SignalBox/ComputedBox (exactly what boxOf
|
|
1959
|
+
* returns); `kind` is the literal "signal" | "local" | "derived". @reactiveEffect
|
|
1960
|
+
* and @batched members are EXCLUDED (non-value-bearing; boxOf refuses them). The
|
|
1961
|
+
* `arg` pass-through threads caller state without a closure, so the walk is
|
|
1962
|
+
* zero-allocation per call and per visit.
|
|
1963
|
+
*
|
|
1964
|
+
* @throws {TypeError} if `fn` is not a function.
|
|
1965
|
+
* @throws {ReactiveDisposedError} if the instance was disposed or parked.
|
|
1966
|
+
* @throws if `vm` is not wired yet, or is not a reactive instance.
|
|
1967
|
+
*/
|
|
1968
|
+
export function forEachReactive(vm, fn, arg) {
|
|
1969
|
+
if (typeof fn !== "function") throwForEachFn();
|
|
1970
|
+
const plan = planOf(vm);
|
|
1971
|
+
if (plan === undefined) throwNoPlan("forEachReactive");
|
|
1972
|
+
const a = vm[ANCHOR];
|
|
1973
|
+
if (a === undefined) throwNotWired("forEachReactive");
|
|
1974
|
+
if (a === DISPOSED) throw new ReactiveDisposedError(plan.ctorName, "<root>");
|
|
1975
|
+
if (a === PARKED) throw new ReactiveDisposedError(plan.ctorName, "<root>", true);
|
|
1976
|
+
let n = 0;
|
|
1977
|
+
const sigs = plan.signals;
|
|
1978
|
+
for (let i = 0; i < sigs.length; i++) {
|
|
1979
|
+
const r = sigs[i];
|
|
1980
|
+
const h = vm[r.slot];
|
|
1981
|
+
if (h !== undefined && h[NONLIVE] === "prewired") throwPrewiredMember(plan.ctorName, r.key);
|
|
1982
|
+
fn(r.key, h, KIND_SIGNAL, arg);
|
|
1983
|
+
n++;
|
|
1984
|
+
}
|
|
1985
|
+
const locs = plan.locals;
|
|
1986
|
+
for (let i = 0; i < locs.length; i++) {
|
|
1987
|
+
const r = locs[i];
|
|
1988
|
+
const h = vm[r.slot];
|
|
1989
|
+
if (h !== undefined && h[NONLIVE] === "prewired") throwPrewiredMember(plan.ctorName, r.key);
|
|
1990
|
+
fn(r.key, h, KIND_LOCAL, arg);
|
|
1991
|
+
n++;
|
|
1992
|
+
}
|
|
1993
|
+
const ders = plan.deriveds;
|
|
1994
|
+
for (let i = 0; i < ders.length; i++) {
|
|
1995
|
+
const r = ders[i];
|
|
1996
|
+
const h = vm[r.slot];
|
|
1997
|
+
if (h !== undefined && h[NONLIVE] === "prewired") throwPrewiredMember(plan.ctorName, r.key);
|
|
1998
|
+
fn(r.key, h, KIND_DERIVED, arg);
|
|
1999
|
+
n++;
|
|
2000
|
+
}
|
|
2001
|
+
return n;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// snapshotOf's per-member visitor -- the forEachReactive walk contract
|
|
2005
|
+
// (key, box, kind, arg). snapshotOf IS forEachReactive's named in-package
|
|
2006
|
+
// consumer: the fill is ROUTED through the walk (0009 candidate 2 / 0013 (c)
|
|
2007
|
+
// admission ground), not a private duplicate. The carrier `arg` threads both the
|
|
2008
|
+
// instance and the output object so the visitor stays a hoisted, closure-free
|
|
2009
|
+
// function. The read is the ACCESSOR vm[key] (PD-62), NOT box.get -- so @localTo
|
|
2010
|
+
// compare-on-read and derived compute stay honest; `box`/`kind` are unused here,
|
|
2011
|
+
// which the walk contract permits (a consumer reads only the fields it needs).
|
|
2012
|
+
function snapshotVisit(key, box, kind, arg) {
|
|
2013
|
+
arg.out[key] = arg.vm[key];
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
/**
|
|
2017
|
+
* Return a plain `{}` snapshot of every value-bearing reactive member of `vm` --
|
|
2018
|
+
* signals, @localTo locals, and deriveds -- keyed by member key (symbol keys
|
|
2019
|
+
* included), each value read through the ACCESSOR `vm[key]` (so @localTo
|
|
2020
|
+
* compare-on-read and derived compute stay honest). SHALLOW by design: a nested
|
|
2021
|
+
* reactive VM is copied by reference, never recursed. The whole read pass runs
|
|
2022
|
+
* under ONE untrack thunk when a tracking scope is active (the makeLocalSet
|
|
2023
|
+
* idiom), so calling snapshotOf inside an effect does NOT subscribe the effect to
|
|
2024
|
+
* every member. The returned object allocates by design -- this is a cold
|
|
2025
|
+
* introspection call, never a gated hot path.
|
|
2026
|
+
*
|
|
2027
|
+
* @throws {ReactiveDisposedError} if the instance was disposed or parked.
|
|
2028
|
+
* @throws if `vm` is not wired yet, or is not a reactive instance.
|
|
2029
|
+
*/
|
|
2030
|
+
export function snapshotOf(vm) {
|
|
2031
|
+
const plan = planOf(vm);
|
|
2032
|
+
if (plan === undefined) throwNoPlan("snapshotOf");
|
|
2033
|
+
const a = vm[ANCHOR];
|
|
2034
|
+
if (a === undefined) throwNotWired("snapshotOf");
|
|
2035
|
+
if (a === DISPOSED) throw new ReactiveDisposedError(plan.ctorName, "<root>");
|
|
2036
|
+
if (a === PARKED) throw new ReactiveDisposedError(plan.ctorName, "<root>", true);
|
|
2037
|
+
const out = {};
|
|
2038
|
+
const carrier = { vm, out }; // allocates by design (cold call)
|
|
2039
|
+
const reg = plan.reg;
|
|
2040
|
+
if (reg.isTracking()) reg.untrack(() => forEachReactive(vm, snapshotVisit, carrier));
|
|
2041
|
+
else forEachReactive(vm, snapshotVisit, carrier);
|
|
2042
|
+
return out;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
1930
2045
|
// --- Introspection & audit (S4; all cold / opt-in) ----------------------------
|
|
1931
2046
|
|
|
1932
2047
|
function throwCostFactory() {
|
|
@@ -2027,6 +2142,122 @@ export function costOf(Factory) {
|
|
|
2027
2142
|
return result;
|
|
2028
2143
|
}
|
|
2029
2144
|
|
|
2145
|
+
// Module-level walk accumulators for costOfInstance. forEachOwned/forEachSource
|
|
2146
|
+
// call fn(descriptor) with NO carrier arg, so the visitor cannot thread state
|
|
2147
|
+
// through a parameter the way forEachReactive's `arg` does. Reusing three module
|
|
2148
|
+
// slots (never a per-call closure) keeps the frozen result the ONLY allocation
|
|
2149
|
+
// (PD-69). Non-reentrant by construction: a cost walk never re-enters
|
|
2150
|
+
// costOfInstance, so the single-threaded ESM model makes the shared slots safe.
|
|
2151
|
+
let COST_INSTANCE_REG = null;
|
|
2152
|
+
let COST_INSTANCE_OWNED = 0;
|
|
2153
|
+
let COST_INSTANCE_LINKS = 0;
|
|
2154
|
+
|
|
2155
|
+
// Tally one source edge (called per forEachSource visit across anchor, owned
|
|
2156
|
+
// nodes, and signal/local boxes).
|
|
2157
|
+
function costInstanceLinkVisit(node) {
|
|
2158
|
+
COST_INSTANCE_LINKS++;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// Tally one owned node (a derived or user effect adopted by the anchor) and fold
|
|
2162
|
+
// its source edges into the link total in the same pass.
|
|
2163
|
+
function costInstanceOwnedVisit(node) {
|
|
2164
|
+
COST_INSTANCE_OWNED++;
|
|
2165
|
+
COST_INSTANCE_REG.forEachSource(node, costInstanceLinkVisit);
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
/**
|
|
2169
|
+
* Measure the cost of ONE live, wired instance right now -- no probe, no
|
|
2170
|
+
* construction, no ctor args, no registry pollution. Returns a per-call frozen
|
|
2171
|
+
* `{ nodes, links, signals, locals, deriveds, effects }` in costOf's exact shape.
|
|
2172
|
+
* `nodes` is WALKED: 1 (the anchor) + plan.signals.length + plan.locals.length +
|
|
2173
|
+
* every child forEachOwned(rootOf(vm)) yields (the deriveds and user effects the
|
|
2174
|
+
* anchor adopted -- signal/local boxes are built pre-anchor and unadopted, so
|
|
2175
|
+
* they are never owned). `links` is the sum of forEachSource over the anchor,
|
|
2176
|
+
* every owned node, and every signal/local box, WITHOUT dedupe -- one edge per
|
|
2177
|
+
* observer, matching costOf's activeLinks delta. Kind counts are read from the
|
|
2178
|
+
* plan arrays, never walked.
|
|
2179
|
+
*
|
|
2180
|
+
* THE LIVE-VS-PROBE CONTRACT. This number is the truth NOW. costOf constructs a
|
|
2181
|
+
* throwaway probe and FORCES every derived (:2079) to report the constructed
|
|
2182
|
+
* CEILING -- "what will an instance of this class cost". costOfInstance reports
|
|
2183
|
+
* what THIS instance costs at this moment: an unforced lazy derived and an
|
|
2184
|
+
* untaken dynamic branch have formed no links yet, so `links` reads BELOW
|
|
2185
|
+
* costOf's for the same shape until the graph is exercised. `nodes` matches
|
|
2186
|
+
* regardless (owned children exist whether or not their links have formed). Read
|
|
2187
|
+
* every derived once and the two agree exactly (A1 parity). The delta is the
|
|
2188
|
+
* feature, not a bug -- fewer links means the instance has not paid for a branch
|
|
2189
|
+
* it has not taken.
|
|
2190
|
+
*
|
|
2191
|
+
* ALLOCATION HONESTY. The frozen result allocates by design, one object per call,
|
|
2192
|
+
* exactly like snapshotOf -- this is a cold introspection call, never a gated hot
|
|
2193
|
+
* path (PD-69). There is no out-param variant; no consumer needs one. The walk
|
|
2194
|
+
* itself allocates nothing (module-slot visitors, no per-call closure).
|
|
2195
|
+
*
|
|
2196
|
+
* UNCACHED (PD-70). costOf caches per class because a class shape is frozen at
|
|
2197
|
+
* decoration; a live instance graph MUTATES (a derived forces, a branch flips),
|
|
2198
|
+
* so a cached number would lie. Every call re-walks.
|
|
2199
|
+
*
|
|
2200
|
+
* WORKS WHERE costOf CANNOT (PD-72). The walk needs no stats() ledger, so
|
|
2201
|
+
* costOfInstance measures an instance on a hand-rolled registry that carries the
|
|
2202
|
+
* introspection walkers but not stats -- exactly the case costOf fails closed on
|
|
2203
|
+
* (:2049).
|
|
2204
|
+
*
|
|
2205
|
+
* @throws {ReactiveDisposedError} if the instance was disposed or parked -- a
|
|
2206
|
+
* parked vm holds ZERO nodes, and a silent `{ nodes: 0 }` is indistinguishable
|
|
2207
|
+
* from a bug, so both states fail closed (PD-71).
|
|
2208
|
+
* @throws if `vm` is not wired yet, has no reactive plan, or exposes a prewired
|
|
2209
|
+
* member slot.
|
|
2210
|
+
*/
|
|
2211
|
+
export function costOfInstance(vm) {
|
|
2212
|
+
const plan = planOf(vm);
|
|
2213
|
+
if (plan === undefined) throwNoPlan("costOfInstance");
|
|
2214
|
+
const a = vm[ANCHOR];
|
|
2215
|
+
if (a === undefined) throwNotWired("costOfInstance");
|
|
2216
|
+
if (a === DISPOSED) throw new ReactiveDisposedError(plan.ctorName, "<root>");
|
|
2217
|
+
if (a === PARKED) throw new ReactiveDisposedError(plan.ctorName, "<root>", true);
|
|
2218
|
+
const reg = plan.reg;
|
|
2219
|
+
COST_INSTANCE_REG = reg;
|
|
2220
|
+
COST_INSTANCE_OWNED = 0;
|
|
2221
|
+
COST_INSTANCE_LINKS = 0;
|
|
2222
|
+
// Owned nodes = deriveds + user effects the anchor adopted; each contributes
|
|
2223
|
+
// its source edges to the link tally as it is visited. Signals/locals are
|
|
2224
|
+
// built pre-anchor (unadopted), so forEachOwned never yields them (:1234-1248).
|
|
2225
|
+
reg.forEachOwned(a, costInstanceOwnedVisit);
|
|
2226
|
+
// The anchor's own source edges.
|
|
2227
|
+
reg.forEachSource(a, costInstanceLinkVisit);
|
|
2228
|
+
// Signal + local boxes are not owned -- read each from its slot (the walker
|
|
2229
|
+
// idiom from forEachReactive) and fold its source edges in. A prewired slot
|
|
2230
|
+
// is impossible past the wired guard above, but the check fails closed if a
|
|
2231
|
+
// partially-built instance is ever measured.
|
|
2232
|
+
const sigs = plan.signals;
|
|
2233
|
+
for (let i = 0; i < sigs.length; i++) {
|
|
2234
|
+
const h = vm[sigs[i].slot];
|
|
2235
|
+
if (h !== undefined && h[NONLIVE] === "prewired") throwPrewiredMember(plan.ctorName, sigs[i].key);
|
|
2236
|
+
reg.forEachSource(h, costInstanceLinkVisit);
|
|
2237
|
+
}
|
|
2238
|
+
const locs = plan.locals;
|
|
2239
|
+
for (let i = 0; i < locs.length; i++) {
|
|
2240
|
+
const h = vm[locs[i].slot];
|
|
2241
|
+
if (h !== undefined && h[NONLIVE] === "prewired") throwPrewiredMember(plan.ctorName, locs[i].key);
|
|
2242
|
+
reg.forEachSource(h, costInstanceLinkVisit);
|
|
2243
|
+
}
|
|
2244
|
+
const sig = sigs.length;
|
|
2245
|
+
const loc = locs.length;
|
|
2246
|
+
const der = plan.deriveds.length;
|
|
2247
|
+
const eff = plan.effects.length;
|
|
2248
|
+
const nodes = 1 + sig + loc + COST_INSTANCE_OWNED;
|
|
2249
|
+
const links = COST_INSTANCE_LINKS;
|
|
2250
|
+
COST_INSTANCE_REG = null; // drop the registry ref (cold)
|
|
2251
|
+
return Object.freeze({
|
|
2252
|
+
nodes: nodes,
|
|
2253
|
+
links: links,
|
|
2254
|
+
signals: sig,
|
|
2255
|
+
locals: loc,
|
|
2256
|
+
deriveds: der,
|
|
2257
|
+
effects: eff,
|
|
2258
|
+
});
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2030
2261
|
function throwCapInventory() {
|
|
2031
2262
|
throw new TypeError(
|
|
2032
2263
|
`${ERR}capacityFor(inventory) -- inventory must be a non-empty array of [Factory, count] pairs.`,
|
|
@@ -2254,4 +2485,4 @@ export function auditReactive(on) {
|
|
|
2254
2485
|
// --- Version ------------------------------------------------------------------
|
|
2255
2486
|
|
|
2256
2487
|
/** Package version. Kept in lockstep with package.json and llms.txt. */
|
|
2257
|
-
export const VERSION = "1.
|
|
2488
|
+
export const VERSION = "1.4.0";
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zakkster/lite-signal-decorators
|
|
2
2
|
|
|
3
|
-
VERSION 1.
|
|
3
|
+
VERSION 1.4.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 (
|
|
25
|
+
## Exports (22)
|
|
26
26
|
|
|
27
27
|
- `reactive` -- `@reactive accessor x = v` (bare) or `@reactive({ equals })`
|
|
28
28
|
(factory). Declares a per-instance signal.
|
|
@@ -89,11 +89,49 @@ 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
|
|
95
117
|
guesses. `nodes` = P+L+D+E+1; `links` = the first-full-read link count. See
|
|
96
118
|
"Introspection & audit".
|
|
119
|
+
- `costOfInstance(vm) -> { nodes, links, signals, locals, deriveds, effects }` --
|
|
120
|
+
the LIVE measured cost of one wired instance right now, walked from its own
|
|
121
|
+
graph. The delta from `costOf` IS the feature: `costOf` answers "what will an
|
|
122
|
+
instance of this class cost" (it forces every derived to the constructed
|
|
123
|
+
ceiling), `costOfInstance` answers "what does THIS instance cost right now" --
|
|
124
|
+
an unforced lazy derived or an untaken dynamic branch has formed no links yet,
|
|
125
|
+
so `links` reads BELOW `costOf` for the same shape until the graph is
|
|
126
|
+
exercised; `nodes` matches regardless. Read every derived once and the two
|
|
127
|
+
agree exactly (forced parity === costOf). UNCACHED by design -- a live graph
|
|
128
|
+
mutates, so a cached number would lie; every call re-walks. Allocates its
|
|
129
|
+
frozen result by design (one object per call, ~71 B/op, cold like snapshotOf --
|
|
130
|
+
reported, never gated). Needs no stats() ledger, so it measures instances on
|
|
131
|
+
hand-rolled registries where `costOf` fails closed. Fails closed on a
|
|
132
|
+
disposed/parked instance with a NAMED throw -- a parked vm holds zero nodes and
|
|
133
|
+
a silent `{ nodes: 0 }` is indistinguishable from a bug -- and on unwired,
|
|
134
|
+
no-plan, or prewired-member values. See "Introspection & audit".
|
|
97
135
|
- `capacityFor(inventory, { headroom }?) -> RegistryConfig` -- size a
|
|
98
136
|
`createRegistry` config from `[Factory, count]` pairs. Nodes exact, links x
|
|
99
137
|
`headroom` (default 1). Fail-closed inventory validation.
|
|
@@ -104,7 +142,7 @@ slot for a poison handle, so any later read/write throws a named
|
|
|
104
142
|
`FinalizationRegistry` reports any instance GC'd without `disposeReactive`.
|
|
105
143
|
- `ReactiveDisposedError` -- `extends Error`, `name` `"ReactiveDisposedError"`,
|
|
106
144
|
fields `className` and `key`.
|
|
107
|
-
- `VERSION` -- `"1.
|
|
145
|
+
- `VERSION` -- `"1.4.0"`.
|
|
108
146
|
|
|
109
147
|
## Registry law (one registry per host chain)
|
|
110
148
|
|
|
@@ -245,7 +283,12 @@ runtime exports plus `costOf`, `capacityFor`, `enableLabels`, `labelOf`, and
|
|
|
245
283
|
0.3.0). 1.2.0 adds `localTo` (decisions/0013 strategic track, 0014 contract) as
|
|
246
284
|
an additive MINOR -> 19 exports; the 1.0.0 canon (`makeGet`/`makeSet`/
|
|
247
285
|
`makeDerivedGet`) stays byte-identical -- @localTo ships its own accessor bodies
|
|
248
|
-
and pays its own measured cost.
|
|
286
|
+
and pays its own measured cost. 1.3.0 adds `forEachReactive` + `snapshotOf`
|
|
287
|
+
(decisions/0013 ladder) as an additive MINOR -> 21 exports; `snapshotOf` is the
|
|
288
|
+
named in-package consumer that admits `forEachReactive` under the 0009 bar, and
|
|
289
|
+
the 1.0.0 canon stays byte-identical. 1.4.0 adds `costOfInstance` (decisions/0013
|
|
290
|
+
criterion (b); the demo is the named consumer) as an additive MINOR -> 22
|
|
291
|
+
exports; the 1.0.0 canon stays byte-identical. The semver promise from here: any change to an existing export's
|
|
249
292
|
signature or behavior is a MAJOR, recorded in a decision file; new exports are
|
|
250
293
|
minors; the hot accessor canon (`makeGet`/`makeSet`) does not move without a
|
|
251
294
|
major. Also present since
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-signal-decorators",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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",
|