@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 +84 -0
- package/Project.d.ts +70 -1
- package/Project.js +186 -1
- package/README.md +22 -0
- package/llms.txt +27 -1
- package/package.json +10 -4
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.
|
|
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.
|
|
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.
|
|
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": "
|
|
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",
|