@zakkster/lite-signal-decorators 0.3.0 → 1.0.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 +158 -0
- package/README.md +117 -14
- package/SignalDecorators.d.ts +94 -1
- package/SignalDecorators.js +369 -6
- package/llms.txt +74 -8
- package/package.json +11 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,162 @@ 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.0.0] - 2026-08-29
|
|
8
|
+
|
|
9
|
+
The 1.0 release: docs freeze, the fleet playground, and the standing pre-publish
|
|
10
|
+
gate. Zero runtime changes -- the 16-export surface ships byte-identical to
|
|
11
|
+
0.4.0 (the hot accessor canon is review-diffed to a zero-line diff), and this
|
|
12
|
+
release freezes that surface under semver: any later signature change is a major
|
|
13
|
+
recorded in a decision file. Dist-tag `latest`. Stage gate, measured at
|
|
14
|
+
closeout by the full section-10 chain (archived verbatim below): 214/214 tests
|
|
15
|
+
on both lanes; torture 15 scenarios (13 pass + the two forward-compat scenarios
|
|
16
|
+
legitimately skipping under the installed 1.5.0 peer) with 15/15 sabotage
|
|
17
|
+
controls breaking as required; the peer-preview lane SUITE-GREEN per tag
|
|
18
|
+
(15/15, forward scenarios RUNNING) against lite-signal 1.9.0-preview.6 AND
|
|
19
|
+
1.9.0-canary.1; the bench sink self-test catching its sabotaged adapter; pack 7
|
|
20
|
+
files, no `demo/` or `Publications/`.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- The fleet-playground demo (`demo/`, dev-only, never in `files[]`): a
|
|
25
|
+
single-file instrument console over a two-plane architecture -- Plane A a
|
|
26
|
+
decorated entity-VM fleet in a `capacityFor`-sized custom registry with
|
|
27
|
+
enforced ceilings, Plane B the telemetry signals in the default registry
|
|
28
|
+
driving five `@zakkster/lite-watch-ex` watchers (`watchUntil`,
|
|
29
|
+
`pausableWatch`, `watchChanged`, `watchMany`, `watchPrevious`). The DOM-free
|
|
30
|
+
core runs headless under the same GC-budget and dispose-storm gates the
|
|
31
|
+
library uses; the PD-29 registry wall is proven by a stats-delta (no watcher
|
|
32
|
+
ever forms an edge into the custom-registry fleet), not by assertion.
|
|
33
|
+
- README migration section: MobX 7 and signal-utils translation tables,
|
|
34
|
+
including the closing row MobX cannot write -- `disposeReactive(vm)` is one
|
|
35
|
+
idempotent, node-exact call after which every later touch throws by name.
|
|
36
|
+
- README emit-support matrix, generated from `test/fixtures/hashes.json` (9
|
|
37
|
+
entries) under drift-proof `EMIT-MATRIX` markers; `04-fixture-freshness`
|
|
38
|
+
asserts the README block equals the generator, so a re-emit that changes a
|
|
39
|
+
byte is loud, not silent.
|
|
40
|
+
- `llms.txt` per-feature forward floors documented alongside the peer range:
|
|
41
|
+
1.6.0 for `createScope`, 1.9.0 for `Symbol.dispose`; the peer range floor
|
|
42
|
+
stays `>=1.5.0 <2.0.0`.
|
|
43
|
+
- `gate` script (`test/gate.mjs`): the section-10 pre-publish chain as captured
|
|
44
|
+
child processes -- fixtures, test, test:gc, torture (semantic + soak),
|
|
45
|
+
the TORTURE_BREAK control sweep, the non-blocking peer-preview lane,
|
|
46
|
+
the bench sink self-test, and `npm pack --dry-run` asserting exactly 7 files.
|
|
47
|
+
Every blocking step exits 0 or the gate exits non-zero; peer-preview is
|
|
48
|
+
reported, never gated.
|
|
49
|
+
- `Publications/` (dev-only, never shipped): per-channel release drafts and the
|
|
50
|
+
GitHub release notes, centered on the class-reactivity benchmark methodology
|
|
51
|
+
with an explicit invitation for competitor adapter PRs.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- Docs re-stamped to reality: Testing sections now read 214 tests across
|
|
56
|
+
fourteen files and 15 torture scenarios (13 run + 2 floor-gated skips), and
|
|
57
|
+
every numeric claim traces to a live stamp (`bench/results.txt`,
|
|
58
|
+
`decisions/0006`, or a gate tail). Version references across README and
|
|
59
|
+
llms.txt move to 1.0.0.
|
|
60
|
+
- The 16-export surface is frozen under semver at 1.0.0. The 0.x "frozen for
|
|
61
|
+
0.x" note is retired in favor of the semver promise.
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- (Found in review, pass 1 charge C4) An inline-style violation in the demo
|
|
66
|
+
HTML -- a non-custom-property `style=` attribute against the demo CSS law --
|
|
67
|
+
was hoisted into the stylesheet. Custom-property `style="--var: value"` hooks
|
|
68
|
+
remain, per the law.
|
|
69
|
+
- (Found in the planner audit) The Testing and gates sections carried
|
|
70
|
+
0.2.0-era numbers (171 tests, "12/12 scenarios", an 11-row file table) two
|
|
71
|
+
stages stale, in violation of the no-claim-without-a-stamp rule. Re-stamped
|
|
72
|
+
against the 1.0.0 tree.
|
|
73
|
+
|
|
74
|
+
### Gate output (section-10 chain, archived verbatim)
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
fixtures OK exit 0 -- emit fixtures regenerated
|
|
78
|
+
test OK exit 0 -- 214 pass / 0 fail
|
|
79
|
+
test:gc OK exit 0 -- 214 pass / 0 fail
|
|
80
|
+
torture OK exit 0 -- 13 passed, 2 skipped, 0 warned, 0 failed in 32.7s
|
|
81
|
+
torture:controls OK exit 0 -- 15 passed, 0 skipped, 0 warned, 0 failed in 1.9s
|
|
82
|
+
torture:peer-preview REPORTED NON-BLOCKING -- lane completed (exit 0) [preview 1.9.0-preview.6 SUITE-GREEN 15 passed, 0 skipped, 0 warned, 0 failed; canary 1.9.0-canary.1 SUITE-GREEN 15 passed, 0 skipped, 0 warned, 0 failed]
|
|
83
|
+
bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
|
|
84
|
+
pack OK exit 0 -- 7/7 files, no demo/ no Publications/
|
|
85
|
+
----------------------------------------------------------------------
|
|
86
|
+
GATE PASS -- 7 blocking steps + 1 non-blocking (peer-preview)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## [0.4.0] - 2026-08-26
|
|
90
|
+
|
|
91
|
+
The introspection release: the surface grows 11 -> 16, every addition cold-path
|
|
92
|
+
or opt-in, the hot accessor canon byte-identical to 0.3.0 (review-diffed
|
|
93
|
+
against the published tarball). Stage gate, measured at closeout: 213/213
|
|
94
|
+
tests on both lanes; torture 15 scenarios (13 pass + the two forward-compat
|
|
95
|
+
scenarios legitimately skipping under the installed 1.5.0 peer) with 15/15
|
|
96
|
+
sabotage controls breaking as required; the peer-preview lane SUITE-GREEN
|
|
97
|
+
(15/15, forward scenarios RUNNING) against lite-signal 1.9.0-preview.6 AND
|
|
98
|
+
1.9.0-canary.1; pack 7 files.
|
|
99
|
+
|
|
100
|
+
### Added
|
|
101
|
+
|
|
102
|
+
- `costOf(Factory)` -- the measured, settled per-instance cost on the class's
|
|
103
|
+
bound registry: `{ nodes, links, signals, deriveds, effects }`, frozen and
|
|
104
|
+
cached per class. Quiet-required, floor-verified, DOUBLE-probed with
|
|
105
|
+
identical-deltas-or-throw: an inconclusive probe (a derived whose read set
|
|
106
|
+
changes between runs, a polluted registry) throws a named error, never
|
|
107
|
+
guesses. `nodes` reproduces the 0002 Q3 grid exactly (P + D + E + 1);
|
|
108
|
+
`links` is the first-full-read count.
|
|
109
|
+
- `capacityFor(inventory, { headroom }?)` -- sizes a ready `createRegistry`
|
|
110
|
+
config from `[Factory, count]` pairs: nodes exact, links x `headroom`
|
|
111
|
+
floored at the engine minimum, `prealloc: "eager"`,
|
|
112
|
+
`onCapacityExceeded: "throw"`; fail-closed inventory and options
|
|
113
|
+
validation. The link-headroom policy -- nodes exact; fixed-shape deriveds
|
|
114
|
+
provision exactly at first-full-read; branchy-read deriveds fail LOUD with
|
|
115
|
+
the `headroom` knob as the documented escape -- is recorded with its
|
|
116
|
+
measured evidence in `decisions/0007-capacity-policy.md`, closing 0002's
|
|
117
|
+
open question.
|
|
118
|
+
- `enableLabels(on)` / `labelOf(idOrHandle, registry?)` -- opt-in devtools
|
|
119
|
+
identity (default OFF): per-registry `nodeId -> "Class.prop" /
|
|
120
|
+
"Class#method" / "Class@anchor"` maps, per-class shared label strings,
|
|
121
|
+
dispose unregisters, misses return `undefined`. A feature-detected
|
|
122
|
+
integration test walks lite-devtools `graph()`/`toTree` from `rootOf(vm)`
|
|
123
|
+
and resolves every walked node. The one-line devtools `labelResolver`
|
|
124
|
+
upstream proposal is recorded in `decisions/0008-introspection.md`.
|
|
125
|
+
- `auditReactive(on)` -- opt-in leak auditor (default OFF): a lazily-created
|
|
126
|
+
`FinalizationRegistry` reports any instance collected without
|
|
127
|
+
`disposeReactive`, naming class and shape; it holds no instance references
|
|
128
|
+
itself, unregisters on dispose, and registers nothing while off (proven by
|
|
129
|
+
a child-process `--expose-gc` test).
|
|
130
|
+
- Forward-compat torture: `scope-adoption` (floor 1.6.0) and `using-dispose`
|
|
131
|
+
(floor 1.9.0), written against the REAL probed future engine surfaces
|
|
132
|
+
(1.6.0's `createScope` adoption; 1.9.0's native `[Symbol.dispose]` on
|
|
133
|
+
handles) via a typeof-only feature probe (`test/shared/peer-probe.mjs` --
|
|
134
|
+
never version parsing). Their sabotage controls lie about the probe, so
|
|
135
|
+
they fail loudly even under the current peer. Plus `torture:peer-preview`:
|
|
136
|
+
a scratch-install lane that runs the whole suite against the peer's
|
|
137
|
+
`preview` and `canary` dist-tags and reports per-tag verdicts.
|
|
138
|
+
- Tests: `12-accounting` (11), `13-labels-audit` (10, incl. the child-process
|
|
139
|
+
audit fixture), `14-qa-s4-boundary` (21 adversarial pins), and two
|
|
140
|
+
capacityFor round-trip lanes (node-bound and link-bound) in
|
|
141
|
+
capacity-torture. Suite 171 -> 213. Dev-only devDependency:
|
|
142
|
+
`@zakkster/lite-devtools` (integration walk).
|
|
143
|
+
|
|
144
|
+
### Fixed
|
|
145
|
+
|
|
146
|
+
- (Found in review) A registry passing the 11-method duck-check but lacking
|
|
147
|
+
`stats()` -- constructible from the public surface -- made `costOf` and
|
|
148
|
+
`capacityFor` die with a raw `TypeError` instead of a named error. Now a
|
|
149
|
+
named, ERR-prefixed throw explains that probing needs a `createRegistry()`
|
|
150
|
+
registry with its stats ledger. The falsified "no reachable raw path"
|
|
151
|
+
claim in 0008 is amended with the counterexample preserved, rejection-
|
|
152
|
+
history style.
|
|
153
|
+
- (Found in QA) `capacityFor`'s options guard borrowed decorator-flavored
|
|
154
|
+
usage wording for a plain function call, and accepted an ARRAY as an
|
|
155
|
+
options bag; it now throws the call-form message
|
|
156
|
+
(`options must be a plain object like { headroom: 1.25 }`) for both.
|
|
157
|
+
`null` still means "omitted".
|
|
158
|
+
- (Found writing tests) `capacityFor` on a signals-only inventory produced
|
|
159
|
+
`maxLinks: 0`, which `createRegistry` rejects; the returned links are now
|
|
160
|
+
floored at the engine minimum of 1 so every valid inventory yields a
|
|
161
|
+
constructible config.
|
|
162
|
+
|
|
7
163
|
## [0.3.0] - 2026-08-26
|
|
8
164
|
|
|
9
165
|
The class-reactivity benchmark release. Zero runtime changes: the 11-export
|
|
@@ -202,6 +358,8 @@ Initial release -- the decorator core.
|
|
|
202
358
|
- Torture skeleton (`@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`):
|
|
203
359
|
retention, conservation, lifecycle, and zero-GC lanes.
|
|
204
360
|
|
|
361
|
+
[1.0.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v1.0.0
|
|
362
|
+
[0.4.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.4.0
|
|
205
363
|
[0.3.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.3.0
|
|
206
364
|
[0.2.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.2.0
|
|
207
365
|
[0.2.0-preview.1]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.2.0-preview.1
|
package/README.md
CHANGED
|
@@ -49,6 +49,7 @@ No base class to extend. No `makeObservable(this, {...})` mirror object to keep
|
|
|
49
49
|
- [Design decisions worth knowing](#design-decisions-worth-knowing)
|
|
50
50
|
- [Testing (for clients & QA)](#testing-for-clients--qa)
|
|
51
51
|
- [Compatibility](#compatibility)
|
|
52
|
+
- [Migrating from MobX 7 & signal-utils](#migrating-from-mobx-7--signal-utils)
|
|
52
53
|
- [What this is not](#what-this-is-not)
|
|
53
54
|
- [Ecosystem](#ecosystem)
|
|
54
55
|
- [FAQ](#faq) - [License](#license)
|
|
@@ -240,12 +241,23 @@ Symbol keys work (`Reflect.ownKeys`). A spec key colliding with an own property
|
|
|
240
241
|
| `boxOf` | `(vm, key) => SignalBox \| ComputedBox` | The live engine box behind a `@reactive`/`@derived` member -- `.peek()`, `.subscribe()`, raw interop. Unknown key: named throw with a did-you-mean. After dispose: `ReactiveDisposedError`. |
|
|
241
242
|
| `rootOf` | `(vm) => NodeDescriptor` | The instance's anchor descriptor -- feeds `forEachOwned` and lite-devtools. Throws `ReactiveDisposedError` after dispose. |
|
|
242
243
|
|
|
244
|
+
### Introspection & audit (1.0.0)
|
|
245
|
+
|
|
246
|
+
| Export | Signature | Behavior |
|
|
247
|
+
|---|---|---|
|
|
248
|
+
| `costOf` | `(Factory) => { nodes, links, signals, deriveds, effects }` | The measured, settled per-instance cost, probed on the class's bound registry (frozen result, cached per class). Double-probed: an inconclusive or polluted probe THROWS -- never a guess. `nodes` is exactly P + D + E + 1; `links` is the first-full-read link count. |
|
|
249
|
+
| `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). |
|
|
250
|
+
| `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. |
|
|
251
|
+
| `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. |
|
|
252
|
+
|
|
253
|
+
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).
|
|
254
|
+
|
|
243
255
|
### Errors & constants
|
|
244
256
|
|
|
245
257
|
| Export | Value |
|
|
246
258
|
|---|---|
|
|
247
259
|
| `ReactiveDisposedError` | `extends Error`; `name: "ReactiveDisposedError"`; fields `className`, `key`. Thrown on ANY touch of a disposed instance's surface. |
|
|
248
|
-
| `VERSION` | `"0.
|
|
260
|
+
| `VERSION` | `"1.0.0"` |
|
|
249
261
|
|
|
250
262
|
### The rejection matrix
|
|
251
263
|
|
|
@@ -294,6 +306,27 @@ console.log(world.stats().activeNodes); // 0 -- every node returned to the pool
|
|
|
294
306
|
|
|
295
307
|
The default registry never notices any of it: bound-registry churn leaves outside `stats()` frozen (torture-pinned). And because `boxOf` returns the *engine's* box, everything lite-signal composes with -- subscriptions, `peek`, batch, untrack, lite-raf frame effects -- composes with decorated members too.
|
|
296
308
|
|
|
309
|
+
### Predicate-gated watchers (`@zakkster/lite-watch-ex`)
|
|
310
|
+
|
|
311
|
+
`lite-watch-ex` adds one-shot, pausable, and change-gated watchers over the same engine. Its sources are plain **thunks** (`() => vm.hp`, never a box handle), and every watcher creates its effect node in the **default registry** -- so wire one only to a **default-registry** instance (one with no `host.registry`), never across a custom-registry fleet, where the edge would cross a boundary the engine's default `dispose` cannot see:
|
|
312
|
+
|
|
313
|
+
```js
|
|
314
|
+
import { watchUntil } from "@zakkster/lite-watch-ex";
|
|
315
|
+
import { defineReactive } from "@zakkster/lite-signal-decorators";
|
|
316
|
+
|
|
317
|
+
// Default-registry instance -- no host.registry, so it lives in the default graph.
|
|
318
|
+
const ReactivePlayer = defineReactive(class Player {}, { signals: { hp: 100 } });
|
|
319
|
+
const vm = new ReactivePlayer();
|
|
320
|
+
|
|
321
|
+
// Fires ONCE when hp crosses the threshold, then self-disposes:
|
|
322
|
+
watchUntil(() => vm.hp, (h) => h <= 25, (h) => console.log("low hp:", h));
|
|
323
|
+
|
|
324
|
+
vm.hp = 40; // predicate false -> no fire
|
|
325
|
+
vm.hp = 20; // predicate true -> "low hp: 20", watcher disposes itself
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
The [`fleet-playground` demo](demo/fleet-playground.html) shows the safe split at scale: decorated entity VMs in an enforced custom registry, all watchers on a separate default-registry telemetry plane.
|
|
329
|
+
|
|
297
330
|
---
|
|
298
331
|
|
|
299
332
|
## The numbers
|
|
@@ -341,12 +374,12 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
|
|
|
341
374
|
| `disposeReactive(vm)` | none | allocation-free success path; poison handles are prebuilt per member at decoration time |
|
|
342
375
|
| `boxOf` / `rootOf` / any throw | cold path | introspection and failure paths may allocate; never on the hot path |
|
|
343
376
|
|
|
344
|
-
The gates that hold it (run on every change, all green at 0.
|
|
377
|
+
The gates that hold it (run on every change, all green at 1.0.0):
|
|
345
378
|
|
|
346
|
-
- `npm test` / `npm run test:gc` -- **
|
|
379
|
+
- `npm test` / `npm run test:gc` -- **214/214** on both lanes.
|
|
347
380
|
- Suite gate (lite-leak + lite-gc-profiler): `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | ok`.
|
|
348
|
-
- Torture: **
|
|
349
|
-
- `churn-soak`: sustained construct/use/dispose
|
|
381
|
+
- Torture: **15 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) -- **13 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 -- **15/15 controls** prove each gate can actually fail.
|
|
382
|
+
- `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.
|
|
350
383
|
|
|
351
384
|
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 seven class-shaped scenarios, 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.
|
|
352
385
|
|
|
@@ -371,18 +404,19 @@ Full rationale lives in [`decisions/`](decisions/) -- each is a numbered, dated
|
|
|
371
404
|
## Testing (for clients & QA)
|
|
372
405
|
|
|
373
406
|
```bash
|
|
374
|
-
npm test # node --test,
|
|
375
|
-
npm run test:gc # the same
|
|
407
|
+
npm test # node --test, 214 tests
|
|
408
|
+
npm run test:gc # the same 214 with --expose-gc (enables the allocation assertions)
|
|
409
|
+
npm run gate # the full pre-publish chain (section 10): fixtures -> test -> test:gc -> torture -> controls -> peer-preview (non-blocking) -> bench selftest -> pack
|
|
376
410
|
```
|
|
377
411
|
|
|
378
|
-
**
|
|
412
|
+
**214 tests** across fourteen files, all green at 1.0.0. The decorator protocol is tested three times over: against a mock Stage-3 emitter *and* against committed real TypeScript 5 and Babel `2023-11` emits, so both toolchains' codegen is pinned, not assumed.
|
|
379
413
|
|
|
380
414
|
| File | Tests | Covers |
|
|
381
415
|
|---|---:|---|
|
|
382
416
|
| `01-protocol-mock` | 30 | Decorator protocol on the mock Stage-3 emitter: wiring, values, options, rejection matrix |
|
|
383
417
|
| `02-fixtures-ts` | 19 | The same laws on real TypeScript 5 emit (committed fixtures) |
|
|
384
418
|
| `03-fixtures-babel` | 19 | The same laws on real Babel `2023-11` emit |
|
|
385
|
-
| `04-fixture-freshness` |
|
|
419
|
+
| `04-fixture-freshness` | 2 | Fixture hashes match the sources (stale-emit guard) + the README emit-matrix block matches its generator |
|
|
386
420
|
| `05-wiring` | 5 | Anchor creation, wiring order, leaf-wires-once |
|
|
387
421
|
| `06-dispose` | 6 | Cascade, idempotency, poison, `using` |
|
|
388
422
|
| `07-qa-boundary` | 13 | S1 adversarial boundary pins |
|
|
@@ -390,19 +424,54 @@ npm run test:gc # the same 171 with --expose-gc (enables the allocation asse
|
|
|
390
424
|
| `09-buildless` | 16 | `defineReactive` parity + the spec rejection matrix |
|
|
391
425
|
| `10-qa-s2a-boundary` | 34 | Adversarial pins: identity guard, frozen dispose, registry heterogeneity, stacking |
|
|
392
426
|
| `11-qa-s2b-boundary` | 8 | Construction-throw boundaries: init-phase drain, chain-base throws, overflow storms |
|
|
427
|
+
| `12-accounting` | 11 | `costOf` node/link/shape grid (double-probe, frozen + cached, fail-closed) + `capacityFor` budget sizing |
|
|
428
|
+
| `13-labels-audit` | 10 | `enableLabels`/`labelOf` per-registry identity + `auditReactive` leak reporting, both opt-in and default-OFF |
|
|
429
|
+
| `14-qa-s4-boundary` | 21 | S4 adversarial edges: stats-less facade closure, signals-only capacity floor, label/audit boundary matrix |
|
|
430
|
+
|
|
431
|
+
### Emit-support matrix
|
|
432
|
+
|
|
433
|
+
Three fixture sources, two Stage-3 emitters, both emit lanes -- every cell below is a committed, hash-pinned fixture (the `04-fixture-freshness` guard above). The table is generated from the fixture manifest, so a re-emit that changes a byte is loud, not silent:
|
|
434
|
+
|
|
435
|
+
<!-- EMIT-MATRIX:START -->
|
|
436
|
+
Generated by `node test/fixtures/emit-matrix.mjs` from `test/fixtures/hashes.json` -- do not hand-edit. Toolchain pinned by the committed fixtures: **TypeScript 5.9.3**, **@babel/core 7.29.7** + **@babel/plugin-proposal-decorators 7.29.7** (`version: 2023-11`). Each `sha256` is the first 12 hex of the committed emit; `npm run fixtures` regenerates and `test/04-fixture-freshness` fails loudly on any drift.
|
|
437
|
+
|
|
438
|
+
| Source | Emitter | Emit lane | Compiled output | sha256 | At decoration time |
|
|
439
|
+
|---|---|---|---|---|---|
|
|
440
|
+
| `fixture.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/fixture.src.js` | `1a3fc0f943bf` | accepted -- full decorator surface wired + pinned green |
|
|
441
|
+
| `fixture.src.ts` | Babel | standard 2023-11 | `babel-out/fixture.src.js` | `eb9dfb5939b1` | accepted -- full decorator surface wired + pinned green |
|
|
442
|
+
| `static.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/static.src.js` | `d2a03e3d5f70` | rejected -- static member is a named throw at decoration time |
|
|
443
|
+
| `static.src.ts` | Babel | standard 2023-11 | `babel-out/static.src.js` | `dc936c5aa235` | rejected -- static member is a named throw at decoration time |
|
|
444
|
+
| `legacy.src.ts` | TypeScript 5 | legacy (experimental) | `ts-legacy-out/legacy.src.js` | `c1059b1d37b1` | rejected -- legacy emit -> named rejection at decoration time |
|
|
445
|
+
| `legacy.src.ts` | Babel | legacy (experimental) | `babel-legacy-out/legacy.src.js` | `1d35a02c57ce` | rejected -- legacy emit -> named rejection at decoration time |
|
|
446
|
+
|
|
447
|
+
Source hashes: `fixture.src.ts` `fb492a396340`, `static.src.ts` `81fb649965e6`, `legacy.src.ts` `30ac3dabaf7c`.
|
|
448
|
+
<!-- EMIT-MATRIX:END -->
|
|
393
449
|
|
|
394
450
|
### The torture suite (dev-side, never shipped)
|
|
395
451
|
|
|
396
452
|
Process-isolated stress scenarios built on `@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`:
|
|
397
453
|
|
|
398
454
|
```bash
|
|
399
|
-
npm run torture # all
|
|
455
|
+
npm run torture # all 15 scenarios (13 run + 2 floor-gated skips)
|
|
400
456
|
npm run torture:semantic # the correctness lane (CI)
|
|
401
|
-
npm run torture:soak # the wall-clock churn
|
|
457
|
+
npm run torture:soak # the wall-clock churn + fleet soaks
|
|
402
458
|
npm run torture:controls # sabotage self-test: every scenario must FAIL when broken
|
|
403
459
|
```
|
|
404
460
|
|
|
405
|
-
|
|
461
|
+
Fifteen 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 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: 13 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`.
|
|
462
|
+
|
|
463
|
+
### The fleet demo (dev-side, never shipped)
|
|
464
|
+
|
|
465
|
+
A single-file instrument console -- [`demo/fleet-playground.html`](demo/fleet-playground.html) -- drives a two-plane capacity fleet: decorated entity VMs in an enforced custom registry, telemetry watchers in the default registry. Its DOM-free core runs headless under the same gates the library uses:
|
|
466
|
+
|
|
467
|
+
```bash
|
|
468
|
+
npm run demo:build # esbuild bundle -> demo/bundle.js + rewrite demo/bundle.sha256
|
|
469
|
+
npm run demo:check # verify the committed bundle matches its recorded hash
|
|
470
|
+
npm run demo:gc # headless GC-budget lane over the fleet core (maxMajor 0)
|
|
471
|
+
npm run demo:storm # headless dispose-storm retention lane (lite-leak, size 0)
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
The `demo/` directory is dev-only -- it never enters `package.json` `files[]` and never ships to consumers.
|
|
406
475
|
|
|
407
476
|
---
|
|
408
477
|
|
|
@@ -419,12 +488,45 @@ Native (untranspiled) `@` decorators in engines: not shipped anywhere yet -- unt
|
|
|
419
488
|
|
|
420
489
|
---
|
|
421
490
|
|
|
491
|
+
## Migrating from MobX 7 & signal-utils
|
|
492
|
+
|
|
493
|
+
The decorator vocabulary maps almost one-to-one; what changes is the lifetime story. Both libraries below leave teardown to the garbage collector -- this package makes it a single deterministic call.
|
|
494
|
+
|
|
495
|
+
### From MobX 7
|
|
496
|
+
|
|
497
|
+
| MobX 7 | lite-signal-decorators |
|
|
498
|
+
|---|---|
|
|
499
|
+
| `@observable accessor x` | `@reactive accessor x` |
|
|
500
|
+
| `@computed get y()` | `@derived get y()` |
|
|
501
|
+
| `@action m()` | `@batched m()` |
|
|
502
|
+
| `makeObservable(this, {...})` | `@reactiveHost` -- one wiring site, no mirror object to keep in sync |
|
|
503
|
+
| `reaction(...)` / `autorun(...)` | `@reactiveEffect m()` |
|
|
504
|
+
| 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.** |
|
|
505
|
+
|
|
506
|
+
### From signal-utils
|
|
507
|
+
|
|
508
|
+
Verified against the installed `signal-utils@0.21.1`: `@signal` (on accessors or getters) and `@cached` (on getters) are the two decorators in its surface.
|
|
509
|
+
|
|
510
|
+
| signal-utils 0.21 | lite-signal-decorators |
|
|
511
|
+
|---|---|
|
|
512
|
+
| `@signal accessor x` (or `@signal get x`) | `@reactive accessor x` |
|
|
513
|
+
| `@cached get y()` | `@derived get y()` |
|
|
514
|
+
| no disposal API at all | `disposeReactive(vm)` -- **and it disposes**: cascade teardown, poison swap, node-exact conservation |
|
|
515
|
+
| Stage-3 build required | `defineReactive(Class, spec)` -- the buildless door signal-utils has no equivalent for |
|
|
516
|
+
|
|
517
|
+
The cross-framework numbers behind this table are stamped in [`decisions/0006-kill-criteria.md`](decisions/0006-kill-criteria.md) (both engines measured through their documented class APIs at checksum-identical work).
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
422
521
|
## What this is not
|
|
423
522
|
|
|
424
523
|
- **Not a home for module-level or global signals.** That is raw `lite-signal` territory; `static` members are rejected by design.
|
|
524
|
+
- **Not a deep/proxy observation layer.** No `observable.deep`, no wrapped Arrays/Maps/Sets, no proxy magic -- the reactive unit is a declared member, not a traversed object graph. Collections are `@zakkster/lite-project` territory.
|
|
425
525
|
- **Not a per-frame action system.** `@batched` costs a measured thunk per call -- fine for "one call per user intent", wrong inside a render loop. Per-frame hot lanes stay on plain accessor writes (and frame *scheduling* belongs to `lite-raf`).
|
|
426
526
|
- **Not a framework, renderer, or component model.** It ends at the reactive view-model; DOM binding is `lite-signal-dom`'s job.
|
|
427
|
-
- **Not a
|
|
527
|
+
- **Not a general meta-programming kit.** Five decorators, one wiring law -- not an open decorator toolbox. It does one thing: turn a class into a reactive view-model with a provable lifetime.
|
|
528
|
+
- **Not a MobX API shim.** No `makeObservable`, no administration objects -- and no GC-based cleanup: disposal is explicit, deterministic, and verified, because "the collector will get it eventually" is not a lifecycle.
|
|
529
|
+
- **Not a legacy-decorators consumer.** TypeScript `experimentalDecorators` emit is detected by call shape at decoration time and rejected with a named error -- never "works differently under legacy".
|
|
428
530
|
- **Not usable as `@` syntax without a toolchain** -- that is exactly what `defineReactive` exists for.
|
|
429
531
|
|
|
430
532
|
---
|
|
@@ -437,6 +539,7 @@ Native (untranspiled) `@` decorators in engines: not shipped anywhere yet -- unt
|
|
|
437
539
|
| `@zakkster/lite-signal-dom` | DOM bindings for the same engine -- where a view-model meets actual elements. |
|
|
438
540
|
| `@zakkster/lite-raf` | Frame-rate scheduling for the same graph; the frame-coalescing pattern the `scheduler` option on `@reactiveEffect` exists to plug into. |
|
|
439
541
|
| `@zakkster/lite-devtools` | Graph inspection; `rootOf(vm)` + `forEachOwned` is the hook it walks. |
|
|
542
|
+
| [`@zakkster/lite-watch-ex`](https://www.npmjs.com/package/@zakkster/lite-watch-ex) | One-shot / predicate-gated / pausable watchers over the same engine; thunk sources, default-registry effects -- see the [registry note](#composability) before pointing one at a decorated member. |
|
|
440
543
|
| `@zakkster/lite-leak` + `@zakkster/lite-gc-profiler` | The dev-side harness that proves every retention and allocation claim in this README. Never shipped to consumers. |
|
|
441
544
|
|
|
442
545
|
---
|
|
@@ -473,7 +576,7 @@ Because an instance whose base-class boxes live in one pool and whose subclass b
|
|
|
473
576
|
No, and the README says so with numbers: 22.12 ns/op vs 15.25 raw vs 11.67 unbatched on the reference rig -- a thunk + rest-array per call. Use it for actions; keep per-frame writes on plain accessors.
|
|
474
577
|
|
|
475
578
|
**Where are `costOf`, labels, the audit hook, private members?**
|
|
476
|
-
|
|
579
|
+
All three are part of the frozen 1.0.0 surface -- `costOf`/`capacityFor` (measured capacity accounting), `enableLabels`/`labelOf` (devtools identity), and `auditReactive` (leak audit), all cold-path or opt-in with the hot canon untouched. Private-member support remains out. The `llms.txt` scope note tracks exactly what is and isn't included.
|
|
477
580
|
|
|
478
581
|
---
|
|
479
582
|
|
package/SignalDecorators.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ import type {
|
|
|
11
11
|
ComputedBox,
|
|
12
12
|
NodeDescriptor,
|
|
13
13
|
Registry,
|
|
14
|
+
RegistryConfig,
|
|
15
|
+
ReactiveHandle,
|
|
14
16
|
EffectScheduler,
|
|
15
17
|
} from "@zakkster/lite-signal";
|
|
16
18
|
|
|
@@ -256,6 +258,97 @@ export function boxOf<T = unknown>(vm: object, key: PropertyKey): SignalBox<T> |
|
|
|
256
258
|
*/
|
|
257
259
|
export function rootOf(vm: object): NodeDescriptor;
|
|
258
260
|
|
|
261
|
+
// --- Introspection & audit (S4) -----------------------------------------------
|
|
262
|
+
|
|
263
|
+
/** The measured per-instance cost of a reactive class, returned by {@link costOf}. */
|
|
264
|
+
export interface ReactiveCost {
|
|
265
|
+
/** Total nodes = `signals + deriveds + effects + 1` (the anchor). */
|
|
266
|
+
nodes: number;
|
|
267
|
+
/** Dependency links held after every `@derived` has been read once (0007). */
|
|
268
|
+
links: number;
|
|
269
|
+
/** Count of `@reactive` members. */
|
|
270
|
+
signals: number;
|
|
271
|
+
/** Count of `@derived` members. */
|
|
272
|
+
deriveds: number;
|
|
273
|
+
/** Count of `@reactiveEffect` members. */
|
|
274
|
+
effects: number;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Measure the settled per-instance cost of a reactive class on its bound
|
|
279
|
+
* registry. Constructs a probe instance (with NO arguments), reads every
|
|
280
|
+
* `@derived` once to force the lazy links, disposes, and verifies the registry
|
|
281
|
+
* floor -- twice, requiring identical deltas. The result is frozen and cached
|
|
282
|
+
* per class.
|
|
283
|
+
*
|
|
284
|
+
* @param Factory the class returned by `@reactiveHost` or `defineReactive`.
|
|
285
|
+
* @throws if `Factory` is not a reactive wrapper class, if the two probes
|
|
286
|
+
* disagree (a data-dependent read or a polluted registry -- costOf never
|
|
287
|
+
* guesses), or if dispose does not return the registry to its floor.
|
|
288
|
+
*/
|
|
289
|
+
export function costOf(Factory: new (...args: any[]) => any): Readonly<ReactiveCost>;
|
|
290
|
+
|
|
291
|
+
/** A `[Factory, count]` pair for {@link capacityFor}. */
|
|
292
|
+
export type InventoryEntry = [new (...args: any[]) => any, number];
|
|
293
|
+
|
|
294
|
+
/** Options for {@link capacityFor}. */
|
|
295
|
+
export interface CapacityForOptions {
|
|
296
|
+
/**
|
|
297
|
+
* Link-budget multiplier (`>= 1`, default `1` = exact). Applied to the link
|
|
298
|
+
* total for workloads with dynamic-dependency (branchy) deriveds whose active
|
|
299
|
+
* branch can read more members than the probe measured. See decisions/0007.
|
|
300
|
+
*/
|
|
301
|
+
headroom?: number;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Size a `createRegistry` config for a stated inventory of `[Factory, count]`
|
|
306
|
+
* pairs. Nodes are exact; links are `sum(cost.links x count)` scaled by
|
|
307
|
+
* `headroom`. Returns a ready `RegistryConfig` with `prealloc: "eager"` and
|
|
308
|
+
* `onCapacityExceeded: "throw"`.
|
|
309
|
+
*
|
|
310
|
+
* @throws on an empty inventory, a non-factory entry, a non-positive/non-integer
|
|
311
|
+
* count, or a bad `headroom`.
|
|
312
|
+
*/
|
|
313
|
+
export function capacityFor(
|
|
314
|
+
inventory: InventoryEntry[],
|
|
315
|
+
options?: CapacityForOptions,
|
|
316
|
+
): RegistryConfig;
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Toggle devtools labels (default OFF). While ON, wiring registers a
|
|
320
|
+
* `nodeId -> label` for every node an instance creates (`"Class.prop"`,
|
|
321
|
+
* `"Class#method"`, `"Class@anchor"`) into a per-registry map, and
|
|
322
|
+
* `disposeReactive` unregisters them. OFF adds no hot-path cost.
|
|
323
|
+
*
|
|
324
|
+
* @throws if `on` is not a boolean.
|
|
325
|
+
*/
|
|
326
|
+
export function enableLabels(on: boolean): void;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Resolve a node id (or a handle, via the registry's `nodeId`) to its label, or
|
|
330
|
+
* `undefined` if unlabeled/unknown -- an introspection miss is never an error.
|
|
331
|
+
* `registry` defaults to the default registry.
|
|
332
|
+
*/
|
|
333
|
+
export function labelOf(
|
|
334
|
+
idOrHandle: number | ReactiveHandle | NodeDescriptor,
|
|
335
|
+
registry?: Registry,
|
|
336
|
+
): string | undefined;
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Toggle the leak auditor (default OFF). While ON, a lazily-created
|
|
340
|
+
* `FinalizationRegistry` reports (one `console.error`) any instance
|
|
341
|
+
* garbage-collected WITHOUT `disposeReactive`, naming the class + shape. OFF: no
|
|
342
|
+
* `FinalizationRegistry` exists and nothing is registered.
|
|
343
|
+
*
|
|
344
|
+
* Note: an instance pinned by its own undisposed derived/effect nodes on a
|
|
345
|
+
* long-lived registry is never collected, so audit cannot fire for it -- that
|
|
346
|
+
* retention is caught by leak torture instead (see decisions/0008).
|
|
347
|
+
*
|
|
348
|
+
* @throws if `on` is not a boolean.
|
|
349
|
+
*/
|
|
350
|
+
export function auditReactive(on: boolean): void;
|
|
351
|
+
|
|
259
352
|
// --- Errors -------------------------------------------------------------------
|
|
260
353
|
|
|
261
354
|
/**
|
|
@@ -273,4 +366,4 @@ export class ReactiveDisposedError extends Error {
|
|
|
273
366
|
// --- Version ------------------------------------------------------------------
|
|
274
367
|
|
|
275
368
|
/** Package version. Kept in lockstep with package.json and llms.txt. */
|
|
276
|
-
export const VERSION: "0.
|
|
369
|
+
export const VERSION: "1.0.0";
|
package/SignalDecorators.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zakkster/lite-signal-decorators
|
|
2
|
+
* @zakkster/lite-signal-decorators v1.0.0
|
|
3
3
|
* --------------------
|
|
4
4
|
* Stage-3 decorator layer over @zakkster/lite-signal. Turns a plain class into
|
|
5
5
|
* a reactive view-model with measured per-instance cost and deterministic
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
isTracking,
|
|
45
45
|
batch,
|
|
46
46
|
untrack,
|
|
47
|
+
stats,
|
|
47
48
|
} from "@zakkster/lite-signal";
|
|
48
49
|
|
|
49
50
|
// --- Module state -------------------------------------------------------------
|
|
@@ -85,10 +86,40 @@ const SCRATCH = [];
|
|
|
85
86
|
// Hoisted package prefix (PD-15): every cold error message begins with this.
|
|
86
87
|
const ERR = "@zakkster/lite-signal-decorators: ";
|
|
87
88
|
|
|
89
|
+
// --- Introspection state (S4; all opt-in, all cold/off by default) -----------
|
|
90
|
+
|
|
91
|
+
// PD-23/PD-24: labels and audit are opt-in debug features. INTROSPECT_ON is the
|
|
92
|
+
// single wiring/dispose gate (= LABELS_ON || AUDIT_ON), so the OFF path pays at
|
|
93
|
+
// most one flag test at wiring and one at dispose -- the hot accessor canon is
|
|
94
|
+
// never touched in either mode.
|
|
95
|
+
let LABELS_ON = false;
|
|
96
|
+
let AUDIT_ON = false;
|
|
97
|
+
let INTROSPECT_ON = false;
|
|
98
|
+
|
|
99
|
+
// PD-23: per-registry nodeId -> label. nodeIds are per-registry, so a
|
|
100
|
+
// module-global Map would collide across registries; the key is the plan's reg
|
|
101
|
+
// object (DEFAULT_REG facade or a custom Registry).
|
|
102
|
+
const LABEL_MAPS = new WeakMap(); // reg -> Map<number, string>
|
|
103
|
+
// Per-plan label strings, built once at first labeled wiring and shared by every
|
|
104
|
+
// instance of the class.
|
|
105
|
+
const LABEL_STRINGS = new WeakMap(); // plan -> { anchor, signals[], deriveds[], effects[] }
|
|
106
|
+
// The instance's registered label ids, so disposeReactive can unregister exactly
|
|
107
|
+
// them (effect handles are otherwise discarded). Only written while LABELS_ON.
|
|
108
|
+
const LABEL_IDS = Symbol("lite-signal-decorators.labelIds");
|
|
109
|
+
|
|
110
|
+
// PD-24: costOf result cache (per wrapper class; shape is frozen at decoration).
|
|
111
|
+
const COST_CACHE = new WeakMap(); // Factory -> frozen cost object
|
|
112
|
+
|
|
113
|
+
// PD-24: the audit FinalizationRegistry is created lazily on first enable and
|
|
114
|
+
// never torn down (a FR holds no strong refs to its targets). Its held value is
|
|
115
|
+
// a plain { className, shape } record -- it must NOT close over the instance.
|
|
116
|
+
let AUDIT_FR = null;
|
|
117
|
+
|
|
88
118
|
// Known option keys per decorator (unknown-key did-you-mean sets, PD-8/PD-11).
|
|
89
119
|
const KNOWN_OPTION_KEYS = ["equals"];
|
|
90
120
|
const EFFECT_OPTION_KEYS = ["scheduler"];
|
|
91
121
|
const HOST_OPTION_KEYS = ["registry"];
|
|
122
|
+
const CAP_OPTION_KEYS = ["headroom"];
|
|
92
123
|
|
|
93
124
|
// --- DEFAULT_REG facade (PD-11) ----------------------------------------------
|
|
94
125
|
|
|
@@ -107,6 +138,10 @@ const DEFAULT_REG = Object.freeze({
|
|
|
107
138
|
isTracking,
|
|
108
139
|
batch,
|
|
109
140
|
untrack,
|
|
141
|
+
// `stats` is not in REG_METHODS (the duck-check stays at the 11 methods the
|
|
142
|
+
// wiring/dispose paths use); costOf reads it here for the default registry,
|
|
143
|
+
// and every custom Registry from createRegistry() exposes it natively.
|
|
144
|
+
stats,
|
|
110
145
|
});
|
|
111
146
|
|
|
112
147
|
// The 11 method names a valid Registry must expose (duck-check set, PD-11).
|
|
@@ -864,11 +899,22 @@ function wireInstance(inst, plan) {
|
|
|
864
899
|
}
|
|
865
900
|
// Effects wire AFTER every derived (D-4a): the first synchronous run
|
|
866
901
|
// sees every field and every derived. Dispose handles are DISCARDED --
|
|
867
|
-
// teardown is the anchor cascade.
|
|
902
|
+
// teardown is the anchor cascade. The ONE introspection flag test
|
|
903
|
+
// (S4, PD-23/24): the OFF branch is byte-identical to 0.3.0; the ON
|
|
904
|
+
// branch captures effect handles for labeling + registers audit.
|
|
868
905
|
const effs = plan.effects;
|
|
869
|
-
|
|
870
|
-
const
|
|
871
|
-
|
|
906
|
+
if (INTROSPECT_ON) {
|
|
907
|
+
const effHandles = LABELS_ON ? [] : null;
|
|
908
|
+
for (let i = 0; i < effs.length; i++) {
|
|
909
|
+
const h = reg.effect(makeEffectBody(inst, effs[i].fn), effs[i].opts);
|
|
910
|
+
if (effHandles !== null) effHandles.push(h);
|
|
911
|
+
}
|
|
912
|
+
introspectWire(inst, plan, reg, effHandles);
|
|
913
|
+
} else {
|
|
914
|
+
for (let i = 0; i < effs.length; i++) {
|
|
915
|
+
const e = effs[i];
|
|
916
|
+
reg.effect(makeEffectBody(inst, e.fn), e.opts);
|
|
917
|
+
}
|
|
872
918
|
}
|
|
873
919
|
});
|
|
874
920
|
} catch (e) {
|
|
@@ -1258,6 +1304,10 @@ export function disposeReactive(vm) {
|
|
|
1258
1304
|
}
|
|
1259
1305
|
}
|
|
1260
1306
|
}
|
|
1307
|
+
// S4 introspection cleanup (one flag test on the OFF dispose path): drop this
|
|
1308
|
+
// instance's label entries and unregister it from the audit FR so a proper
|
|
1309
|
+
// dispose is never mistaken for a silent death.
|
|
1310
|
+
if (INTROSPECT_ON) introspectDispose(vm, reg);
|
|
1261
1311
|
disposeCore(vm, plan);
|
|
1262
1312
|
return true;
|
|
1263
1313
|
}
|
|
@@ -1295,7 +1345,320 @@ export function rootOf(vm) {
|
|
|
1295
1345
|
return a;
|
|
1296
1346
|
}
|
|
1297
1347
|
|
|
1348
|
+
// --- Introspection & audit (S4; all cold / opt-in) ----------------------------
|
|
1349
|
+
|
|
1350
|
+
function throwCostFactory() {
|
|
1351
|
+
throw new TypeError(
|
|
1352
|
+
`${ERR}costOf(Factory) -- Factory must be a @reactiveHost / defineReactive wrapper class.`,
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
function throwCostNoPlan() {
|
|
1357
|
+
throw new Error(
|
|
1358
|
+
`${ERR}costOf(Factory) -- that class has no reactive plan; pass the class returned by @reactiveHost or defineReactive, not the undecorated inner class.`,
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function throwCostNoStats(name) {
|
|
1363
|
+
throw new TypeError(
|
|
1364
|
+
`${ERR}costOf(${name}) -- the bound registry has no stats() ledger; costOf/capacityFor need a createRegistry() registry (which always carries the stats ledger). A hand-rolled 11-method registry facade cannot be probed.`,
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function throwCostInconclusive(name, a, b) {
|
|
1369
|
+
throw new Error(
|
|
1370
|
+
`${ERR}costOf(${name}) -- inconclusive: two probes disagreed (nodes ${a.nodes}/${b.nodes}, links ${a.links}/${b.links}). A data-dependent derived read, or a registry mutated mid-probe, makes the cost non-deterministic; costOf fails closed rather than guess.`,
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function throwCostNodeMismatch(name, got, want) {
|
|
1375
|
+
throw new Error(
|
|
1376
|
+
`${ERR}costOf(${name}) -- probed node count ${got} != P+D+E+1 (${want}); the bound registry was not quiet during the probe.`,
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function throwCostFloor(name) {
|
|
1381
|
+
throw new Error(
|
|
1382
|
+
`${ERR}costOf(${name}) -- dispose did not return the bound registry to its pre-probe floor; the probe could not run against a quiet registry.`,
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// costOf runs the probe twice and requires identical deltas: an inconclusive
|
|
1387
|
+
// probe is a fail-closed THROW, never a guessed number (PD-21).
|
|
1388
|
+
function probeCost(Factory, plan, reg) {
|
|
1389
|
+
const before = reg.stats();
|
|
1390
|
+
const inst = new Factory();
|
|
1391
|
+
const ders = plan.deriveds;
|
|
1392
|
+
for (let i = 0; i < ders.length; i++) void inst[ders[i].key]; // force lazy links
|
|
1393
|
+
const mid = reg.stats();
|
|
1394
|
+
const nodes = mid.activeNodes - before.activeNodes;
|
|
1395
|
+
const links = mid.activeLinks - before.activeLinks;
|
|
1396
|
+
disposeReactive(inst);
|
|
1397
|
+
const after = reg.stats();
|
|
1398
|
+
if (after.activeNodes !== before.activeNodes || after.activeLinks !== before.activeLinks) {
|
|
1399
|
+
throwCostFloor(plan.ctorName);
|
|
1400
|
+
}
|
|
1401
|
+
return { nodes, links };
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* Measure the settled per-instance cost of a reactive class on its bound
|
|
1406
|
+
* registry: construct, read every `@derived` once (forcing the lazy links),
|
|
1407
|
+
* snapshot, dispose, verify the floor -- twice, requiring identical deltas.
|
|
1408
|
+
* Returns a frozen `{ nodes, links, signals, deriveds, effects }`; `nodes`
|
|
1409
|
+
* equals P+D+E+1. Cached per class. Throws (never guesses) on an inconclusive
|
|
1410
|
+
* or polluted probe. Constructs the probe instance with no arguments.
|
|
1411
|
+
*/
|
|
1412
|
+
export function costOf(Factory) {
|
|
1413
|
+
if (typeof Factory !== "function") throwCostFactory();
|
|
1414
|
+
const cached = COST_CACHE.get(Factory);
|
|
1415
|
+
if (cached !== undefined) return cached;
|
|
1416
|
+
const plan = PLANS.get(Factory);
|
|
1417
|
+
if (plan === undefined) throwCostNoPlan();
|
|
1418
|
+
const reg = plan.reg;
|
|
1419
|
+
// The 11-method REG_METHODS duck-check excludes stats (the wiring/dispose
|
|
1420
|
+
// paths never need it), so a hand-rolled facade can be duck-valid yet lack
|
|
1421
|
+
// stats -- guard here rather than let probeCost throw a raw TypeError.
|
|
1422
|
+
if (typeof reg.stats !== "function") throwCostNoStats(plan.ctorName);
|
|
1423
|
+
const first = probeCost(Factory, plan, reg);
|
|
1424
|
+
const second = probeCost(Factory, plan, reg);
|
|
1425
|
+
if (first.nodes !== second.nodes || first.links !== second.links) {
|
|
1426
|
+
throwCostInconclusive(plan.ctorName, first, second);
|
|
1427
|
+
}
|
|
1428
|
+
const sig = plan.signals.length;
|
|
1429
|
+
const der = plan.deriveds.length;
|
|
1430
|
+
const eff = plan.effects.length;
|
|
1431
|
+
const expected = sig + der + eff + 1;
|
|
1432
|
+
if (first.nodes !== expected) throwCostNodeMismatch(plan.ctorName, first.nodes, expected);
|
|
1433
|
+
const result = Object.freeze({
|
|
1434
|
+
nodes: first.nodes,
|
|
1435
|
+
links: first.links,
|
|
1436
|
+
signals: sig,
|
|
1437
|
+
deriveds: der,
|
|
1438
|
+
effects: eff,
|
|
1439
|
+
});
|
|
1440
|
+
COST_CACHE.set(Factory, result);
|
|
1441
|
+
return result;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function throwCapInventory() {
|
|
1445
|
+
throw new TypeError(
|
|
1446
|
+
`${ERR}capacityFor(inventory) -- inventory must be a non-empty array of [Factory, count] pairs.`,
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function throwCapPair(i) {
|
|
1451
|
+
throw new TypeError(
|
|
1452
|
+
`${ERR}capacityFor -- inventory[${i}] must be a [Factory, count] pair.`,
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function throwCapFactory(i) {
|
|
1457
|
+
throw new TypeError(
|
|
1458
|
+
`${ERR}capacityFor -- inventory[${i}][0] must be a @reactiveHost / defineReactive wrapper class.`,
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function throwCapCount(i) {
|
|
1463
|
+
throw new TypeError(
|
|
1464
|
+
`${ERR}capacityFor -- inventory[${i}][1] must be a positive integer count.`,
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function throwCapHeadroom() {
|
|
1469
|
+
throw new TypeError(
|
|
1470
|
+
`${ERR}capacityFor -- headroom must be a finite number >= 1.`,
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function throwCapOptions(options) {
|
|
1475
|
+
const got = Array.isArray(options) ? "an array" : typeof options;
|
|
1476
|
+
throw new TypeError(
|
|
1477
|
+
`${ERR}capacityFor(inventory, options?) -- options must be a plain object like { headroom: 1.25 }; got ${got}.`,
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Size a `createRegistry` config for a stated inventory of `[Factory, count]`
|
|
1483
|
+
* pairs. Nodes are exact (`sum(cost.nodes x count)`); links are
|
|
1484
|
+
* `sum(cost.links x count)` scaled by `headroom` (default 1 -- exact; see
|
|
1485
|
+
* decisions/0007). Returns
|
|
1486
|
+
* `{ maxNodes, maxLinks, prealloc: "eager", onCapacityExceeded: "throw" }`.
|
|
1487
|
+
* Fail-closed on a non-factory, a non-positive/non-integer count, an empty
|
|
1488
|
+
* inventory, or a bad `headroom`.
|
|
1489
|
+
*/
|
|
1490
|
+
export function capacityFor(inventory, options) {
|
|
1491
|
+
if (!Array.isArray(inventory) || inventory.length === 0) throwCapInventory();
|
|
1492
|
+
let headroom = 1;
|
|
1493
|
+
if (options !== undefined && options !== null) { // null == omitted (preserved)
|
|
1494
|
+
if (typeof options !== "object" || Array.isArray(options)) throwCapOptions(options);
|
|
1495
|
+
const okeys = Object.keys(options);
|
|
1496
|
+
for (let i = 0; i < okeys.length; i++) {
|
|
1497
|
+
if (okeys[i] !== "headroom") throwUnknownOption("capacityFor", okeys[i], CAP_OPTION_KEYS);
|
|
1498
|
+
}
|
|
1499
|
+
if (options.headroom !== undefined) {
|
|
1500
|
+
const h = options.headroom;
|
|
1501
|
+
if (typeof h !== "number" || !isFinite(h) || h < 1) throwCapHeadroom();
|
|
1502
|
+
headroom = h;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
let totalNodes = 0;
|
|
1506
|
+
let totalLinks = 0;
|
|
1507
|
+
for (let i = 0; i < inventory.length; i++) {
|
|
1508
|
+
const pair = inventory[i];
|
|
1509
|
+
if (!Array.isArray(pair) || pair.length !== 2) throwCapPair(i);
|
|
1510
|
+
const Factory = pair[0];
|
|
1511
|
+
const count = pair[1];
|
|
1512
|
+
if (typeof Factory !== "function") throwCapFactory(i);
|
|
1513
|
+
if (typeof count !== "number" || !Number.isInteger(count) || count <= 0) throwCapCount(i);
|
|
1514
|
+
const cost = costOf(Factory);
|
|
1515
|
+
totalNodes += cost.nodes * count;
|
|
1516
|
+
totalLinks += cost.links * count;
|
|
1517
|
+
}
|
|
1518
|
+
return {
|
|
1519
|
+
maxNodes: totalNodes, // exact -- nodes are deterministic
|
|
1520
|
+
// links x headroom, floored at the engine minimum of 1 (createRegistry
|
|
1521
|
+
// rejects maxLinks: 0) so a signals-only inventory still yields a
|
|
1522
|
+
// constructible config (0007). Floor is applied AFTER the multiplier.
|
|
1523
|
+
maxLinks: Math.max(1, Math.ceil(totalLinks * headroom)),
|
|
1524
|
+
prealloc: "eager",
|
|
1525
|
+
onCapacityExceeded: "throw",
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function throwFlagArg(what) {
|
|
1530
|
+
throw new TypeError(`${ERR}${what}(on) -- on must be a boolean.`);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// Per-class label strings, built once and shared by every instance (PD-23).
|
|
1534
|
+
function labelStringsFor(plan) {
|
|
1535
|
+
let s = LABEL_STRINGS.get(plan);
|
|
1536
|
+
if (s !== undefined) return s;
|
|
1537
|
+
const name = plan.ctorName;
|
|
1538
|
+
const sig = [];
|
|
1539
|
+
for (let i = 0; i < plan.signals.length; i++) sig.push(`${name}.${keyLabel(plan.signals[i].key)}`);
|
|
1540
|
+
const der = [];
|
|
1541
|
+
for (let i = 0; i < plan.deriveds.length; i++) der.push(`${name}.${keyLabel(plan.deriveds[i].key)}`);
|
|
1542
|
+
const eff = [];
|
|
1543
|
+
for (let i = 0; i < plan.effects.length; i++) eff.push(`${name}#${keyLabel(plan.effects[i].key)}`);
|
|
1544
|
+
s = { anchor: `${name}@anchor`, signals: sig, deriveds: der, effects: eff };
|
|
1545
|
+
LABEL_STRINGS.set(plan, s);
|
|
1546
|
+
return s;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function registerLabels(inst, plan, reg, effHandles) {
|
|
1550
|
+
let map = LABEL_MAPS.get(reg);
|
|
1551
|
+
if (map === undefined) {
|
|
1552
|
+
map = new Map();
|
|
1553
|
+
LABEL_MAPS.set(reg, map);
|
|
1554
|
+
}
|
|
1555
|
+
const strings = labelStringsFor(plan);
|
|
1556
|
+
const ids = [];
|
|
1557
|
+
const anchorId = reg.nodeId(inst[ANCHOR]);
|
|
1558
|
+
if (anchorId !== undefined) { map.set(anchorId, strings.anchor); ids.push(anchorId); }
|
|
1559
|
+
const sigs = plan.signals;
|
|
1560
|
+
for (let i = 0; i < sigs.length; i++) {
|
|
1561
|
+
const id = reg.nodeId(inst[sigs[i].slot]);
|
|
1562
|
+
if (id !== undefined) { map.set(id, strings.signals[i]); ids.push(id); }
|
|
1563
|
+
}
|
|
1564
|
+
const ders = plan.deriveds;
|
|
1565
|
+
for (let i = 0; i < ders.length; i++) {
|
|
1566
|
+
const id = reg.nodeId(inst[ders[i].slot]);
|
|
1567
|
+
if (id !== undefined) { map.set(id, strings.deriveds[i]); ids.push(id); }
|
|
1568
|
+
}
|
|
1569
|
+
if (effHandles !== null) {
|
|
1570
|
+
for (let i = 0; i < effHandles.length; i++) {
|
|
1571
|
+
const id = reg.nodeId(effHandles[i]);
|
|
1572
|
+
if (id !== undefined) { map.set(id, strings.effects[i]); ids.push(id); }
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
inst[LABEL_IDS] = ids;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function auditShape(plan) {
|
|
1579
|
+
return `P=${plan.signals.length} D=${plan.deriveds.length} E=${plan.effects.length}`;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function auditFinalize(held) {
|
|
1583
|
+
// held is a plain { className, shape } record -- it never references the
|
|
1584
|
+
// (now-collected) instance, so the FR cannot itself retain what it watches.
|
|
1585
|
+
console.error(
|
|
1586
|
+
`${ERR}auditReactive: an instance of ${held.className} (${held.shape}) was garbage-collected without disposeReactive() -- its reactive graph was reclaimed by GC, not by you. Dispose at end of life: disposeReactive(vm), or a \`using\` block.`,
|
|
1587
|
+
);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// Wiring-time introspection (S4): only reached when INTROSPECT_ON. Registers
|
|
1591
|
+
// per-node labels (while LABELS_ON) and the audit FR entry (while AUDIT_ON).
|
|
1592
|
+
function introspectWire(inst, plan, reg, effHandles) {
|
|
1593
|
+
if (LABELS_ON) registerLabels(inst, plan, reg, effHandles);
|
|
1594
|
+
if (AUDIT_ON && AUDIT_FR !== null) {
|
|
1595
|
+
AUDIT_FR.register(inst, { className: plan.ctorName, shape: auditShape(plan) }, inst);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// Dispose-time introspection (S4): only reached when INTROSPECT_ON. Drops this
|
|
1600
|
+
// instance's label entries and unregisters it from the audit FR.
|
|
1601
|
+
function introspectDispose(vm, reg) {
|
|
1602
|
+
const ids = vm[LABEL_IDS];
|
|
1603
|
+
if (ids !== undefined) {
|
|
1604
|
+
const map = LABEL_MAPS.get(reg);
|
|
1605
|
+
if (map !== undefined) for (let i = 0; i < ids.length; i++) map.delete(ids[i]);
|
|
1606
|
+
vm[LABEL_IDS] = undefined;
|
|
1607
|
+
}
|
|
1608
|
+
if (AUDIT_FR !== null) AUDIT_FR.unregister(vm);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/**
|
|
1612
|
+
* Toggle devtools labels (default OFF). While ON, wiring registers a
|
|
1613
|
+
* `nodeId -> "Class.prop" / "Class#method" / "Class@anchor"` label for every
|
|
1614
|
+
* node an instance creates, into a per-registry map; `disposeReactive`
|
|
1615
|
+
* unregisters them. OFF adds no hot-path cost (the accessor canon is untouched).
|
|
1616
|
+
*/
|
|
1617
|
+
export function enableLabels(on) {
|
|
1618
|
+
if (typeof on !== "boolean") throwFlagArg("enableLabels");
|
|
1619
|
+
LABELS_ON = on;
|
|
1620
|
+
INTROSPECT_ON = LABELS_ON || AUDIT_ON;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* Resolve a node id (or a handle, via the registry's `nodeId`) to its label, or
|
|
1625
|
+
* `undefined` if unlabeled/unknown -- an introspection miss is never an error.
|
|
1626
|
+
* `registry` defaults to the default registry; pass a custom `Registry` to look
|
|
1627
|
+
* up nodes it owns.
|
|
1628
|
+
*/
|
|
1629
|
+
export function labelOf(idOrHandle, registry) {
|
|
1630
|
+
const reg = registry === undefined || registry === null ? DEFAULT_REG : registry;
|
|
1631
|
+
if (typeof reg !== "object" || reg === null) return undefined;
|
|
1632
|
+
let id;
|
|
1633
|
+
if (typeof idOrHandle === "number") {
|
|
1634
|
+
id = idOrHandle;
|
|
1635
|
+
} else if (idOrHandle !== null && typeof idOrHandle === "object" && typeof reg.nodeId === "function") {
|
|
1636
|
+
id = reg.nodeId(idOrHandle);
|
|
1637
|
+
if (id === undefined) return undefined;
|
|
1638
|
+
} else {
|
|
1639
|
+
return undefined;
|
|
1640
|
+
}
|
|
1641
|
+
const map = LABEL_MAPS.get(reg);
|
|
1642
|
+
if (map === undefined) return undefined;
|
|
1643
|
+
return map.get(id);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/**
|
|
1647
|
+
* Toggle the leak auditor (default OFF). While ON, a lazily-created
|
|
1648
|
+
* FinalizationRegistry reports (one `console.error`) any instance that is
|
|
1649
|
+
* garbage-collected WITHOUT `disposeReactive` -- naming the class + shape. OFF:
|
|
1650
|
+
* no FinalizationRegistry exists and nothing is registered.
|
|
1651
|
+
*/
|
|
1652
|
+
export function auditReactive(on) {
|
|
1653
|
+
if (typeof on !== "boolean") throwFlagArg("auditReactive");
|
|
1654
|
+
if (on && AUDIT_FR === null && typeof FinalizationRegistry === "function") {
|
|
1655
|
+
AUDIT_FR = new FinalizationRegistry(auditFinalize);
|
|
1656
|
+
}
|
|
1657
|
+
AUDIT_ON = on;
|
|
1658
|
+
INTROSPECT_ON = LABELS_ON || AUDIT_ON;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1298
1661
|
// --- Version ------------------------------------------------------------------
|
|
1299
1662
|
|
|
1300
1663
|
/** Package version. Kept in lockstep with package.json and llms.txt. */
|
|
1301
|
-
export const VERSION = "0.
|
|
1664
|
+
export const VERSION = "1.0.0";
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zakkster/lite-signal-decorators
|
|
2
2
|
|
|
3
|
-
VERSION 0.
|
|
3
|
+
VERSION 1.0.0
|
|
4
4
|
|
|
5
5
|
> Stage-3 decorator layer over @zakkster/lite-signal. Turns a plain class into a
|
|
6
6
|
> reactive view-model where each instance has a measured per-property cost, a
|
|
@@ -20,7 +20,7 @@ zero decorator syntax, sharing the SAME core by function identity.
|
|
|
20
20
|
slot for a poison handle, so any later read/write throws a named
|
|
21
21
|
`ReactiveDisposedError`.
|
|
22
22
|
|
|
23
|
-
## Exports (
|
|
23
|
+
## Exports (16)
|
|
24
24
|
|
|
25
25
|
- `reactive` -- `@reactive accessor x = v` (bare) or `@reactive({ equals })`
|
|
26
26
|
(factory). Declares a per-instance signal.
|
|
@@ -53,9 +53,22 @@ slot for a poison handle, so any later read/write throws a named
|
|
|
53
53
|
dispose.
|
|
54
54
|
- `rootOf(vm) -> NodeDescriptor` -- the instance's anchor descriptor; feeds
|
|
55
55
|
`forEachOwned` / lite-devtools. Throws `ReactiveDisposedError` after dispose.
|
|
56
|
+
- `costOf(Factory) -> { nodes, links, signals, deriveds, effects }` -- the
|
|
57
|
+
measured settled per-instance cost on the class's bound registry (frozen,
|
|
58
|
+
cached). Double-probed: an inconclusive or polluted probe throws, never
|
|
59
|
+
guesses. `nodes` = P+D+E+1; `links` = the first-full-read link count. See
|
|
60
|
+
"Introspection & audit".
|
|
61
|
+
- `capacityFor(inventory, { headroom }?) -> RegistryConfig` -- size a
|
|
62
|
+
`createRegistry` config from `[Factory, count]` pairs. Nodes exact, links x
|
|
63
|
+
`headroom` (default 1). Fail-closed inventory validation.
|
|
64
|
+
- `enableLabels(on)` / `labelOf(idOrHandle, registry?) -> string | undefined` --
|
|
65
|
+
opt-in devtools labels (default OFF); per-registry `nodeId -> "Class.prop" /
|
|
66
|
+
"Class#method" / "Class@anchor"`.
|
|
67
|
+
- `auditReactive(on)` -- opt-in leak auditor (default OFF); a lazy
|
|
68
|
+
`FinalizationRegistry` reports any instance GC'd without `disposeReactive`.
|
|
56
69
|
- `ReactiveDisposedError` -- `extends Error`, `name` `"ReactiveDisposedError"`,
|
|
57
70
|
fields `className` and `key`.
|
|
58
|
-
- `VERSION` -- `"0.
|
|
71
|
+
- `VERSION` -- `"1.0.0"`.
|
|
59
72
|
|
|
60
73
|
## Registry law (one registry per host chain)
|
|
61
74
|
|
|
@@ -118,6 +131,45 @@ with the decorator path: values, deriveds, effect methods, disposal, poison, and
|
|
|
118
131
|
capacity accounting are identical because both front doors call the SAME wiring
|
|
119
132
|
core.
|
|
120
133
|
|
|
134
|
+
## Introspection & audit (S4; all cold, all opt-in)
|
|
135
|
+
|
|
136
|
+
`costOf(Factory)` measures a class's settled per-instance cost by a stats-delta
|
|
137
|
+
probe on its bound registry: construct, read every `@derived` once (forcing the
|
|
138
|
+
lazy links), snapshot, dispose, verify the floor -- run TWICE, requiring
|
|
139
|
+
identical deltas. An inconclusive probe (a data-dependent derived read, or a
|
|
140
|
+
registry mutated mid-probe) or a floor-verify failure THROWS a named error;
|
|
141
|
+
costOf never returns a guess. `nodes` equals P+D+E+1 (the R-A law); `links` is
|
|
142
|
+
the first-full-read count. The result is frozen and cached per class. costOf
|
|
143
|
+
constructs the probe instance with NO constructor arguments.
|
|
144
|
+
|
|
145
|
+
`capacityFor(inventory, { headroom } = {})` turns `[Factory, count]` pairs into a
|
|
146
|
+
ready `createRegistry` config `{ maxNodes, maxLinks, prealloc: "eager",
|
|
147
|
+
onCapacityExceeded: "throw" }`. Nodes are exact (`sum(cost.nodes x count)`);
|
|
148
|
+
links are `sum(cost.links x count)` scaled by `headroom` (default 1 = exact),
|
|
149
|
+
floored at the engine minimum of 1 so a signals-only inventory stays
|
|
150
|
+
constructible.
|
|
151
|
+
FIXED-shape deriveds provision exactly; a BRANCHY derived whose active branch
|
|
152
|
+
reads more members than the probe measured under-provisions and the engine throws
|
|
153
|
+
`CapacityError` at link formation (loud, named -- raise `headroom` for such
|
|
154
|
+
workloads). See decisions/0007.
|
|
155
|
+
|
|
156
|
+
Labels (`enableLabels(true)`, default OFF) register, at wiring, a per-registry
|
|
157
|
+
`nodeId -> label` for every node an instance creates
|
|
158
|
+
(`"Class.prop"` / `"Class#method"` / `"Class@anchor"`); `labelOf(idOrHandle,
|
|
159
|
+
registry?)` resolves them (an unknown id returns `undefined` -- a miss is not an
|
|
160
|
+
error); `disposeReactive` unregisters. Maps are per-registry (node ids are
|
|
161
|
+
per-registry). OFF adds no hot-path cost -- the accessor canon is byte-identical
|
|
162
|
+
to 0.3.0; labels feed a `graph()`/`toTree()` devtools walk from `rootOf(vm)`.
|
|
163
|
+
|
|
164
|
+
Audit (`auditReactive(true)`, default OFF) lazily creates one
|
|
165
|
+
`FinalizationRegistry` and reports (one `console.error`) any instance
|
|
166
|
+
garbage-collected WITHOUT `disposeReactive`, naming the class + shape. OFF: no
|
|
167
|
+
`FinalizationRegistry` exists, nothing is registered, wiring measures unchanged.
|
|
168
|
+
Reach: an instance still pinned by its own undisposed derived/effect nodes on a
|
|
169
|
+
long-lived registry is never collected, so audit cannot fire for it -- that
|
|
170
|
+
retention is what the leak/retention torture catches; audit catches instances
|
|
171
|
+
that reach GC without dispose (e.g. a per-scope registry dropped whole).
|
|
172
|
+
|
|
121
173
|
## Rejections (all named, all fail-closed, all at decoration/definition time)
|
|
122
174
|
|
|
123
175
|
Legacy emit, wrong decorator kind, static members, private (#) members, unknown
|
|
@@ -133,12 +185,26 @@ throw a named error.
|
|
|
133
185
|
Requires `@zakkster/lite-signal` `>=1.5.0 <2.0.0` (peer dependency). Uses
|
|
134
186
|
`signalBox`, `computedBox`, `effect`, `createRoot`, `getOwner`, `runWithOwner`,
|
|
135
187
|
`dispose`, `nodeId`, `isTracking`, `batch`, and `untrack` from it (all 11 also
|
|
136
|
-
routed per-registry via `createRegistry()`).
|
|
188
|
+
routed per-registry via `createRegistry()`). The RANGE floor is 1.5.0 and stays
|
|
189
|
+
1.5.0 -- everything the runtime surface needs shipped in 1.5.0 stable. Two
|
|
190
|
+
forward-compat torture scenarios are feature-detected above that floor and SKIP
|
|
191
|
+
below it (a skip below the floor is correct; at or above it is a failure): the
|
|
192
|
+
`createScope` adoption scenario needs peer 1.6.0, and the engine
|
|
193
|
+
`[Symbol.dispose]`/`using` interop scenario needs peer 1.9.0. Neither is a
|
|
194
|
+
runtime requirement -- the shipped surface runs on 1.5.0.
|
|
137
195
|
|
|
138
196
|
## Scope note
|
|
139
197
|
|
|
140
|
-
0.
|
|
141
|
-
|
|
198
|
+
1.0.0 FREEZES the export surface -- 16 exports: the 11 runtime exports plus
|
|
199
|
+
`costOf`, `capacityFor`, `enableLabels`, `labelOf`, and `auditReactive` (all
|
|
200
|
+
cold / opt-in; the hot accessor canon is byte-identical to 0.3.0). The semver
|
|
201
|
+
promise from here: any change to an existing export's signature or behavior is a
|
|
202
|
+
MAJOR, recorded in a decision file; new exports are minors; the hot accessor
|
|
203
|
+
canon (`makeGet`/`makeSet`) does not move without a major. Also present since
|
|
204
|
+
0.3.0: the dev-side class-reactivity benchmark (`bench/`, private, never
|
|
142
205
|
shipped) and the fleet-soak torture scenario; the formal kill-criteria verdicts
|
|
143
|
-
live in decisions/0006-kill-criteria.md.
|
|
144
|
-
|
|
206
|
+
live in decisions/0006-kill-criteria.md. Still OUT of the 1.0 surface (each a
|
|
207
|
+
deliberate, documented exclusion, not an oversight): private (`#`) members,
|
|
208
|
+
static members, module/global signals (raw lite-signal territory), and any
|
|
209
|
+
instance reinitialization / pooling API -- an instance is single-lifetime,
|
|
210
|
+
disposed once, never revived.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-signal-decorators",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Stage-3 decorator layer over @zakkster/lite-signal. The reactive class layer where an instance has a measured cost, deterministic teardown, and a churn benchmark.",
|
|
5
5
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -66,6 +66,8 @@
|
|
|
66
66
|
"torture:semantic": "node test/torture/run.mjs --group semantic",
|
|
67
67
|
"torture:soak": "node test/torture/run.mjs --group soak",
|
|
68
68
|
"torture:controls": "node test/torture/run.mjs --controls",
|
|
69
|
+
"torture:peer-preview": "node test/torture/peer-preview.mjs",
|
|
70
|
+
"gate": "node test/gate.mjs",
|
|
69
71
|
"spikes": "for f in spikes/*.mjs; do echo \"== $f ==\"; node --expose-gc \"$f\" || exit 1; done",
|
|
70
72
|
"spike:emit": "node --expose-gc spikes/emit/probe.mjs",
|
|
71
73
|
"spike:ownership": "node --expose-gc spikes/ownership.mjs",
|
|
@@ -73,7 +75,11 @@
|
|
|
73
75
|
"spike:manual": "node --expose-gc spikes/manual-call.mjs",
|
|
74
76
|
"spike:poison": "node --expose-gc spikes/poison.mjs",
|
|
75
77
|
"spike:buildless": "node --expose-gc spikes/buildless.mjs",
|
|
76
|
-
"fixtures:regen": "node spikes/emit/regen.mjs"
|
|
78
|
+
"fixtures:regen": "node spikes/emit/regen.mjs",
|
|
79
|
+
"demo:build": "node demo/build.mjs",
|
|
80
|
+
"demo:check": "node demo/build.mjs --check",
|
|
81
|
+
"demo:gc": "node --expose-gc demo/gc-lane.mjs",
|
|
82
|
+
"demo:storm": "node --expose-gc demo/storm-lane.mjs"
|
|
77
83
|
},
|
|
78
84
|
"peerDependencies": {
|
|
79
85
|
"@zakkster/lite-signal": ">=1.5.0 <2.0.0"
|
|
@@ -82,9 +88,12 @@
|
|
|
82
88
|
"@babel/core": "^7.25.0",
|
|
83
89
|
"@babel/plugin-proposal-decorators": "^7.25.0",
|
|
84
90
|
"@babel/preset-typescript": "^7.29.7",
|
|
91
|
+
"@zakkster/lite-devtools": "^1.4.0",
|
|
85
92
|
"@zakkster/lite-gc-profiler": "^1.16.0",
|
|
86
93
|
"@zakkster/lite-leak": "^1.10.0",
|
|
87
94
|
"@zakkster/lite-signal": "1.5.0",
|
|
95
|
+
"@zakkster/lite-watch-ex": "^1.1.0",
|
|
96
|
+
"esbuild": "0.28.2",
|
|
88
97
|
"typescript": "^5.6.0"
|
|
89
98
|
}
|
|
90
99
|
}
|