@zakkster/lite-project 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,35 @@
3
3
  All notable changes to `@zakkster/lite-project` are documented here. The format
4
4
  follows Keep a Changelog; this project adheres to semantic versioning.
5
5
 
6
+ ## [1.4.1] - 2026-09-05
7
+
8
+ ### Fixed
9
+
10
+ - **Hot-path transient allocation (~40 B/op on `get`/`peek`/`set`).** The slot-
11
+ creation closure lived inline in `slotFor`'s cold miss branch and captured
12
+ `key`, so V8 allocated a context object on EVERY `slotFor` call -- hit or
13
+ miss -- taxing the three hottest operations ~40 B/op each (measured: warm
14
+ `get` 2,002,808 B over 50,000 ops). `peek` had the same defect twice over via
15
+ inline `untrack(() => source.get(key))` closures, allocating even on the warm
16
+ overlaid path that never takes the fallthrough; `reconcileAll` once per
17
+ overlaid key. Fixes: slot creation hoisted to `_createSlot(key)` (context now
18
+ allocated only on the cold miss), `peek` and `reconcileAll` ride the hoisted
19
+ `_pk`/`_readSrc` scratch that `forEachPatch` already used. Measured after:
20
+ 0.04-0.15 B/op fixed noise across all warm windows. No API or behaviour
21
+ change.
22
+
23
+ ### Added
24
+
25
+ - **T6 Proof 0, the transient witness.** Warm `get` / `peek` / `set` /
26
+ `set-clear toggle` / `get+set+clear` triangle windows are now hard-gated by
27
+ the V8 new-space used-bytes delta over a GC-free 50,000-op window
28
+ (<= 16,384 B total each). Every prior lane -- the gc-profiler heap gate, the
29
+ retained-bytes bracket, the pool census -- is structurally blind to per-op
30
+ garbage that never survives a collection, which is how the 40 B/op defect
31
+ above passed the full gate. The GATE line now reports `transient=<n> B/op`
32
+ (triangle; measured 0.131 B/op). Falsified: reverting the fix makes the gate
33
+ exit 1 naming the window.
34
+
6
35
  ## [1.4.0] - 2026-09-05
7
36
 
8
37
  ### Added
package/Project.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Type declarations for @zakkster/lite-project v1.4.0
1
+ // Type declarations for @zakkster/lite-project v1.4.1
2
2
  // Zero-GC projections for @zakkster/lite-signal.
3
3
  // (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com> -- MIT
4
4
 
package/Project.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @zakkster/lite-project v1.4.0 -- zero-GC projections for @zakkster/lite-signal.
2
+ * @zakkster/lite-project v1.4.1 -- zero-GC projections for @zakkster/lite-signal.
3
3
  * -----------------------------------------------------------------------------
4
4
  * A projection is a granular, derived, NON-MUTATING reactive view over a keyed
5
5
  * source: a lens that can carry ephemeral overlays (optimistic edits, merges,
@@ -89,7 +89,7 @@ import {
89
89
  hasObservers as _hasObservers,
90
90
  } from "@zakkster/lite-signal";
91
91
 
92
- export const VERSION = "1.4.0";
92
+ export const VERSION = "1.4.1";
93
93
 
94
94
  // Module-level sentinel for "this key has no overlay". A unique symbol, never a
95
95
  // per-operation allocation. Stored directly in the overlay signal's value slot, so
@@ -201,23 +201,29 @@ export function createProjector(reg) {
201
201
  // Invariant: exp !== 0 implies overlaid, so prune() never orphans a deadline.
202
202
  const slots = new Map();
203
203
 
204
+ // Slot creation lives in its OWN function, never inline in slotFor: the
205
+ // creation closure captures `key`, and a capture inside slotFor's scope
206
+ // would make V8 allocate a context object on EVERY slotFor call -- hit or
207
+ // miss -- taxing get/peek/set ~40 B/op. Here the context is allocated only
208
+ // on the cold miss. (Bytes in a hot body: a closure in a cold branch still
209
+ // costs the hot branch its context.)
210
+ const _createSlot = (key) => {
211
+ // Detach owner+observer for creation: these nodes outlive the consumer
212
+ // that first reads `key`, and the projection -- not that consumer --
213
+ // owns their disposal. (See header: OWNERSHIP.)
214
+ return createRoot(() => {
215
+ const ov = signal(ABSENT);
216
+ const read = computed(() => {
217
+ const o = ov(); // track the overlay
218
+ const base = source.get(key); // track the source cell too
219
+ return o === ABSENT ? base : o;
220
+ });
221
+ return { ov, read, exp: 0 };
222
+ });
223
+ };
204
224
  const slotFor = (key) => {
205
225
  let s = slots.get(key);
206
- if (s === undefined) {
207
- // Detach owner+observer for creation: these nodes outlive the consumer
208
- // that first reads `key`, and the projection -- not that consumer --
209
- // owns their disposal. (See header: OWNERSHIP.)
210
- s = createRoot(() => {
211
- const ov = signal(ABSENT);
212
- const read = computed(() => {
213
- const o = ov(); // track the overlay
214
- const base = source.get(key); // track the source cell too
215
- return o === ABSENT ? base : o;
216
- });
217
- return { ov, read, exp: 0 };
218
- });
219
- slots.set(key, s);
220
- }
226
+ if (s === undefined) { s = _createSlot(key); slots.set(key, s); }
221
227
  return s;
222
228
  };
223
229
 
@@ -371,11 +377,17 @@ export function createProjector(reg) {
371
377
  isDirty: () => dirtySig() > 0,
372
378
  // Untracked effective read (overlay if set, else source) -- for
373
379
  // reconciliation policies and imperative inspection, without subscribing.
380
+ // Source fallthrough rides the hoisted _pk/_readSrc scratch (the
381
+ // forEachPatch precedent): an inline untrack closure would capture
382
+ // `key` and cost EVERY peek a context allocation -- including the warm
383
+ // overlaid path that never takes the fallthrough.
374
384
  peek: (key) => {
375
385
  const s = slots.get(key);
376
- if (s === undefined) return untrack(() => source.get(key));
386
+ if (s === undefined) { _pk = key; return untrack(_readSrc); }
377
387
  const o = s.ov.peek();
378
- return o === ABSENT ? untrack(() => source.get(key)) : o;
388
+ if (o !== ABSENT) return o;
389
+ _pk = key;
390
+ return untrack(_readSrc);
379
391
  },
380
392
  // Iterate currently-overlaid keys (untracked). Cold path.
381
393
  forEachOverlay: (fn) => {
@@ -407,7 +419,10 @@ export function createProjector(reg) {
407
419
  for (const [key, s] of slots) {
408
420
  const o = s.ov.peek();
409
421
  if (o !== ABSENT) {
410
- const authoritative = untrack(() => source.get(key));
422
+ // _pk/_readSrc scratch (the forEachPatch precedent): an
423
+ // inline untrack closure would allocate per overlaid key.
424
+ _pk = key;
425
+ const authoritative = untrack(_readSrc);
411
426
  if (pol(authoritative, o, key)) { s.ov.set(ABSENT); _dropExp(s); dropped++; }
412
427
  }
413
428
  }
package/llms.txt CHANGED
@@ -147,7 +147,11 @@ prune() -> number [1.1] [release slots that are BOTH un-overlaid AND unobserved;
147
147
 
148
148
  Steady state: re-overlaying a warmed key reuses pooled nodes (200k toggles ->
149
149
  poolGrowths/totalAllocations flat). First touch of a NEW key allocates its slot,
150
- a Map entry, and two pooled nodes. Warm the keys you churn.
150
+ a Map entry, and two pooled nodes. Warm the keys you churn. Gated transient-clean
151
+ since 1.4.1: warm get/peek/set/toggle windows measured by V8 new-space delta
152
+ (<= 16384 B total per 50k-op window; measured ~0.13 B/op triangle noise). 1.4.0
153
+ allocated ~40 B/op on get/peek/set via a hot-path closure context -- fixed and
154
+ now impossible to reintroduce silently.
151
155
 
152
156
  ## Gotchas
153
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-project",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Zero-GC projections for @zakkster/lite-signal: granular, derived, non-mutating reactive overlays with commit / revert / reconcile, and draft adapters for lite-store and lite-room.",
5
5
  "type": "module",
6
6
  "main": "./Project.js",