@zakkster/lite-project 1.0.0 → 1.1.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,125 @@
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.1.1] - 2026-09-03
7
+
8
+ ### Added
9
+
10
+ - **`VERSION`** export from `Project.js` (declared in `Project.d.ts`), kept in
11
+ exact sync with `package.json`, so a consumer can read the shipped version at
12
+ runtime.
13
+ - Gated torture harness under `test/torture/` (dev-only; not shipped). Seven
14
+ tiers run strictly sequentially behind `node --expose-gc test/torture.mjs`:
15
+ T0 metamorphic laws, T1 degenerate inputs, T4 reconcile door, T5 oracle fuzz,
16
+ T6 zero-alloc gate, T7 retention soak, T9 controls. Gate RULES:
17
+ `maxMajor 0`, `maxPauseMs 4`, `maxArrayBuffersGrowth 0` under `stabilize:"deep"`.
18
+ Witnessed on a green run (default seed):
19
+ `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 |
20
+ retained=0.00 B/op growths=0`
21
+ (the `alloc=` per-op heap-bracket reading prints as-is, `n/a` when
22
+ inconclusive; the binding alloc gates are `checkNoGc`, the zero-retention
23
+ gate below, and the structural deltas below).
24
+ The get/set/clear triangle on warmed keys leaves `poolGrowths` and
25
+ `totalAllocations` deltas both 0 over 200k toggles; 4096 build/tear-down cycles
26
+ return the leak tracker to `size()===0`; `prune()` reclaims >= 19990 of 20000
27
+ unbounded-read slots.
28
+ - Retained-allocation gate (`measureAllocs` + `checkAllocs` with
29
+ `maxBytesPerCall: 0`, the profiler's zero-retention assertion): the warmed
30
+ get/set/clear triangle retains 0 B/call (min over 8 batches), catching
31
+ arbitrary JS-object retention that the asynchronously-delivered `gc.major`
32
+ count cannot see; an unsettled or inconclusive reading fails the gate.
33
+
34
+ ### Changed
35
+
36
+ - Peer dependency floor is now `@zakkster/lite-signal ^1.5.0` (was a pinned
37
+ preview build). No behaviour change: 1.5.0 is the current stable line and
38
+ supplies every surface this package uses (`batch` 1.0.0, `hasObservers` 1.1.4,
39
+ `createRoot` 1.5.0).
40
+
41
+ ## [1.1.0] - 2026-07-16
42
+
43
+ ### Added
44
+
45
+ - **`projectQuery(qc, key, opts?)`** — a library adapter projecting ONE
46
+ [`@zakkster/lite-query`](https://www.npmjs.com/package/@zakkster/lite-query)
47
+ entry's data object as a draft overlay whose projected keys are the **fields**
48
+ of that record. Stage optimistic field edits with `set(field, v)`, then
49
+ `commit()` promotes every staged field into the cache as a **single**
50
+ `setQueryData(key, prev => merge(prev, overlays))` write (one cache mutation,
51
+ one broadcast) — `commit(field)` writes just one. Options:
52
+ - `data` — the query's reactive data accessor (e.g. `query.data`). Supplied,
53
+ projected reads track the cache and an auto-reconcile drops drafts the
54
+ authoritative record catches up to (echo policy) while leaving conflicting
55
+ values masked, exactly as in `projectRoom`. Omitted, the adapter degrades to
56
+ a non-reactive `getQueryData` snapshot with no auto-reconcile.
57
+ - `policy` — reconciliation policy (default `confirmOnEcho`).
58
+ - `merge` — how overlays fold into the record (default shallow spread
59
+ `{ ...prev, ...overlays }`; `prev` may be nullish, seeding a fresh record).
60
+
61
+ The query client is consumed **structurally** (any object exposing
62
+ `getQueryData` / `setQueryData`), so this adds no hard dependency on
63
+ lite-query. The returned handle's `dispose()` also stops the reconcile effect.
64
+ No changes to the core or the existing adapters.
65
+
66
+ - **`Projection.prune()`** — bounded-keyspace reclamation. A slot (one overlay
67
+ signal + one projected computed) is created by the first **read** of a key and
68
+ retained until `dispose()`, because its computed may still have subscribers —
69
+ so neither `commit()` nor `revert()` gives any of it back. Over a large or
70
+ unbounded keyspace (a virtualised list, a record whose fields churn, a
71
+ projection driven by user input) that is real growth: 20,000 reads retained
72
+ 60,000 nodes. `prune()` releases only the slots that are **both** un-overlaid
73
+ (nothing staged to lose) and unobserved (no live consumer subscribed to the
74
+ projected read), so it can never dispose a computed out from under a
75
+ subscriber; a pruned key rebuilds transparently on its next read. Returns the
76
+ number of slots freed. Cold path — call it on a viewport change or after a
77
+ commit, not per frame. `O(slots)`. Requires `hasObservers` from the registry;
78
+ a custom registry without it gets a `prune()` that reclaims nothing and
79
+ returns `0` rather than a crash. Available on every projection handle,
80
+ including the `projectStore` / `projectRoom` / `projectQuery` wrappers.
81
+
82
+ ### Fixed
83
+
84
+ Found by the adversarial suite below during the 1.1.0 prepublish review. Every
85
+ one of these failed **silently**: `commit()` returned normally and `dirtyCount()`
86
+ fell to 0 while the value never reached the record.
87
+
88
+ - **A draft field named `__proto__` was dropped — and could inject fields.** The
89
+ default merge built the record with `out[k] = v`, which for `__proto__`
90
+ retargets the prototype instead of creating an own key, so the field vanished.
91
+ Worse, staging that draft set the overlay bag's own prototype, and the merge's
92
+ `for...in` then enumerated *that object's* keys — so committing a `__proto__`
93
+ draft injected its contents as top-level fields of the record. The overlay bag
94
+ is now null-prototype, keys are defined rather than assigned, and iteration is
95
+ own-keys only.
96
+ - **Symbol-keyed drafts evaporated on commit.** Projection keys are
97
+ `PropertyKey` and slots live in a `Map`, so a symbol-keyed draft staged fine
98
+ and reported dirty — then `for...in` skipped it and the commit reported
99
+ success for a value that never landed. The merge now includes own enumerable
100
+ symbols.
101
+ - **Inherited properties leaked into the record.** `for...in` walked `prev`'s
102
+ prototype chain, absorbing inherited properties into the committed record as
103
+ own fields. Own-keys only now.
104
+
105
+ ### Verified
106
+
107
+ - 17 new adversarial tests (`test/torture_test.mjs`); **65 tests total**,
108
+ `node --test`. `prune()` is exercised for reclamation, for refusing to drop
109
+ observed or overlaid slots, and for transparent rebuild after a prune.
110
+
111
+ ### Torture (opt-in: `npm run test:torture`)
112
+
113
+ - `test/torture_test.mjs` — adversarial regression suite, part of the normal
114
+ `npm test`. Each case pins a defect from the list above, or a limit that is
115
+ deliberately **not** fixed and must not drift silently. Node-count tests
116
+ install a fixed-ceiling registry over a node-free source, so the
117
+ projection's own accounting is readable.
118
+ - `bench/torture/overlay-fuzzer.mjs` — seeded, oracle-checked fuzz: projectQuery
119
+ set/clear/commit(field)/commit-all/revert plus external cache writes (driving
120
+ auto-reconcile), asserting the view + cache track an overlay/record oracle and
121
+ every commit is a single write; plus a core project() overlay/commit/revert
122
+ fuzz over a reactive source. Scale with `TORTURE_SCALE`. Dev-only; not in
123
+ `files[]`.
124
+
6
125
  ## [1.0.0] - 2026-06-25
7
126
 
8
127
  First stable release. Zero-GC projections for `@zakkster/lite-signal`.
package/Project.d.ts CHANGED
@@ -1,7 +1,9 @@
1
- // Type declarations for @zakkster/lite-project v1.0.0
1
+ // Type declarations for @zakkster/lite-project v1.1.1
2
2
  // Zero-GC projections for @zakkster/lite-signal.
3
3
  // (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com> -- MIT
4
4
 
5
+ export const VERSION: string;
6
+
5
7
  /**
6
8
  * A reactive keyed source a projection can wrap. Any object with a reactive
7
9
  * `get(key)` and a `set(key, value)` qualifies (the built-in `keyedStore`, a
@@ -61,6 +63,24 @@ export interface Projection<K extends PropertyKey = PropertyKey, V = unknown> {
61
63
  commit(key?: K): void;
62
64
  /** Drop all overlays. */
63
65
  revert(): void;
66
+ /**
67
+ * Release slots for keys that are neither overlaid nor observed, returning
68
+ * how many were freed.
69
+ *
70
+ * A slot (one overlay signal + one projected computed) is created by the
71
+ * first READ of a key and retained until `dispose()`, because its computed
72
+ * may have live subscribers. Over a large or unbounded keyspace that is real
73
+ * growth, and neither `commit()` nor `revert()` gives any of it back.
74
+ *
75
+ * `prune()` is the safe reclamation path: it skips any key with a staged
76
+ * overlay (nothing to lose) and any whose projected read still has
77
+ * observers. A pruned key rebuilds transparently on its next read.
78
+ *
79
+ * Cold path -- call it on a viewport change or after a commit, not per frame.
80
+ * O(slots). Returns 0 on a custom registry that does not supply
81
+ * `hasObservers`.
82
+ */
83
+ prune(): number;
64
84
  /** Recycle every projection-owned node back to the lite-signal pool. */
65
85
  dispose(): void;
66
86
  }
@@ -91,6 +111,18 @@ export interface ProjectorRegistry {
91
111
  createRoot<T>(fn: () => T): T;
92
112
  dispose(handle: unknown): void;
93
113
  untrack<T>(fn: () => T): T;
114
+ /**
115
+ * Optional. Coalesces the multi-signal writes in commit / revert /
116
+ * reconcileAll into one propagation so a multi-key consumer never sees a
117
+ * torn snapshot. Omit it and those writes propagate one at a time.
118
+ */
119
+ batch?<T>(fn: () => T): T;
120
+ /**
121
+ * Optional. Required by {@link Projection.prune}, which uses it to tell a
122
+ * slot nobody is subscribed to from one a consumer still depends on. Omit it
123
+ * and `prune()` safely reclaims nothing and returns 0.
124
+ */
125
+ hasObservers?(handle: unknown): boolean;
94
126
  }
95
127
 
96
128
  /** The registry-bound projection primitives returned by {@link createProjector}. */
@@ -185,3 +217,42 @@ export function projectRoom(
185
217
  room: RoomLike,
186
218
  opts?: ProjectRoomOptions,
187
219
  ): Projection<string, unknown>;
220
+
221
+ /** The subset of a @zakkster/lite-query client that {@link projectQuery} consumes. */
222
+ export interface QueryClientLike {
223
+ /** Non-reactive cache peek for a key. */
224
+ getQueryData(key: unknown): unknown;
225
+ /** Write a key's data; an updater function receives the previous value. */
226
+ setQueryData(key: unknown, valueOrUpdater: unknown | ((prev: unknown) => unknown)): unknown;
227
+ }
228
+
229
+ /** Options for {@link projectQuery}. */
230
+ export interface ProjectQueryOptions<V extends object = Record<PropertyKey, unknown>> {
231
+ /**
232
+ * The query's reactive data accessor (e.g. `query.data`). When supplied,
233
+ * projected reads track the cache and auto-reconcile is armed. Omit to degrade
234
+ * to a non-reactive `getQueryData` snapshot with no auto-reconcile.
235
+ */
236
+ data?: () => V | null | undefined;
237
+ /** Reconciliation policy for auto-reconcile; defaults to {@link confirmOnEcho}. */
238
+ policy?: ReconcilePolicy<keyof V, unknown>;
239
+ /** Fold staged field overlays into the record; defaults to a shallow spread `{ ...prev, ...overlays }`. */
240
+ merge?: (prev: V | null | undefined, overlays: Partial<V>) => V;
241
+ }
242
+
243
+ /**
244
+ * Project ONE @zakkster/lite-query entry's data object as a DRAFT overlay whose
245
+ * projected keys are the FIELDS of that object. `set(field, v)` stages a draft;
246
+ * `commit()` promotes every staged field into the cache as a SINGLE
247
+ * `setQueryData(key, prev => merge(prev, overlays))` write (`commit(field)` writes
248
+ * one). When `opts.data` is supplied, reads track the cache and an auto-reconcile
249
+ * drops drafts the authoritative record catches up to while leaving conflicts
250
+ * masked. The query client is consumed structurally, so there is no hard
251
+ * dependency on lite-query. The returned handle's `dispose()` also stops the
252
+ * reconcile effect.
253
+ */
254
+ export function projectQuery<V extends object = Record<PropertyKey, unknown>>(
255
+ qc: QueryClientLike,
256
+ key: unknown,
257
+ opts?: ProjectQueryOptions<V>,
258
+ ): Projection<keyof V, unknown>;
package/Project.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @zakkster/lite-project v1.0.0 -- zero-GC projections for @zakkster/lite-signal.
2
+ * @zakkster/lite-project v1.1.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,
@@ -56,8 +56,11 @@ import {
56
56
  untrack as _untrack,
57
57
  effect as _effect,
58
58
  batch as _batch,
59
+ hasObservers as _hasObservers,
59
60
  } from "@zakkster/lite-signal";
60
61
 
62
+ export const VERSION = "1.1.1";
63
+
61
64
  // Module-level sentinel for "this key has no overlay". A unique symbol, never a
62
65
  // per-operation allocation. Stored directly in the overlay signal's value slot, so
63
66
  // "absent" and "present with value V" share one node and one field -- no boxing.
@@ -74,6 +77,9 @@ const ABSENT = Symbol("projection.absent");
74
77
  export function createProjector(reg) {
75
78
  const signal = reg.signal;
76
79
  const computed = reg.computed;
80
+ // Optional: only prune() needs it. A custom registry that does not provide it
81
+ // simply gets a prune() that reclaims nothing rather than a crash.
82
+ const hasObservers = reg.hasObservers;
77
83
  const createRoot = reg.createRoot;
78
84
  const dispose = reg.dispose;
79
85
  const untrack = reg.untrack;
@@ -258,6 +264,42 @@ export function createProjector(reg) {
258
264
  if (changed) { dirty = 0; dirtySig.set(0); }
259
265
  });
260
266
  },
267
+ /**
268
+ * Reclaim slots for keys that are no longer in use.
269
+ *
270
+ * A slot is created by the first READ of a key and retained until
271
+ * dispose(), because its computed may have live subscribers. Over a
272
+ * large or unbounded keyspace -- a virtualised list, a query whose
273
+ * record churns, a projection driven by user input -- that is real
274
+ * growth: 20,000 reads retained 60,000 nodes, and neither commit()
275
+ * nor revert() gave any of them back.
276
+ *
277
+ * A slot is only safe to drop when it is BOTH un-overlaid (no staged
278
+ * value to lose) and unobserved (no consumer's computed/effect is
279
+ * subscribed to its read). `hasObservers` is what makes the second
280
+ * half checkable; without it, pruning could dispose a computed out
281
+ * from under a live subscriber.
282
+ *
283
+ * Cold path -- call it on a viewport change or after a commit, not
284
+ * per frame. O(slots).
285
+ *
286
+ * @returns {number} how many slots were released.
287
+ */
288
+ prune: () => {
289
+ if (typeof hasObservers !== "function") return 0;
290
+ let n = 0;
291
+ for (const [key, s] of slots) {
292
+ if (s.ov.peek() !== ABSENT) continue; // a staged draft would be lost
293
+ // Only the READ computed's observers matter. `ov` is private to
294
+ // the slot and is always observed by that very computed, so
295
+ // testing it too would make prune() a permanent no-op.
296
+ if (hasObservers(s.read)) continue; // a consumer is subscribed
297
+ dispose(s.read); dispose(s.ov);
298
+ slots.delete(key);
299
+ n++;
300
+ }
301
+ return n;
302
+ },
261
303
  dispose: () => {
262
304
  // createRoot left these unowned, so nothing auto-disposes them.
263
305
  // Dispose the computed before its overlay so the read never re-evaluates
@@ -282,6 +324,7 @@ const _default = createProjector({
282
324
  dispose: _dispose,
283
325
  untrack: _untrack,
284
326
  batch: _batch,
327
+ hasObservers: _hasObservers,
285
328
  });
286
329
 
287
330
  export const project = _default.project;
@@ -417,3 +460,147 @@ export function projectRoom(room, opts) {
417
460
  dispose: () => { stopReconcile(); view.dispose(); },
418
461
  };
419
462
  }
463
+
464
+ /**
465
+ * Copy one own enumerable property WITHOUT going through assignment.
466
+ * `out[k] = v` retargets the prototype when k is "__proto__" instead of creating
467
+ * an own key, so the field silently vanishes. defineProperty creates a real own
468
+ * key and leaves Object.prototype alone, so the merged record still has a normal
469
+ * prototype for consumers that deepStrictEqual it.
470
+ * @private
471
+ */
472
+ function _put(out, k, v) {
473
+ Object.defineProperty(out, k, { value: v, writable: true, enumerable: true, configurable: true });
474
+ }
475
+
476
+ /**
477
+ * Default merge for projectQuery's commit.
478
+ *
479
+ * Iterates OWN keys only, symbols included. `for...in` was wrong on both counts:
480
+ *
481
+ * - It walks the prototype chain. Combined with the assignment bug above, a
482
+ * draft field named "__proto__" did not merely disappear -- `overlays.__proto__
483
+ * = {pwned:1}` set the overlay bag's PROTOTYPE, and `for...in` then enumerated
484
+ * that object's keys, so committing a "__proto__" draft INJECTED `pwned` as a
485
+ * top-level field of the record. Own-keys iteration plus _put closes both ends.
486
+ * - It skips symbols. project() keys are PropertyKey and slots live in a Map, so
487
+ * a symbol-keyed draft staged fine, reported dirty, then evaporated on commit
488
+ * while dirtyCount fell to 0 -- a "saved" signal for a value that never landed.
489
+ * @private
490
+ */
491
+ const _ownEnumerableKeys = (o) => {
492
+ const keys = Object.keys(o);
493
+ const syms = Object.getOwnPropertySymbols(o);
494
+ for (let i = 0; i < syms.length; i++) {
495
+ if (Object.prototype.propertyIsEnumerable.call(o, syms[i])) keys.push(syms[i]);
496
+ }
497
+ return keys;
498
+ };
499
+
500
+ const _spreadMerge = (prev, overlays) => {
501
+ const out = {};
502
+ if (prev != null) {
503
+ const pk = _ownEnumerableKeys(prev);
504
+ for (let i = 0; i < pk.length; i++) _put(out, pk[i], prev[pk[i]]);
505
+ }
506
+ const ok = _ownEnumerableKeys(overlays);
507
+ for (let i = 0; i < ok.length; i++) _put(out, ok[i], overlays[ok[i]]);
508
+ return out;
509
+ };
510
+
511
+ /**
512
+ * Project ONE @zakkster/lite-query entry's data object as a DRAFT overlay whose
513
+ * projected keys are the FIELDS of that object. This is the optimistic-edit
514
+ * layer for a fetched record: stage field drafts locally, then commit them back
515
+ * into the query cache as a SINGLE `setQueryData` write (one cache mutation, one
516
+ * broadcast, one refetch-eligible change) rather than one write per field.
517
+ *
518
+ * - get(field) reactive read: the draft if staged, else the query field
519
+ * - set(field, v) stage a draft -- the query cache is NOT touched
520
+ * - commit(field?) promote drafts into the cache via ONE setQueryData(key, prev
521
+ * => merge(prev, overlays)); commit() writes all, commit(f) one
522
+ * - revert() discard drafts
523
+ * - auto-reconcile when `opts.data` is supplied, a refetch / external cache
524
+ * write that the policy considers confirmed drops the matching
525
+ * drafts; a CONFLICTING authoritative value leaves the draft
526
+ * masked (the engine's Object.is short-circuit suppresses the
527
+ * flicker), exactly as in projectRoom
528
+ *
529
+ * Reactivity depends on `opts.data`: pass the query's reactive data accessor
530
+ * (e.g. `query.data` from lite-query's `createQuery`) so projected reads track
531
+ * the cache and auto-reconcile is armed. WITHOUT it the adapter degrades to a
532
+ * non-reactive `qc.getQueryData(key)` snapshot for the base read (drafts are
533
+ * still reactive through their overlay signals, but the underlying record is not
534
+ * tracked and there is no auto-reconcile).
535
+ *
536
+ * The query client is consumed structurally -- any object exposing
537
+ * `getQueryData(key)` and `setQueryData(key, valueOrUpdater)` works -- so this
538
+ * adapter adds no hard dependency on lite-query.
539
+ *
540
+ * @param {{getQueryData:Function, setQueryData:Function}} qc A lite-query client.
541
+ * @param {PropertyKey|Array<unknown>} key The query key whose record is projected.
542
+ * @param {{
543
+ * data?: () => (Record<PropertyKey, unknown> | null | undefined),
544
+ * policy?: (authoritative:unknown, draft:unknown, key:PropertyKey)=>boolean,
545
+ * merge?: (prev:(Record<PropertyKey,unknown>|null|undefined), overlays:Record<PropertyKey,unknown>)=>Record<PropertyKey,unknown>,
546
+ * }} [opts]
547
+ * @returns {object} A projection handle whose commit() writes the cache once and
548
+ * whose dispose() also stops the reconcile effect.
549
+ */
550
+ export function projectQuery(qc, key, opts) {
551
+ if (qc == null || typeof qc.getQueryData !== "function" || typeof qc.setQueryData !== "function") {
552
+ throw new TypeError("projectQuery: qc must expose getQueryData(key) and setQueryData(key, valueOrUpdater)");
553
+ }
554
+ const data = opts && typeof opts.data === "function" ? opts.data : null;
555
+ const policy = (opts && opts.policy) || confirmOnEcho;
556
+ const merge = (opts && opts.merge) || _spreadMerge;
557
+
558
+ // Reactive when `data` is supplied (tracks the query accessor); otherwise a
559
+ // non-reactive cache peek. `set` is only reached if a caller drives the base
560
+ // commit path directly; the overridden commit() below never uses it.
561
+ const source = {
562
+ get: (field) => {
563
+ const rec = data ? data() : qc.getQueryData(key);
564
+ return rec == null ? undefined : rec[field];
565
+ },
566
+ set: (field, v) => qc.setQueryData(key, (prev) => {
567
+ const one = Object.create(null);
568
+ _put(one, field, v);
569
+ return merge(prev, one);
570
+ }),
571
+ };
572
+ const view = project(source);
573
+
574
+ // Auto-reconcile: only meaningful when the record read is reactive. Tracks
575
+ // `data()` (never the overlays), so clearing drafts inside reconcileAll does
576
+ // not re-trigger it -> no loop. Mirrors projectRoom.
577
+ const stopReconcile = data
578
+ ? _effect(() => { data(); view.reconcileAll(policy); })
579
+ : null;
580
+
581
+ return {
582
+ ...view,
583
+ // One cache write for the whole burst of field drafts.
584
+ commit: (field) => {
585
+ if (field !== undefined) {
586
+ if (!view.isOverlaid(field)) return;
587
+ const v = view.peek(field);
588
+ const one = Object.create(null);
589
+ _put(one, field, v);
590
+ qc.setQueryData(key, (prev) => merge(prev, one));
591
+ view.clear(field);
592
+ return;
593
+ }
594
+ // Null-prototype bag: `overlays["__proto__"] = v` on a plain object
595
+ // sets the prototype instead of creating a key, which is how a
596
+ // "__proto__" draft used to turn into field injection downstream.
597
+ const overlays = Object.create(null);
598
+ let any = false;
599
+ view.forEachOverlay((f, v) => { _put(overlays, f, v); any = true; });
600
+ if (!any) return;
601
+ qc.setQueryData(key, (prev) => merge(prev, overlays));
602
+ view.revert();
603
+ },
604
+ dispose: () => { if (stopReconcile) stopReconcile(); view.dispose(); },
605
+ };
606
+ }
package/README.md CHANGED
@@ -83,6 +83,10 @@ Updating the dirty count is allocation-free (a single fixed signal per projectio
83
83
 
84
84
  In steady state the projection allocates nothing: toggling an overlay on a key you have already touched reuses its pooled nodes (verified — 200k overlay toggles on warmed keys leave `poolGrowths` and `totalAllocations` flat). The honest non-claim: the *first* touch of a **new** key allocates a slot record, a Map entry, and two pooled nodes (one overlay signal, one projected computed). Warm the keys you churn.
85
85
 
86
+ **Slots outlive `commit()` and `revert()`.** A slot is created by the first *read* of a key and retained until `dispose()`, because its projected computed may still have subscribers — clearing an overlay does not release it. Over a bounded keyspace (a form, a settings panel) that is exactly the point: the nodes are there to be reused. Over a large or unbounded one — a virtualised list, a record whose fields churn, a projection driven by user input — it is real growth that neither `commit()` nor `revert()` gives back.
87
+
88
+ `prune()` <sub>1.1</sub> is the reclamation path. It releases only slots that are **both** un-overlaid (no staged value to lose) and unobserved (no live consumer subscribed to the projected read), so it can never pull a computed out from under a subscriber; a pruned key rebuilds transparently on its next read. It is a cold path — call it on a viewport change or after a commit, never per frame — and it is `O(slots)`. It needs `hasObservers` from the registry to tell an unused slot from a watched one; a custom registry without it gets a `prune()` that safely reclaims nothing and returns `0`.
89
+
86
90
  ## API
87
91
 
88
92
  ### `createProjector(reg) -> { project, keyedStore }`
@@ -107,6 +111,7 @@ Bind the primitives to a lite-signal registry. Pass the default namespace for no
107
111
  | `peek(key)` | untracked effective read (no subscribe) |
108
112
  | `forEachOverlay(fn)` | iterate overlaid keys + values (untracked) |
109
113
  | `reconcileAll(policy?)` | drop overlays the policy confirms against the current source |
114
+ | `prune()` | release slots for keys that are neither overlaid nor observed; returns how many were freed <sub>1.1</sub> |
110
115
  | `dispose()` | recycle every projection-owned node back to the pool |
111
116
 
112
117
  ### `keyedStore(initial?) -> { get, set, has, keys }`
@@ -153,6 +158,23 @@ draft.commit(); // promotes via room.storage.set (writes + sy
153
158
 
154
159
  Room storage is authoritative and CRDT-merged, so the projection is **presentation-only**: it never joins the merge. `set` stages a local draft, `commit()` promotes it through `room.storage.set`, and an auto-reconcile drops drafts once the authoritative value catches up (echo) while leaving a conflicting authoritative value **masked** (no flicker). Because `room.storage` is coarse (a single `entries` signal, a plain non-reactive `get`), the adapter subscribes through `entries()` and the projection inherits that coarse granularity. Call `dispose()` to stop the reconcile effect. Only `room.storage` is projectable this way; sets / lists / texts have non-keyed shapes.
155
160
 
161
+ ### `projectQuery(qc, key, { data, policy, merge })` — optimistic field drafts over [lite-query](https://www.npmjs.com/package/@zakkster/lite-query)
162
+
163
+ ```js
164
+ import { projectQuery } from "@zakkster/lite-project";
165
+
166
+ const query = qc.createQuery(["user", id], fetchUser);
167
+ const draft = projectQuery(qc, ["user", id], { data: query.data });
168
+
169
+ draft.set("name", "Ada"); // optimistic field edit, cache untouched
170
+ draft.set("email", "ada@x.dev");
171
+ draft.commit(); // ONE setQueryData merging both fields back in
172
+ ```
173
+
174
+ Projects a single query entry's data **object**, exposing its **fields** as the projected keys. `commit()` folds every staged field into the cached record in a **single** `setQueryData(key, prev => merge(prev, overlays))` write (one cache mutation, one broadcast), rather than one write per field; `commit(field)` writes just one. Pass the query's reactive `data` accessor so reads track the cache and an auto-reconcile drops drafts a refetch confirms (echo) while masking conflicts — omit it to degrade to a non-reactive `getQueryData` snapshot with no auto-reconcile. `merge` defaults to a shallow spread (a nullish `prev` seeds a fresh record); `policy` defaults to `confirmOnEcho`. The client is consumed structurally (`getQueryData` / `setQueryData`), so there's no hard dependency on lite-query. `dispose()` stops the reconcile effect.
175
+
176
+ The default merge copies **own enumerable** properties, symbols included, and defines them rather than assigning them. That matters for three field names you would otherwise lose silently: a field literally called `__proto__` lands as a real own key (assignment would retarget the prototype and drop it), inherited properties on `prev` are not absorbed into the record, and a symbol-keyed draft survives the commit instead of evaporating while `dirtyCount()` reports it saved. A custom `merge` is on its own for all three.
177
+
156
178
  ## Conventions
157
179
 
158
180
  ESM only. ASCII source. `node:test`. MIT.
package/llms.txt CHANGED
@@ -32,7 +32,14 @@ Peer dependency: @zakkster/lite-signal ^1.5.0 (uses createRoot). ESM only. MIT.
32
32
  get(key) | set(key,value) | clear(key) | commit(key?) [one key or all] | revert() |
33
33
  dirtyCount() [TRACKED reactive] | isDirty() [TRACKED reactive] |
34
34
  isOverlaid(key) | overlaidCount() | peek(key) [untracked effective read] |
35
- forEachOverlay(fn) | reconcileAll(policy?) | dispose() [recycle all owned nodes]
35
+ forEachOverlay(fn) | reconcileAll(policy?) | dispose() [recycle all owned nodes] |
36
+ prune() -> number [1.1] [release slots that are BOTH un-overlaid AND unobserved;
37
+ returns how many were freed. A slot = 1 overlay signal + 1 projected computed,
38
+ created by the first READ of a key and retained until dispose() (its computed may
39
+ have subscribers), so commit()/revert() reclaim nothing. Cold path: viewport change
40
+ or post-commit, never per frame. O(slots). Needs registry.hasObservers; without it
41
+ returns 0 rather than throwing. A pruned key rebuilds on next read. Present on the
42
+ projectStore/projectRoom/projectQuery handles too.]
36
43
 
37
44
  ## Source = any { get(key): reactive, set(key, value) }
38
45
 
@@ -58,6 +65,25 @@ forEachOverlay(fn) | reconcileAll(policy?) | dispose() [recycle all owned nodes]
58
65
  // drafts via the coarse `entries` signal; conflicts
59
66
  // stay masked. dispose() stops the reconcile effect.
60
67
  // CRDT owns the merge; the projection never joins it.
68
+ - projectQuery(qc, key, opts?) // [1.1] lite-query entry. Projects ONE record's
69
+ // FIELDS as draft keys. opts = {data?, policy?, merge?}.
70
+ // set(field,v) stages a draft (cache untouched);
71
+ // commit() folds ALL staged fields into the cache in a
72
+ // SINGLE setQueryData(key, prev => merge(prev, overlays))
73
+ // write (commit(field) writes one). data = the query's
74
+ // reactive data accessor (e.g. query.data) -> reactive
75
+ // reads + auto-reconcile (echo drops confirmed drafts,
76
+ // conflicts masked); OMIT data -> non-reactive
77
+ // getQueryData snapshot, no auto-reconcile. merge default
78
+ // = {...prev,...overlays} (nullish prev seeds a record).
79
+ // qc consumed structurally (getQueryData/setQueryData);
80
+ // no hard lite-query dep. dispose() stops the effect.
81
+ // Default merge copies OWN ENUMERABLE props (symbols
82
+ // included) and DEFINES them (never out[k]=v): a field
83
+ // named "__proto__" lands as a real own key instead of
84
+ // retargeting the prototype, inherited props on prev are
85
+ // not absorbed, and symbol-keyed drafts survive commit.
86
+ // A custom merge owns all three concerns itself.
61
87
 
62
88
  ## Zero-GC
63
89
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-project",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",
@@ -24,10 +24,18 @@
24
24
  "LICENSE.txt"
25
25
  ],
26
26
  "scripts": {
27
- "test": "node --test"
27
+ "test": "node --test test/*_test.mjs",
28
+ "torture": "node --expose-gc test/torture.mjs",
29
+ "verify": "npm test && npm run torture",
30
+ "prepublishOnly": "npm run verify"
28
31
  },
29
32
  "peerDependencies": {
30
- "@zakkster/lite-signal": ">=1.5.0-alpha"
33
+ "@zakkster/lite-signal": "^1.5.0"
34
+ },
35
+ "devDependencies": {
36
+ "@zakkster/lite-gc-profiler": "^1.16.0",
37
+ "@zakkster/lite-leak": "^1.10.0",
38
+ "@zakkster/lite-signal": "^1.5.0"
31
39
  },
32
40
  "keywords": [
33
41
  "reactive",
@@ -38,7 +46,12 @@
38
46
  "overlay",
39
47
  "zero-gc",
40
48
  "esm",
41
- "lite-signal"
49
+ "lite-signal",
50
+ "query",
51
+ "tanstack-query",
52
+ "lite-query",
53
+ "cache",
54
+ "commit"
42
55
  ],
43
56
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
44
57
  "license": "MIT",
@@ -47,6 +60,10 @@
47
60
  "url": "git+https://github.com/PeshoVurtoleta/lite-project.git"
48
61
  },
49
62
  "homepage": "https://github.com/PeshoVurtoleta/lite-project#readme",
63
+ "bugs": {
64
+ "url": "https://github.com/PeshoVurtoleta/lite-project/issues",
65
+ "email": "shinikchiev@yahoo.com"
66
+ },
50
67
  "funding": {
51
68
  "type": "github",
52
69
  "url": "https://github.com/sponsors/PeshoVurtoleta"