@zakkster/lite-project 1.0.0 → 1.1.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 CHANGED
@@ -3,6 +3,90 @@
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.0] - 2026-07-16
7
+
8
+ ### Added
9
+
10
+ - **`projectQuery(qc, key, opts?)`** — a library adapter projecting ONE
11
+ [`@zakkster/lite-query`](https://www.npmjs.com/package/@zakkster/lite-query)
12
+ entry's data object as a draft overlay whose projected keys are the **fields**
13
+ of that record. Stage optimistic field edits with `set(field, v)`, then
14
+ `commit()` promotes every staged field into the cache as a **single**
15
+ `setQueryData(key, prev => merge(prev, overlays))` write (one cache mutation,
16
+ one broadcast) — `commit(field)` writes just one. Options:
17
+ - `data` — the query's reactive data accessor (e.g. `query.data`). Supplied,
18
+ projected reads track the cache and an auto-reconcile drops drafts the
19
+ authoritative record catches up to (echo policy) while leaving conflicting
20
+ values masked, exactly as in `projectRoom`. Omitted, the adapter degrades to
21
+ a non-reactive `getQueryData` snapshot with no auto-reconcile.
22
+ - `policy` — reconciliation policy (default `confirmOnEcho`).
23
+ - `merge` — how overlays fold into the record (default shallow spread
24
+ `{ ...prev, ...overlays }`; `prev` may be nullish, seeding a fresh record).
25
+
26
+ The query client is consumed **structurally** (any object exposing
27
+ `getQueryData` / `setQueryData`), so this adds no hard dependency on
28
+ lite-query. The returned handle's `dispose()` also stops the reconcile effect.
29
+ No changes to the core or the existing adapters.
30
+
31
+ - **`Projection.prune()`** — bounded-keyspace reclamation. A slot (one overlay
32
+ signal + one projected computed) is created by the first **read** of a key and
33
+ retained until `dispose()`, because its computed may still have subscribers —
34
+ so neither `commit()` nor `revert()` gives any of it back. Over a large or
35
+ unbounded keyspace (a virtualised list, a record whose fields churn, a
36
+ projection driven by user input) that is real growth: 20,000 reads retained
37
+ 60,000 nodes. `prune()` releases only the slots that are **both** un-overlaid
38
+ (nothing staged to lose) and unobserved (no live consumer subscribed to the
39
+ projected read), so it can never dispose a computed out from under a
40
+ subscriber; a pruned key rebuilds transparently on its next read. Returns the
41
+ number of slots freed. Cold path — call it on a viewport change or after a
42
+ commit, not per frame. `O(slots)`. Requires `hasObservers` from the registry;
43
+ a custom registry without it gets a `prune()` that reclaims nothing and
44
+ returns `0` rather than a crash. Available on every projection handle,
45
+ including the `projectStore` / `projectRoom` / `projectQuery` wrappers.
46
+
47
+ ### Fixed
48
+
49
+ Found by the adversarial suite below during the 1.1.0 prepublish review. Every
50
+ one of these failed **silently**: `commit()` returned normally and `dirtyCount()`
51
+ fell to 0 while the value never reached the record.
52
+
53
+ - **A draft field named `__proto__` was dropped — and could inject fields.** The
54
+ default merge built the record with `out[k] = v`, which for `__proto__`
55
+ retargets the prototype instead of creating an own key, so the field vanished.
56
+ Worse, staging that draft set the overlay bag's own prototype, and the merge's
57
+ `for...in` then enumerated *that object's* keys — so committing a `__proto__`
58
+ draft injected its contents as top-level fields of the record. The overlay bag
59
+ is now null-prototype, keys are defined rather than assigned, and iteration is
60
+ own-keys only.
61
+ - **Symbol-keyed drafts evaporated on commit.** Projection keys are
62
+ `PropertyKey` and slots live in a `Map`, so a symbol-keyed draft staged fine
63
+ and reported dirty — then `for...in` skipped it and the commit reported
64
+ success for a value that never landed. The merge now includes own enumerable
65
+ symbols.
66
+ - **Inherited properties leaked into the record.** `for...in` walked `prev`'s
67
+ prototype chain, absorbing inherited properties into the committed record as
68
+ own fields. Own-keys only now.
69
+
70
+ ### Verified
71
+
72
+ - 17 new adversarial tests (`test/torture_test.mjs`); **65 tests total**,
73
+ `node --test`. `prune()` is exercised for reclamation, for refusing to drop
74
+ observed or overlaid slots, and for transparent rebuild after a prune.
75
+
76
+ ### Torture (opt-in: `npm run test:torture`)
77
+
78
+ - `test/torture_test.mjs` — adversarial regression suite, part of the normal
79
+ `npm test`. Each case pins a defect from the list above, or a limit that is
80
+ deliberately **not** fixed and must not drift silently. Node-count tests
81
+ install a fixed-ceiling registry over a node-free source, so the
82
+ projection's own accounting is readable.
83
+ - `bench/torture/overlay-fuzzer.mjs` — seeded, oracle-checked fuzz: projectQuery
84
+ set/clear/commit(field)/commit-all/revert plus external cache writes (driving
85
+ auto-reconcile), asserting the view + cache track an overlay/record oracle and
86
+ every commit is a single write; plus a core project() overlay/commit/revert
87
+ fuzz over a reactive source. Scale with `TORTURE_SCALE`. Dev-only; not in
88
+ `files[]`.
89
+
6
90
  ## [1.0.0] - 2026-06-25
7
91
 
8
92
  First stable release. Zero-GC projections for `@zakkster/lite-signal`.
package/Project.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Type declarations for @zakkster/lite-project v1.0.0
1
+ // Type declarations for @zakkster/lite-project v1.1.0
2
2
  // Zero-GC projections for @zakkster/lite-signal.
3
3
  // (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com> -- MIT
4
4
 
@@ -61,6 +61,24 @@ export interface Projection<K extends PropertyKey = PropertyKey, V = unknown> {
61
61
  commit(key?: K): void;
62
62
  /** Drop all overlays. */
63
63
  revert(): void;
64
+ /**
65
+ * Release slots for keys that are neither overlaid nor observed, returning
66
+ * how many were freed.
67
+ *
68
+ * A slot (one overlay signal + one projected computed) is created by the
69
+ * first READ of a key and retained until `dispose()`, because its computed
70
+ * may have live subscribers. Over a large or unbounded keyspace that is real
71
+ * growth, and neither `commit()` nor `revert()` gives any of it back.
72
+ *
73
+ * `prune()` is the safe reclamation path: it skips any key with a staged
74
+ * overlay (nothing to lose) and any whose projected read still has
75
+ * observers. A pruned key rebuilds transparently on its next read.
76
+ *
77
+ * Cold path -- call it on a viewport change or after a commit, not per frame.
78
+ * O(slots). Returns 0 on a custom registry that does not supply
79
+ * `hasObservers`.
80
+ */
81
+ prune(): number;
64
82
  /** Recycle every projection-owned node back to the lite-signal pool. */
65
83
  dispose(): void;
66
84
  }
@@ -91,6 +109,18 @@ export interface ProjectorRegistry {
91
109
  createRoot<T>(fn: () => T): T;
92
110
  dispose(handle: unknown): void;
93
111
  untrack<T>(fn: () => T): T;
112
+ /**
113
+ * Optional. Coalesces the multi-signal writes in commit / revert /
114
+ * reconcileAll into one propagation so a multi-key consumer never sees a
115
+ * torn snapshot. Omit it and those writes propagate one at a time.
116
+ */
117
+ batch?<T>(fn: () => T): T;
118
+ /**
119
+ * Optional. Required by {@link Projection.prune}, which uses it to tell a
120
+ * slot nobody is subscribed to from one a consumer still depends on. Omit it
121
+ * and `prune()` safely reclaims nothing and returns 0.
122
+ */
123
+ hasObservers?(handle: unknown): boolean;
94
124
  }
95
125
 
96
126
  /** The registry-bound projection primitives returned by {@link createProjector}. */
@@ -185,3 +215,42 @@ export function projectRoom(
185
215
  room: RoomLike,
186
216
  opts?: ProjectRoomOptions,
187
217
  ): Projection<string, unknown>;
218
+
219
+ /** The subset of a @zakkster/lite-query client that {@link projectQuery} consumes. */
220
+ export interface QueryClientLike {
221
+ /** Non-reactive cache peek for a key. */
222
+ getQueryData(key: unknown): unknown;
223
+ /** Write a key's data; an updater function receives the previous value. */
224
+ setQueryData(key: unknown, valueOrUpdater: unknown | ((prev: unknown) => unknown)): unknown;
225
+ }
226
+
227
+ /** Options for {@link projectQuery}. */
228
+ export interface ProjectQueryOptions<V extends object = Record<PropertyKey, unknown>> {
229
+ /**
230
+ * The query's reactive data accessor (e.g. `query.data`). When supplied,
231
+ * projected reads track the cache and auto-reconcile is armed. Omit to degrade
232
+ * to a non-reactive `getQueryData` snapshot with no auto-reconcile.
233
+ */
234
+ data?: () => V | null | undefined;
235
+ /** Reconciliation policy for auto-reconcile; defaults to {@link confirmOnEcho}. */
236
+ policy?: ReconcilePolicy<keyof V, unknown>;
237
+ /** Fold staged field overlays into the record; defaults to a shallow spread `{ ...prev, ...overlays }`. */
238
+ merge?: (prev: V | null | undefined, overlays: Partial<V>) => V;
239
+ }
240
+
241
+ /**
242
+ * Project ONE @zakkster/lite-query entry's data object as a DRAFT overlay whose
243
+ * projected keys are the FIELDS of that object. `set(field, v)` stages a draft;
244
+ * `commit()` promotes every staged field into the cache as a SINGLE
245
+ * `setQueryData(key, prev => merge(prev, overlays))` write (`commit(field)` writes
246
+ * one). When `opts.data` is supplied, reads track the cache and an auto-reconcile
247
+ * drops drafts the authoritative record catches up to while leaving conflicts
248
+ * masked. The query client is consumed structurally, so there is no hard
249
+ * dependency on lite-query. The returned handle's `dispose()` also stops the
250
+ * reconcile effect.
251
+ */
252
+ export function projectQuery<V extends object = Record<PropertyKey, unknown>>(
253
+ qc: QueryClientLike,
254
+ key: unknown,
255
+ opts?: ProjectQueryOptions<V>,
256
+ ): 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.0 -- 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,6 +56,7 @@ 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
 
61
62
  // Module-level sentinel for "this key has no overlay". A unique symbol, never a
@@ -74,6 +75,9 @@ const ABSENT = Symbol("projection.absent");
74
75
  export function createProjector(reg) {
75
76
  const signal = reg.signal;
76
77
  const computed = reg.computed;
78
+ // Optional: only prune() needs it. A custom registry that does not provide it
79
+ // simply gets a prune() that reclaims nothing rather than a crash.
80
+ const hasObservers = reg.hasObservers;
77
81
  const createRoot = reg.createRoot;
78
82
  const dispose = reg.dispose;
79
83
  const untrack = reg.untrack;
@@ -258,6 +262,42 @@ export function createProjector(reg) {
258
262
  if (changed) { dirty = 0; dirtySig.set(0); }
259
263
  });
260
264
  },
265
+ /**
266
+ * Reclaim slots for keys that are no longer in use.
267
+ *
268
+ * A slot is created by the first READ of a key and retained until
269
+ * dispose(), because its computed may have live subscribers. Over a
270
+ * large or unbounded keyspace -- a virtualised list, a query whose
271
+ * record churns, a projection driven by user input -- that is real
272
+ * growth: 20,000 reads retained 60,000 nodes, and neither commit()
273
+ * nor revert() gave any of them back.
274
+ *
275
+ * A slot is only safe to drop when it is BOTH un-overlaid (no staged
276
+ * value to lose) and unobserved (no consumer's computed/effect is
277
+ * subscribed to its read). `hasObservers` is what makes the second
278
+ * half checkable; without it, pruning could dispose a computed out
279
+ * from under a live subscriber.
280
+ *
281
+ * Cold path -- call it on a viewport change or after a commit, not
282
+ * per frame. O(slots).
283
+ *
284
+ * @returns {number} how many slots were released.
285
+ */
286
+ prune: () => {
287
+ if (typeof hasObservers !== "function") return 0;
288
+ let n = 0;
289
+ for (const [key, s] of slots) {
290
+ if (s.ov.peek() !== ABSENT) continue; // a staged draft would be lost
291
+ // Only the READ computed's observers matter. `ov` is private to
292
+ // the slot and is always observed by that very computed, so
293
+ // testing it too would make prune() a permanent no-op.
294
+ if (hasObservers(s.read)) continue; // a consumer is subscribed
295
+ dispose(s.read); dispose(s.ov);
296
+ slots.delete(key);
297
+ n++;
298
+ }
299
+ return n;
300
+ },
261
301
  dispose: () => {
262
302
  // createRoot left these unowned, so nothing auto-disposes them.
263
303
  // Dispose the computed before its overlay so the read never re-evaluates
@@ -282,6 +322,7 @@ const _default = createProjector({
282
322
  dispose: _dispose,
283
323
  untrack: _untrack,
284
324
  batch: _batch,
325
+ hasObservers: _hasObservers,
285
326
  });
286
327
 
287
328
  export const project = _default.project;
@@ -417,3 +458,147 @@ export function projectRoom(room, opts) {
417
458
  dispose: () => { stopReconcile(); view.dispose(); },
418
459
  };
419
460
  }
461
+
462
+ /**
463
+ * Copy one own enumerable property WITHOUT going through assignment.
464
+ * `out[k] = v` retargets the prototype when k is "__proto__" instead of creating
465
+ * an own key, so the field silently vanishes. defineProperty creates a real own
466
+ * key and leaves Object.prototype alone, so the merged record still has a normal
467
+ * prototype for consumers that deepStrictEqual it.
468
+ * @private
469
+ */
470
+ function _put(out, k, v) {
471
+ Object.defineProperty(out, k, { value: v, writable: true, enumerable: true, configurable: true });
472
+ }
473
+
474
+ /**
475
+ * Default merge for projectQuery's commit.
476
+ *
477
+ * Iterates OWN keys only, symbols included. `for...in` was wrong on both counts:
478
+ *
479
+ * - It walks the prototype chain. Combined with the assignment bug above, a
480
+ * draft field named "__proto__" did not merely disappear -- `overlays.__proto__
481
+ * = {pwned:1}` set the overlay bag's PROTOTYPE, and `for...in` then enumerated
482
+ * that object's keys, so committing a "__proto__" draft INJECTED `pwned` as a
483
+ * top-level field of the record. Own-keys iteration plus _put closes both ends.
484
+ * - It skips symbols. project() keys are PropertyKey and slots live in a Map, so
485
+ * a symbol-keyed draft staged fine, reported dirty, then evaporated on commit
486
+ * while dirtyCount fell to 0 -- a "saved" signal for a value that never landed.
487
+ * @private
488
+ */
489
+ const _ownEnumerableKeys = (o) => {
490
+ const keys = Object.keys(o);
491
+ const syms = Object.getOwnPropertySymbols(o);
492
+ for (let i = 0; i < syms.length; i++) {
493
+ if (Object.prototype.propertyIsEnumerable.call(o, syms[i])) keys.push(syms[i]);
494
+ }
495
+ return keys;
496
+ };
497
+
498
+ const _spreadMerge = (prev, overlays) => {
499
+ const out = {};
500
+ if (prev != null) {
501
+ const pk = _ownEnumerableKeys(prev);
502
+ for (let i = 0; i < pk.length; i++) _put(out, pk[i], prev[pk[i]]);
503
+ }
504
+ const ok = _ownEnumerableKeys(overlays);
505
+ for (let i = 0; i < ok.length; i++) _put(out, ok[i], overlays[ok[i]]);
506
+ return out;
507
+ };
508
+
509
+ /**
510
+ * Project ONE @zakkster/lite-query entry's data object as a DRAFT overlay whose
511
+ * projected keys are the FIELDS of that object. This is the optimistic-edit
512
+ * layer for a fetched record: stage field drafts locally, then commit them back
513
+ * into the query cache as a SINGLE `setQueryData` write (one cache mutation, one
514
+ * broadcast, one refetch-eligible change) rather than one write per field.
515
+ *
516
+ * - get(field) reactive read: the draft if staged, else the query field
517
+ * - set(field, v) stage a draft -- the query cache is NOT touched
518
+ * - commit(field?) promote drafts into the cache via ONE setQueryData(key, prev
519
+ * => merge(prev, overlays)); commit() writes all, commit(f) one
520
+ * - revert() discard drafts
521
+ * - auto-reconcile when `opts.data` is supplied, a refetch / external cache
522
+ * write that the policy considers confirmed drops the matching
523
+ * drafts; a CONFLICTING authoritative value leaves the draft
524
+ * masked (the engine's Object.is short-circuit suppresses the
525
+ * flicker), exactly as in projectRoom
526
+ *
527
+ * Reactivity depends on `opts.data`: pass the query's reactive data accessor
528
+ * (e.g. `query.data` from lite-query's `createQuery`) so projected reads track
529
+ * the cache and auto-reconcile is armed. WITHOUT it the adapter degrades to a
530
+ * non-reactive `qc.getQueryData(key)` snapshot for the base read (drafts are
531
+ * still reactive through their overlay signals, but the underlying record is not
532
+ * tracked and there is no auto-reconcile).
533
+ *
534
+ * The query client is consumed structurally -- any object exposing
535
+ * `getQueryData(key)` and `setQueryData(key, valueOrUpdater)` works -- so this
536
+ * adapter adds no hard dependency on lite-query.
537
+ *
538
+ * @param {{getQueryData:Function, setQueryData:Function}} qc A lite-query client.
539
+ * @param {PropertyKey|Array<unknown>} key The query key whose record is projected.
540
+ * @param {{
541
+ * data?: () => (Record<PropertyKey, unknown> | null | undefined),
542
+ * policy?: (authoritative:unknown, draft:unknown, key:PropertyKey)=>boolean,
543
+ * merge?: (prev:(Record<PropertyKey,unknown>|null|undefined), overlays:Record<PropertyKey,unknown>)=>Record<PropertyKey,unknown>,
544
+ * }} [opts]
545
+ * @returns {object} A projection handle whose commit() writes the cache once and
546
+ * whose dispose() also stops the reconcile effect.
547
+ */
548
+ export function projectQuery(qc, key, opts) {
549
+ if (qc == null || typeof qc.getQueryData !== "function" || typeof qc.setQueryData !== "function") {
550
+ throw new TypeError("projectQuery: qc must expose getQueryData(key) and setQueryData(key, valueOrUpdater)");
551
+ }
552
+ const data = opts && typeof opts.data === "function" ? opts.data : null;
553
+ const policy = (opts && opts.policy) || confirmOnEcho;
554
+ const merge = (opts && opts.merge) || _spreadMerge;
555
+
556
+ // Reactive when `data` is supplied (tracks the query accessor); otherwise a
557
+ // non-reactive cache peek. `set` is only reached if a caller drives the base
558
+ // commit path directly; the overridden commit() below never uses it.
559
+ const source = {
560
+ get: (field) => {
561
+ const rec = data ? data() : qc.getQueryData(key);
562
+ return rec == null ? undefined : rec[field];
563
+ },
564
+ set: (field, v) => qc.setQueryData(key, (prev) => {
565
+ const one = Object.create(null);
566
+ _put(one, field, v);
567
+ return merge(prev, one);
568
+ }),
569
+ };
570
+ const view = project(source);
571
+
572
+ // Auto-reconcile: only meaningful when the record read is reactive. Tracks
573
+ // `data()` (never the overlays), so clearing drafts inside reconcileAll does
574
+ // not re-trigger it -> no loop. Mirrors projectRoom.
575
+ const stopReconcile = data
576
+ ? _effect(() => { data(); view.reconcileAll(policy); })
577
+ : null;
578
+
579
+ return {
580
+ ...view,
581
+ // One cache write for the whole burst of field drafts.
582
+ commit: (field) => {
583
+ if (field !== undefined) {
584
+ if (!view.isOverlaid(field)) return;
585
+ const v = view.peek(field);
586
+ const one = Object.create(null);
587
+ _put(one, field, v);
588
+ qc.setQueryData(key, (prev) => merge(prev, one));
589
+ view.clear(field);
590
+ return;
591
+ }
592
+ // Null-prototype bag: `overlays["__proto__"] = v` on a plain object
593
+ // sets the prototype instead of creating a key, which is how a
594
+ // "__proto__" draft used to turn into field injection downstream.
595
+ const overlays = Object.create(null);
596
+ let any = false;
597
+ view.forEachOverlay((f, v) => { _put(overlays, f, v); any = true; });
598
+ if (!any) return;
599
+ qc.setQueryData(key, (prev) => merge(prev, overlays));
600
+ view.revert();
601
+ },
602
+ dispose: () => { if (stopReconcile) stopReconcile(); view.dispose(); },
603
+ };
604
+ }
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.0",
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,11 @@
24
24
  "LICENSE.txt"
25
25
  ],
26
26
  "scripts": {
27
- "test": "node --test"
27
+ "test": "node --test",
28
+ "test:torture": "node bench/torture/overlay-fuzzer.mjs"
28
29
  },
29
30
  "peerDependencies": {
30
- "@zakkster/lite-signal": ">=1.5.0-alpha"
31
+ "@zakkster/lite-signal": "^1.6.0-preview.2"
31
32
  },
32
33
  "keywords": [
33
34
  "reactive",
@@ -38,7 +39,12 @@
38
39
  "overlay",
39
40
  "zero-gc",
40
41
  "esm",
41
- "lite-signal"
42
+ "lite-signal",
43
+ "query",
44
+ "tanstack-query",
45
+ "lite-query",
46
+ "cache",
47
+ "commit"
42
48
  ],
43
49
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
44
50
  "license": "MIT",