@zakkster/lite-signal-decorators 0.2.0 → 0.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 +132 -6
- package/README.md +14 -3
- package/SignalDecorators.d.ts +94 -1
- package/SignalDecorators.js +369 -6
- package/llms.txt +62 -7
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,131 @@ All notable changes to `@zakkster/lite-signal-decorators` are documented here.
|
|
|
4
4
|
The format follows Keep a Changelog; this project adheres to Semantic
|
|
5
5
|
Versioning.
|
|
6
6
|
|
|
7
|
+
## [0.4.0] - 2026-08-26
|
|
8
|
+
|
|
9
|
+
The introspection release: the surface grows 11 -> 16, every addition cold-path
|
|
10
|
+
or opt-in, the hot accessor canon byte-identical to 0.3.0 (review-diffed
|
|
11
|
+
against the published tarball). Stage gate, measured at closeout: 213/213
|
|
12
|
+
tests on both lanes; torture 15 scenarios (13 pass + the two forward-compat
|
|
13
|
+
scenarios legitimately skipping under the installed 1.5.0 peer) with 15/15
|
|
14
|
+
sabotage controls breaking as required; the peer-preview lane SUITE-GREEN
|
|
15
|
+
(15/15, forward scenarios RUNNING) against lite-signal 1.9.0-preview.6 AND
|
|
16
|
+
1.9.0-canary.1; pack 7 files.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `costOf(Factory)` -- the measured, settled per-instance cost on the class's
|
|
21
|
+
bound registry: `{ nodes, links, signals, deriveds, effects }`, frozen and
|
|
22
|
+
cached per class. Quiet-required, floor-verified, DOUBLE-probed with
|
|
23
|
+
identical-deltas-or-throw: an inconclusive probe (a derived whose read set
|
|
24
|
+
changes between runs, a polluted registry) throws a named error, never
|
|
25
|
+
guesses. `nodes` reproduces the 0002 Q3 grid exactly (P + D + E + 1);
|
|
26
|
+
`links` is the first-full-read count.
|
|
27
|
+
- `capacityFor(inventory, { headroom }?)` -- sizes a ready `createRegistry`
|
|
28
|
+
config from `[Factory, count]` pairs: nodes exact, links x `headroom`
|
|
29
|
+
floored at the engine minimum, `prealloc: "eager"`,
|
|
30
|
+
`onCapacityExceeded: "throw"`; fail-closed inventory and options
|
|
31
|
+
validation. The link-headroom policy -- nodes exact; fixed-shape deriveds
|
|
32
|
+
provision exactly at first-full-read; branchy-read deriveds fail LOUD with
|
|
33
|
+
the `headroom` knob as the documented escape -- is recorded with its
|
|
34
|
+
measured evidence in `decisions/0007-capacity-policy.md`, closing 0002's
|
|
35
|
+
open question.
|
|
36
|
+
- `enableLabels(on)` / `labelOf(idOrHandle, registry?)` -- opt-in devtools
|
|
37
|
+
identity (default OFF): per-registry `nodeId -> "Class.prop" /
|
|
38
|
+
"Class#method" / "Class@anchor"` maps, per-class shared label strings,
|
|
39
|
+
dispose unregisters, misses return `undefined`. A feature-detected
|
|
40
|
+
integration test walks lite-devtools `graph()`/`toTree` from `rootOf(vm)`
|
|
41
|
+
and resolves every walked node. The one-line devtools `labelResolver`
|
|
42
|
+
upstream proposal is recorded in `decisions/0008-introspection.md`.
|
|
43
|
+
- `auditReactive(on)` -- opt-in leak auditor (default OFF): a lazily-created
|
|
44
|
+
`FinalizationRegistry` reports any instance collected without
|
|
45
|
+
`disposeReactive`, naming class and shape; it holds no instance references
|
|
46
|
+
itself, unregisters on dispose, and registers nothing while off (proven by
|
|
47
|
+
a child-process `--expose-gc` test).
|
|
48
|
+
- Forward-compat torture: `scope-adoption` (floor 1.6.0) and `using-dispose`
|
|
49
|
+
(floor 1.9.0), written against the REAL probed future engine surfaces
|
|
50
|
+
(1.6.0's `createScope` adoption; 1.9.0's native `[Symbol.dispose]` on
|
|
51
|
+
handles) via a typeof-only feature probe (`test/shared/peer-probe.mjs` --
|
|
52
|
+
never version parsing). Their sabotage controls lie about the probe, so
|
|
53
|
+
they fail loudly even under the current peer. Plus `torture:peer-preview`:
|
|
54
|
+
a scratch-install lane that runs the whole suite against the peer's
|
|
55
|
+
`preview` and `canary` dist-tags and reports per-tag verdicts.
|
|
56
|
+
- Tests: `12-accounting` (11), `13-labels-audit` (10, incl. the child-process
|
|
57
|
+
audit fixture), `14-qa-s4-boundary` (21 adversarial pins), and two
|
|
58
|
+
capacityFor round-trip lanes (node-bound and link-bound) in
|
|
59
|
+
capacity-torture. Suite 171 -> 213. Dev-only devDependency:
|
|
60
|
+
`@zakkster/lite-devtools` (integration walk).
|
|
61
|
+
|
|
62
|
+
### Fixed
|
|
63
|
+
|
|
64
|
+
- (Found in review) A registry passing the 11-method duck-check but lacking
|
|
65
|
+
`stats()` -- constructible from the public surface -- made `costOf` and
|
|
66
|
+
`capacityFor` die with a raw `TypeError` instead of a named error. Now a
|
|
67
|
+
named, ERR-prefixed throw explains that probing needs a `createRegistry()`
|
|
68
|
+
registry with its stats ledger. The falsified "no reachable raw path"
|
|
69
|
+
claim in 0008 is amended with the counterexample preserved, rejection-
|
|
70
|
+
history style.
|
|
71
|
+
- (Found in QA) `capacityFor`'s options guard borrowed decorator-flavored
|
|
72
|
+
usage wording for a plain function call, and accepted an ARRAY as an
|
|
73
|
+
options bag; it now throws the call-form message
|
|
74
|
+
(`options must be a plain object like { headroom: 1.25 }`) for both.
|
|
75
|
+
`null` still means "omitted".
|
|
76
|
+
- (Found writing tests) `capacityFor` on a signals-only inventory produced
|
|
77
|
+
`maxLinks: 0`, which `createRegistry` rejects; the returned links are now
|
|
78
|
+
floored at the engine minimum of 1 so every valid inventory yields a
|
|
79
|
+
constructible config.
|
|
80
|
+
|
|
81
|
+
## [0.3.0] - 2026-08-26
|
|
82
|
+
|
|
83
|
+
The class-reactivity benchmark release. Zero runtime changes: the 11-export
|
|
84
|
+
surface ships logic-identical to 0.2.0 (version constants aside); what this
|
|
85
|
+
release adds is the proof. Stage gate, all measured at closeout: 171/171
|
|
86
|
+
tests on both lanes; torture 13 scenarios / 13 sabotage controls all
|
|
87
|
+
green-and-breakable; the full benchmark matrix 42/42 lanes `sink=ok`, exit 0.
|
|
88
|
+
|
|
89
|
+
### Added
|
|
90
|
+
|
|
91
|
+
- `bench/` -- a private, never-shipped benchmark sub-package: six admitted
|
|
92
|
+
engines (`lsd` decorators, `lsd-define` buildless, the `lite-raw-boxes`
|
|
93
|
+
honesty baseline, MobX 7, signal-utils + signal-polyfill, a hand-rolled
|
|
94
|
+
alien-signals class) x seven class-shaped scenarios (vm-write, fleet-read
|
|
95
|
+
and fleet-tick over 10k instances, cascade 64/16+aggregate, deep-vm 64-deep,
|
|
96
|
+
churn as the headline, retention as gates). Ported lite-signal rig: anti-DCE
|
|
97
|
+
sink with analytic expected-sum oracles (skipped work rejects the lane),
|
|
98
|
+
median-of-5 + min, GC-fenced runs with real delta/retained heap columns,
|
|
99
|
+
machine provenance stamps with resolved adapter versions, a sabotaged-adapter
|
|
100
|
+
self-test proving the harness can fail, and machine-gated effect liveness on
|
|
101
|
+
lifecycle lanes. Candidates `classy-solid` and `@reactively/decorate` probed
|
|
102
|
+
and excluded with recorded one-line blockers (solid's stock-Node SSR
|
|
103
|
+
resolution; legacy-only decorators). Reproducible from a clean clone:
|
|
104
|
+
`cd bench && npm install && npm run bench`.
|
|
105
|
+
- `fleet-soak` -- the 13th torture scenario (soak group): 2000 VMs (16000 pool
|
|
106
|
+
nodes exactly) under sustained ticks with partial churn rotations for a
|
|
107
|
+
wall-clock budget; per-sample F-0, flat retained heap, gcGate maxMajor 0;
|
|
108
|
+
its sabotage control leaks one VM per rotation and is caught at the first
|
|
109
|
+
sample.
|
|
110
|
+
- `decisions/0006-kill-criteria.md` -- the formal verdicts. Criterion 1 (the
|
|
111
|
+
2.0x line vs the hand-written instance-field baseline) CLEARED WITH MARGIN:
|
|
112
|
+
vm-write 0.94x, fleet-read 1.10x (parity, per 0003's tied-layout finding).
|
|
113
|
+
Criterion 2 (churn cleanliness) CLEARED: 0 major and 0 minor collections
|
|
114
|
+
over 4096 lifecycle cycles on every lite lane, pools at floor (asserted
|
|
115
|
+
in-lane), retained settled at-or-below baseline -- and the decorated churn
|
|
116
|
+
loop emits ~12.6x less transient garbage than the hand-rolled equivalent.
|
|
117
|
+
Every competitor number published under the same stamp (AD-6 honesty
|
|
118
|
+
tiering); the wide-shape cascade/deep-vm gap vs raw boxes is recorded as an
|
|
119
|
+
S4 investigation item, not hidden.
|
|
120
|
+
|
|
121
|
+
### Changed
|
|
122
|
+
|
|
123
|
+
- README: the measured-numbers section now cites the cross-framework matrix
|
|
124
|
+
and the 0006 verdicts alongside the package's own committed probes; version
|
|
125
|
+
references bumped.
|
|
126
|
+
- CHANGELOG release links now point at the package `repository`
|
|
127
|
+
(github.com/PeshoVurtoleta/lite-signal-decorators), matching the new
|
|
128
|
+
`repository`/`homepage`/`funding` fields in package.json.
|
|
129
|
+
- `llms.txt` scope note: 0.3.0 adds only dev-side proof; the export surface
|
|
130
|
+
is unchanged and frozen for 0.x.
|
|
131
|
+
|
|
7
132
|
## [0.2.0] - 2026-08-26
|
|
8
133
|
|
|
9
134
|
The adversarial-torture release: the 0.2 surface is frozen for 0.x, proven by
|
|
@@ -54,9 +179,8 @@ gc major=0 minor=0`.
|
|
|
54
179
|
|
|
55
180
|
## [0.2.0-preview.1] - 2026-08-26
|
|
56
181
|
|
|
57
|
-
The full runtime surface. Exports grow from 8 to 11.
|
|
58
|
-
preview
|
|
59
|
-
changes below first shipped with 0.2.0.
|
|
182
|
+
The full runtime surface. Exports grow from 8 to 11. Published under the
|
|
183
|
+
`preview` dist-tag on the same day 0.2.0 went to `latest`.
|
|
60
184
|
|
|
61
185
|
### Added
|
|
62
186
|
|
|
@@ -152,6 +276,8 @@ Initial release -- the decorator core.
|
|
|
152
276
|
- Torture skeleton (`@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`):
|
|
153
277
|
retention, conservation, lifecycle, and zero-GC lanes.
|
|
154
278
|
|
|
155
|
-
[0.
|
|
156
|
-
[0.
|
|
157
|
-
[0.
|
|
279
|
+
[0.4.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.4.0
|
|
280
|
+
[0.3.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.3.0
|
|
281
|
+
[0.2.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.2.0
|
|
282
|
+
[0.2.0-preview.1]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.2.0-preview.1
|
|
283
|
+
[0.1.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -240,12 +240,23 @@ Symbol keys work (`Reflect.ownKeys`). A spec key colliding with an own property
|
|
|
240
240
|
| `boxOf` | `(vm, key) => SignalBox \| ComputedBox` | The live engine box behind a `@reactive`/`@derived` member -- `.peek()`, `.subscribe()`, raw interop. Unknown key: named throw with a did-you-mean. After dispose: `ReactiveDisposedError`. |
|
|
241
241
|
| `rootOf` | `(vm) => NodeDescriptor` | The instance's anchor descriptor -- feeds `forEachOwned` and lite-devtools. Throws `ReactiveDisposedError` after dispose. |
|
|
242
242
|
|
|
243
|
+
### Introspection & audit (0.4.0)
|
|
244
|
+
|
|
245
|
+
| Export | Signature | Behavior |
|
|
246
|
+
|---|---|---|
|
|
247
|
+
| `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. |
|
|
248
|
+
| `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). |
|
|
249
|
+
| `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. |
|
|
250
|
+
| `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. |
|
|
251
|
+
|
|
252
|
+
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).
|
|
253
|
+
|
|
243
254
|
### Errors & constants
|
|
244
255
|
|
|
245
256
|
| Export | Value |
|
|
246
257
|
|---|---|
|
|
247
258
|
| `ReactiveDisposedError` | `extends Error`; `name: "ReactiveDisposedError"`; fields `className`, `key`. Thrown on ANY touch of a disposed instance's surface. |
|
|
248
|
-
| `VERSION` | `"0.
|
|
259
|
+
| `VERSION` | `"0.4.0"` |
|
|
249
260
|
|
|
250
261
|
### The rejection matrix
|
|
251
262
|
|
|
@@ -348,7 +359,7 @@ The gates that hold it (run on every change, all green at 0.2.0):
|
|
|
348
359
|
- Torture: **12/12 scenarios** (zero-GC read/write lanes at `maxMajor 0, maxPauseMs 4`; 4096-cycle leak gate at 0 live / 0 findings / 0 warnings; capacity atomicity at every overflow point; a 300-seed x 20k-op oracle with zero divergences) -- plus **12/12 sabotage controls** proving each gate can actually fail.
|
|
349
360
|
- `churn-soak`: sustained construct/use/dispose for a wall-clock budget; pools at floor and retained heap flat at every sample.
|
|
350
361
|
|
|
351
|
-
The cross-framework
|
|
362
|
+
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
363
|
|
|
353
364
|
</details>
|
|
354
365
|
|
|
@@ -473,7 +484,7 @@ Because an instance whose base-class boxes live in one pool and whose subclass b
|
|
|
473
484
|
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
485
|
|
|
475
486
|
**Where are `costOf`, labels, the audit hook, private members?**
|
|
476
|
-
|
|
487
|
+
The first three landed in 0.4.0 -- `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
488
|
|
|
478
489
|
---
|
|
479
490
|
|
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: "0.4.0";
|
package/SignalDecorators.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zakkster/lite-signal-decorators v0.
|
|
2
|
+
* @zakkster/lite-signal-decorators v0.4.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 = "0.4.0";
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zakkster/lite-signal-decorators
|
|
2
2
|
|
|
3
|
-
VERSION 0.
|
|
3
|
+
VERSION 0.4.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` -- `"0.4.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
|
|
@@ -137,7 +189,10 @@ routed per-registry via `createRegistry()`).
|
|
|
137
189
|
|
|
138
190
|
## Scope note
|
|
139
191
|
|
|
140
|
-
0.
|
|
141
|
-
|
|
142
|
-
`
|
|
143
|
-
|
|
192
|
+
0.4.0 adds the S4 introspection surface -- 16 exports: the 11 runtime
|
|
193
|
+
exports plus `costOf`, `capacityFor`, `enableLabels`, `labelOf`, and
|
|
194
|
+
`auditReactive` (all cold / opt-in; the hot accessor canon is byte-identical to
|
|
195
|
+
0.3.0). Also present since 0.3.0: the dev-side class-reactivity benchmark
|
|
196
|
+
(`bench/`, private, never shipped) and the fleet-soak torture scenario; the
|
|
197
|
+
formal kill-criteria verdicts live in decisions/0006-kill-criteria.md. Not yet
|
|
198
|
+
included: private-member support.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-signal-decorators",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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,7 @@
|
|
|
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",
|
|
69
70
|
"spikes": "for f in spikes/*.mjs; do echo \"== $f ==\"; node --expose-gc \"$f\" || exit 1; done",
|
|
70
71
|
"spike:emit": "node --expose-gc spikes/emit/probe.mjs",
|
|
71
72
|
"spike:ownership": "node --expose-gc spikes/ownership.mjs",
|
|
@@ -82,6 +83,7 @@
|
|
|
82
83
|
"@babel/core": "^7.25.0",
|
|
83
84
|
"@babel/plugin-proposal-decorators": "^7.25.0",
|
|
84
85
|
"@babel/preset-typescript": "^7.29.7",
|
|
86
|
+
"@zakkster/lite-devtools": "^1.4.0",
|
|
85
87
|
"@zakkster/lite-gc-profiler": "^1.16.0",
|
|
86
88
|
"@zakkster/lite-leak": "^1.10.0",
|
|
87
89
|
"@zakkster/lite-signal": "1.5.0",
|