@zakkster/lite-project 1.1.0 → 1.2.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,91 @@
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.2.0] - 2026-09-05
7
+
8
+ ### Added
9
+
10
+ - **`Projection.forEachPatch(fn, skip?)`** -- emit the staged drafts as a
11
+ `(key, from, to)` stream, where `from` is the **untracked** current source
12
+ value and `to` the staged overlay. Read-only and untracked: it touches neither
13
+ the source nor the overlays and subscribes the caller to nothing, so it is safe
14
+ inside an effect. Visits exactly the overlaid keys, in `forEachOverlay` order,
15
+ with a **zero-allocation** per-key body (the source read is hoisted through one
16
+ closure per projection, never one per key). The optional `skip` reuses the
17
+ `ReconcilePolicy` shape `(from, to, key) => boolean` -- pass `confirmOnEcho` to
18
+ drop unchanged drafts. A throwing `source.get` propagates on the offending key
19
+ with the overlay bag intact (no writes happen anywhere in the call).
20
+ - **`Projection.toPatch(skip?)`** -- the cold convenience that materializes the
21
+ same stream as `[{ key, from, to }, ...]` (same visit set, order, and values).
22
+ The per-key record is this form's documented allocation; reach for
23
+ `forEachPatch` when you need the zero-alloc callback. Both methods are present
24
+ on the `projectStore` / `projectRoom` / `projectQuery` handles (for
25
+ `projectQuery`, `from` is the cached record's field value; for `projectRoom`,
26
+ `room.storage.get`).
27
+ - **`Patch<K, V>`** interface (`{ key, from, to }`) exported from `Project.d.ts`.
28
+
29
+ **The decision -- unchanged drafts are emitted by default.** An overlaid key is
30
+ emitted whether or not `Object.is(from, to)`. This keeps the visit set
31
+ definitionally equal to `forEachOverlay`, `dirtyCount()`, and `commit()`'s write
32
+ set, so `toPatch().length === dirtyCount()` always holds; a patch consumer is a
33
+ protocol (an LWW-Map op, a CRDT timestamp bump, an HTTP PATCH field), not a diff
34
+ viewer, so dropping an unchanged key would be silent data loss one layer out.
35
+ Callers who want the filter pass `forEachPatch(fn, confirmOnEcho)`. Recorded in
36
+ `decisions/0001-patch-emission.md` (dev-only; not shipped).
37
+
38
+ ### Verified
39
+
40
+ - 17 new `test/patch_test.mjs` cases (visit-set exactness + order, from/to vs
41
+ source and overlay, `toPatch()` == the callback stream, the emit-by-default and
42
+ echo-skip pins, the tracking contract, `__proto__` / symbol / numeric keys,
43
+ `undefined` / `NaN` / `-0` under `Object.is`, fail-closed on a throwing
44
+ `source.get`, patch-apply == commit, and all four adapter handles); **84 tests
45
+ total**, `node --test`.
46
+ - Torture green (default seed):
47
+ `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 |
48
+ alloc=n/a retained=0.00 B/op growths=0`. T5 gains the metamorphic law (the
49
+ emitted patch applied to a fresh source copy == `commit()` into it, across the
50
+ fuzz corpus incl. object drafts). T6 gains Proof 4 -- `forEachPatch` over a
51
+ warm overlaid set passes both the heap gate (`maxMajor 0`, `maxPauseMs 4`,
52
+ `maxArrayBuffersGrowth 0`) and the zero-retention gate (`maxBytesPerCall 0`).
53
+ T9 gains control (g): a per-visit-allocating emitter body demonstrably trips the
54
+ retained-alloc gate through the same helper.
55
+
56
+ ## [1.1.1] - 2026-09-03
57
+
58
+ ### Added
59
+
60
+ - **`VERSION`** export from `Project.js` (declared in `Project.d.ts`), kept in
61
+ exact sync with `package.json`, so a consumer can read the shipped version at
62
+ runtime.
63
+ - Gated torture harness under `test/torture/` (dev-only; not shipped). Seven
64
+ tiers run strictly sequentially behind `node --expose-gc test/torture.mjs`:
65
+ T0 metamorphic laws, T1 degenerate inputs, T4 reconcile door, T5 oracle fuzz,
66
+ T6 zero-alloc gate, T7 retention soak, T9 controls. Gate RULES:
67
+ `maxMajor 0`, `maxPauseMs 4`, `maxArrayBuffersGrowth 0` under `stabilize:"deep"`.
68
+ Witnessed on a green run (default seed):
69
+ `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 |
70
+ retained=0.00 B/op growths=0`
71
+ (the `alloc=` per-op heap-bracket reading prints as-is, `n/a` when
72
+ inconclusive; the binding alloc gates are `checkNoGc`, the zero-retention
73
+ gate below, and the structural deltas below).
74
+ The get/set/clear triangle on warmed keys leaves `poolGrowths` and
75
+ `totalAllocations` deltas both 0 over 200k toggles; 4096 build/tear-down cycles
76
+ return the leak tracker to `size()===0`; `prune()` reclaims >= 19990 of 20000
77
+ unbounded-read slots.
78
+ - Retained-allocation gate (`measureAllocs` + `checkAllocs` with
79
+ `maxBytesPerCall: 0`, the profiler's zero-retention assertion): the warmed
80
+ get/set/clear triangle retains 0 B/call (min over 8 batches), catching
81
+ arbitrary JS-object retention that the asynchronously-delivered `gc.major`
82
+ count cannot see; an unsettled or inconclusive reading fails the gate.
83
+
84
+ ### Changed
85
+
86
+ - Peer dependency floor is now `@zakkster/lite-signal ^1.5.0` (was a pinned
87
+ preview build). No behaviour change: 1.5.0 is the current stable line and
88
+ supplies every surface this package uses (`batch` 1.0.0, `hasObservers` 1.1.4,
89
+ `createRoot` 1.5.0).
90
+
6
91
  ## [1.1.0] - 2026-07-16
7
92
 
8
93
  ### Added
package/Project.d.ts CHANGED
@@ -1,7 +1,9 @@
1
- // Type declarations for @zakkster/lite-project v1.1.0
1
+ // Type declarations for @zakkster/lite-project v1.2.0
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
@@ -23,6 +25,17 @@ export interface ProjectionSource<K extends PropertyKey = PropertyKey, V = unkno
23
25
  export type ReconcilePolicy<K extends PropertyKey = PropertyKey, V = unknown> =
24
26
  (authoritative: V, overlayValue: V, key: K) => boolean;
25
27
 
28
+ /**
29
+ * One staged draft as a patch entry: the current source value (`from`) and the
30
+ * staged overlay value (`to`) for `key`. The materialized shape returned by
31
+ * {@link Projection.toPatch}.
32
+ */
33
+ export interface Patch<K extends PropertyKey = PropertyKey, V = unknown> {
34
+ key: K;
35
+ from: V;
36
+ to: V;
37
+ }
38
+
26
39
  /**
27
40
  * A projection handle: a granular, derived, non-mutating draft overlay over a
28
41
  * keyed source. Each touched key owns one overlay signal + one projected
@@ -51,6 +64,26 @@ export interface Projection<K extends PropertyKey = PropertyKey, V = unknown> {
51
64
  peek(key: K): V;
52
65
  /** Iterate currently-overlaid keys with their overlay values (untracked). */
53
66
  forEachOverlay(fn: (key: K, value: V) => void): void;
67
+ /**
68
+ * Emit the staged drafts as a patch stream `fn(key, from, to)` -- `from` is
69
+ * the UNTRACKED current source value, `to` the staged overlay. Read-only and
70
+ * untracked: it touches neither the source nor the overlays and subscribes the
71
+ * caller to nothing, so it is safe inside an effect. Visits exactly the
72
+ * overlaid keys, in {@link Projection.forEachOverlay} order, with a zero-alloc
73
+ * per-key body. An overlaid key is emitted whether or not `Object.is(from, to)`
74
+ * (the visit set stays equal to `dirtyCount()`); pass `skip` -- the same
75
+ * predicate shape reconcile uses, e.g. {@link confirmOnEcho} -- to drop
76
+ * unchanged drafts. A throwing `source.get` propagates on that key with the
77
+ * overlay bag intact (callers needing atomicity use {@link Projection.toPatch}).
78
+ */
79
+ forEachPatch(fn: (key: K, from: V, to: V) => void, skip?: ReconcilePolicy<K, V>): void;
80
+ /**
81
+ * Cold convenience over {@link Projection.forEachPatch}: materialize the drafts
82
+ * as `[{ key, from, to }, ...]` -- same visit set, order, and values. The
83
+ * per-key record is this form's allocation; reach for `forEachPatch` when you
84
+ * need the zero-alloc callback.
85
+ */
86
+ toPatch(skip?: ReconcilePolicy<K, V>): Array<Patch<K, V>>;
54
87
  /**
55
88
  * Full-snapshot reconciliation: drop every overlay the policy considers
56
89
  * confirmed against the current (untracked) source value. Presentation-only --
package/Project.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @zakkster/lite-project v1.1.0 -- zero-GC projections for @zakkster/lite-signal.
2
+ * @zakkster/lite-project v1.2.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,
@@ -21,6 +21,10 @@
21
21
  * circuit -- does NOT churn downstream consumers. The optimistic value
22
22
  * is stable under source noise.
23
23
  *
24
+ * Patch emission (forEachPatch / toPatch) exposes the staged drafts as a
25
+ * (key, from, to) stream for a save/sync trigger. It is READ-ONLY and UNTRACKED:
26
+ * it never touches the source or the overlays and subscribes the caller to nothing.
27
+ *
24
28
  * -- OWNERSHIP (why createRoot) --
25
29
  * Per-key nodes are created LAZILY, on the first get/set of a key -- which happens
26
30
  * inside whatever consumer effect first reads that key. Without detachment the
@@ -59,6 +63,8 @@ import {
59
63
  hasObservers as _hasObservers,
60
64
  } from "@zakkster/lite-signal";
61
65
 
66
+ export const VERSION = "1.2.0";
67
+
62
68
  // Module-level sentinel for "this key has no overlay". A unique symbol, never a
63
69
  // per-operation allocation. Stored directly in the overlay signal's value slot, so
64
70
  // "absent" and "present with value V" share one node and one field -- no boxing.
@@ -127,6 +133,8 @@ export function createProjector(reg) {
127
133
  * isDirty:()=>boolean, // TRACKED: any staged overlays? (reactive)
128
134
  * peek:(key:PropertyKey)=>unknown, // untracked effective read (no subscribe)
129
135
  * forEachOverlay:(fn:(key:PropertyKey, value:unknown)=>void)=>void, // iterate overlaid keys (untracked)
136
+ * forEachPatch:(fn:(key:PropertyKey, from:unknown, to:unknown)=>void, skip?:Function)=>void, // patch stream (untracked, read-only)
137
+ * toPatch:(skip?:Function)=>Array<{key:PropertyKey, from:unknown, to:unknown}>, // materialized patch (cold convenience)
130
138
  * reconcileAll:(policy?:(authoritative:unknown, overlayValue:unknown, key:PropertyKey)=>boolean)=>void, // drop confirmed overlays
131
139
  * commit:(key?:PropertyKey)=>void, // write one key's overlay, or all, into the source then clear
132
140
  * revert:()=>void, // drop all overlays
@@ -158,6 +166,28 @@ export function createProjector(reg) {
158
166
  return s;
159
167
  };
160
168
 
169
+ // Patch emission: iterate exactly the overlaid keys, handing scalars
170
+ // (key, from, to) to `fn` -- `from` is the UNTRACKED current source value,
171
+ // `to` the staged overlay. Read-only: overlays via .peek(), source under
172
+ // untrack, so calling this inside an effect subscribes to nothing. The
173
+ // visit set / order is byte-identical to forEachOverlay. Optional `skip`
174
+ // reuses ReconcilePolicy: (from, to, key) => true drops that key from the
175
+ // stream (e.g. `forEachPatch(fn, confirmOnEcho)` skips echoes); default
176
+ // undefined emits every overlaid key, changed or not. A throwing
177
+ // source.get propagates on the offending key with no writes anywhere, so
178
+ // the overlay bag is intact by construction (fn may already have run for
179
+ // earlier keys; callers needing atomicity use toPatch()).
180
+ const forEachPatch = (fn, skip) => {
181
+ for (const [key, s] of slots) {
182
+ const to = s.ov.peek();
183
+ if (to === ABSENT) continue;
184
+ _pk = key;
185
+ const from = untrack(_readSrc);
186
+ if (skip !== undefined && skip(from, to, key)) continue;
187
+ fn(key, from, to);
188
+ }
189
+ };
190
+
161
191
  // Reactive dirty state. ONE fixed signal per projection (created detached so
162
192
  // dispose() owns its teardown, like the per-key nodes). `dirty` is the
163
193
  // source-of-truth count of staged overlays, mirrored into the signal on every
@@ -167,6 +197,12 @@ export function createProjector(reg) {
167
197
  const dirtySig = createRoot(() => signal(0));
168
198
  let dirty = 0;
169
199
 
200
+ // Hoisted scratch for forEachPatch's untracked source read: ONE closure
201
+ // per projection, never per key/call, so the per-key emit body allocates
202
+ // nothing. `untrack` needs a function; _readSrc is it.
203
+ let _pk;
204
+ const _readSrc = () => source.get(_pk);
205
+
170
206
  return {
171
207
  get: (key) => slotFor(key).read(),
172
208
  set: (key, v) => {
@@ -206,6 +242,18 @@ export function createProjector(reg) {
206
242
  if (o !== ABSENT) fn(key, o);
207
243
  }
208
244
  },
245
+ // Emit the overlaid keys as a patch stream fn(key, from, to). Untracked,
246
+ // read-only, zero-alloc per-key body. See forEachPatch above.
247
+ forEachPatch,
248
+ // Cold convenience over forEachPatch: materialize the drafts as
249
+ // [{ key, from, to }, ...] (same visit set, order, values). The
250
+ // per-key record is the documented allocation of this form; reach for
251
+ // forEachPatch when you need the zero-alloc callback.
252
+ toPatch: (skip) => {
253
+ const out = [];
254
+ forEachPatch((key, from, to) => { out.push({ key, from, to }); }, skip);
255
+ return out;
256
+ },
209
257
  // Full-snapshot reconciliation: drop every overlay the policy considers
210
258
  // confirmed against the CURRENT (untracked) source value. For sources that
211
259
  // sync wholesale rather than per-key. Presentation-only -- the source owns
package/README.md CHANGED
@@ -110,6 +110,8 @@ Bind the primitives to a lite-signal registry. Pass the default namespace for no
110
110
  | `overlaidCount()` | untracked diagnostic: number of overlaid keys |
111
111
  | `peek(key)` | untracked effective read (no subscribe) |
112
112
  | `forEachOverlay(fn)` | iterate overlaid keys + values (untracked) |
113
+ | `forEachPatch(fn, skip?)` | emit staged drafts as a `(key, from, to)` stream (untracked, read-only, zero-alloc per key) <sub>1.2</sub> |
114
+ | `toPatch(skip?)` | materialize the drafts as `[{ key, from, to }, ...]` (cold convenience over `forEachPatch`) <sub>1.2</sub> |
113
115
  | `reconcileAll(policy?)` | drop overlays the policy confirms against the current source |
114
116
  | `prune()` | release slots for keys that are neither overlaid nor observed; returns how many were freed <sub>1.1</sub> |
115
117
  | `dispose()` | recycle every projection-owned node back to the pool |
@@ -175,6 +177,30 @@ Projects a single query entry's data **object**, exposing its **fields** as the
175
177
 
176
178
  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
179
 
180
+ ## Patch emission <sub>1.2</sub>
181
+
182
+ The overlay bag already knows every staged draft's `to`; `forEachPatch` adds the source's `from` so a draft can cross the wire without re-walking the view.
183
+
184
+ ```js
185
+ const draft = project(source);
186
+ draft.set("name", "Ada");
187
+ draft.set("email", "ada@x.dev");
188
+
189
+ // Zero-alloc callback: hand each draft to a serializer / transport.
190
+ draft.forEachPatch((key, from, to) => {
191
+ wire.send({ op: "set", key, prev: from, next: to });
192
+ });
193
+
194
+ // Cold convenience: materialize the same deltas as an array.
195
+ const patch = draft.toPatch(); // [{ key: "name", from: undefined, to: "Ada" }, ...]
196
+ ```
197
+
198
+ `from` is the **untracked** current source value, `to` the staged overlay. Both methods are read-only and untracked -- calling them inside an effect subscribes it to nothing -- and visit exactly the overlaid keys, in `forEachOverlay` order. `forEachPatch` allocates nothing per key; `toPatch` is the cold convenience whose per-key record is its documented allocation.
199
+
200
+ An overlaid key is emitted whether or not `Object.is(from, to)`: the visit set stays equal to `dirtyCount()` and `commit()`'s write set, so a patch consumer (an LWW-Map op, a CRDT bump, an HTTP PATCH field) is never silently dropped. To suppress unchanged drafts, pass the same predicate shape reconcile uses -> `draft.forEachPatch(fn, confirmOnEcho)`. A throwing `source.get` propagates on the offending key with the overlay bag intact; callers needing atomicity use `toPatch()` (a partial array never escapes). The patch and `commit()` are two views of one delta: applying `toPatch()` to a copy of the source yields the same state `commit()` would write.
201
+
202
+ Present on the `projectStore` / `projectRoom` / `projectQuery` handles too (for `projectQuery`, `from` is the cached record's field value).
203
+
178
204
  ## Conventions
179
205
 
180
206
  ESM only. ASCII source. `node:test`. MIT.
package/llms.txt CHANGED
@@ -26,6 +26,7 @@ Peer dependency: @zakkster/lite-signal ^1.5.0 (uses createRoot). ESM only. MIT.
26
26
  - createProjector(reg) -> { project, keyedStore } // bind to any lite-signal registry
27
27
  - project(source) -> Projection // default registry
28
28
  - keyedStore(initial?) -> { get, set, has, keys } // minimal built-in source
29
+ - VERSION -> string // shipped package version, synced to package.json
29
30
 
30
31
  ## Projection handle
31
32
 
@@ -33,6 +34,16 @@ get(key) | set(key,value) | clear(key) | commit(key?) [one key or all] | revert(
33
34
  dirtyCount() [TRACKED reactive] | isDirty() [TRACKED reactive] |
34
35
  isOverlaid(key) | overlaidCount() | peek(key) [untracked effective read] |
35
36
  forEachOverlay(fn) | reconcileAll(policy?) | dispose() [recycle all owned nodes] |
37
+ forEachPatch(fn, skip?) [1.2] | toPatch(skip?) -> [{key,from,to}] [1.2]
38
+ [patch emission: iterate exactly the overlaid keys as fn(key, from, to) -- from =
39
+ UNTRACKED current source value, to = staged overlay. Read-only + untracked: safe
40
+ inside an effect, subscribes to nothing. Same visit set/order as forEachOverlay,
41
+ zero-alloc per-key body. Emits UNCHANGED drafts by default (visit set stays equal
42
+ to dirtyCount()); pass a reconcile-policy predicate (from,to,key)=>bool, e.g.
43
+ confirmOnEcho, to skip echoes. A throwing source.get propagates on that key with
44
+ the overlay bag intact. toPatch() is the cold convenience that materializes the
45
+ stream (its per-key record is the documented allocation). Present on the
46
+ projectStore/projectRoom/projectQuery handles too.] |
36
47
  prune() -> number [1.1] [release slots that are BOTH un-overlaid AND unobserved;
37
48
  returns how many were freed. A slot = 1 overlay signal + 1 projected computed,
38
49
  created by the first READ of a key and retained until dispose() (its computed may
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-project",
3
- "version": "1.1.0",
3
+ "version": "1.2.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,11 +24,18 @@
24
24
  "LICENSE.txt"
25
25
  ],
26
26
  "scripts": {
27
- "test": "node --test",
28
- "test:torture": "node bench/torture/overlay-fuzzer.mjs"
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"
29
31
  },
30
32
  "peerDependencies": {
31
- "@zakkster/lite-signal": "^1.6.0-preview.2"
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"
32
39
  },
33
40
  "keywords": [
34
41
  "reactive",
@@ -53,6 +60,10 @@
53
60
  "url": "git+https://github.com/PeshoVurtoleta/lite-project.git"
54
61
  },
55
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
+ },
56
67
  "funding": {
57
68
  "type": "github",
58
69
  "url": "https://github.com/sponsors/PeshoVurtoleta"