@zakkster/lite-project 1.1.1 → 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 +50 -0
- package/Project.d.ts +32 -1
- package/Project.js +48 -2
- package/README.md +26 -0
- package/llms.txt +11 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,56 @@
|
|
|
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
|
+
|
|
6
56
|
## [1.1.1] - 2026-09-03
|
|
7
57
|
|
|
8
58
|
### Added
|
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.2.0
|
|
2
2
|
// Zero-GC projections for @zakkster/lite-signal.
|
|
3
3
|
// (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com> -- MIT
|
|
4
4
|
|
|
@@ -25,6 +25,17 @@ export interface ProjectionSource<K extends PropertyKey = PropertyKey, V = unkno
|
|
|
25
25
|
export type ReconcilePolicy<K extends PropertyKey = PropertyKey, V = unknown> =
|
|
26
26
|
(authoritative: V, overlayValue: V, key: K) => boolean;
|
|
27
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
|
+
|
|
28
39
|
/**
|
|
29
40
|
* A projection handle: a granular, derived, non-mutating draft overlay over a
|
|
30
41
|
* keyed source. Each touched key owns one overlay signal + one projected
|
|
@@ -53,6 +64,26 @@ export interface Projection<K extends PropertyKey = PropertyKey, V = unknown> {
|
|
|
53
64
|
peek(key: K): V;
|
|
54
65
|
/** Iterate currently-overlaid keys with their overlay values (untracked). */
|
|
55
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>>;
|
|
56
87
|
/**
|
|
57
88
|
* Full-snapshot reconciliation: drop every overlay the policy considers
|
|
58
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.
|
|
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,7 +63,7 @@ import {
|
|
|
59
63
|
hasObservers as _hasObservers,
|
|
60
64
|
} from "@zakkster/lite-signal";
|
|
61
65
|
|
|
62
|
-
export const VERSION = "1.
|
|
66
|
+
export const VERSION = "1.2.0";
|
|
63
67
|
|
|
64
68
|
// Module-level sentinel for "this key has no overlay". A unique symbol, never a
|
|
65
69
|
// per-operation allocation. Stored directly in the overlay signal's value slot, so
|
|
@@ -129,6 +133,8 @@ export function createProjector(reg) {
|
|
|
129
133
|
* isDirty:()=>boolean, // TRACKED: any staged overlays? (reactive)
|
|
130
134
|
* peek:(key:PropertyKey)=>unknown, // untracked effective read (no subscribe)
|
|
131
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)
|
|
132
138
|
* reconcileAll:(policy?:(authoritative:unknown, overlayValue:unknown, key:PropertyKey)=>boolean)=>void, // drop confirmed overlays
|
|
133
139
|
* commit:(key?:PropertyKey)=>void, // write one key's overlay, or all, into the source then clear
|
|
134
140
|
* revert:()=>void, // drop all overlays
|
|
@@ -160,6 +166,28 @@ export function createProjector(reg) {
|
|
|
160
166
|
return s;
|
|
161
167
|
};
|
|
162
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
|
+
|
|
163
191
|
// Reactive dirty state. ONE fixed signal per projection (created detached so
|
|
164
192
|
// dispose() owns its teardown, like the per-key nodes). `dirty` is the
|
|
165
193
|
// source-of-truth count of staged overlays, mirrored into the signal on every
|
|
@@ -169,6 +197,12 @@ export function createProjector(reg) {
|
|
|
169
197
|
const dirtySig = createRoot(() => signal(0));
|
|
170
198
|
let dirty = 0;
|
|
171
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
|
+
|
|
172
206
|
return {
|
|
173
207
|
get: (key) => slotFor(key).read(),
|
|
174
208
|
set: (key, v) => {
|
|
@@ -208,6 +242,18 @@ export function createProjector(reg) {
|
|
|
208
242
|
if (o !== ABSENT) fn(key, o);
|
|
209
243
|
}
|
|
210
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
|
+
},
|
|
211
257
|
// Full-snapshot reconciliation: drop every overlay the policy considers
|
|
212
258
|
// confirmed against the CURRENT (untracked) source value. For sources that
|
|
213
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.
|
|
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",
|