@zakkster/lite-project 1.0.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 ADDED
@@ -0,0 +1,56 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@zakkster/lite-project` are documented here. The format
4
+ follows Keep a Changelog; this project adheres to semantic versioning.
5
+
6
+ ## [1.0.0] - 2026-06-25
7
+
8
+ First stable release. Zero-GC projections for `@zakkster/lite-signal`.
9
+
10
+ ### Added
11
+
12
+ - **`project(source)`** — a granular, derived, non-mutating draft overlay over
13
+ any keyed reactive source. Each touched key owns one overlay signal + one
14
+ projected computed, created lazily inside `createRoot` (so they outlive the
15
+ consumer that first reads the key) and recycled by `dispose()`.
16
+ - `get` / `set` / `clear` / `commit` / `revert`, plus `commit(key)` for a
17
+ partial (single-key) commit
18
+ - **reactive dirty state:** `dirtyCount()` / `isDirty()` are tracked (back an
19
+ "unsaved changes" badge or a Save button with no polling), backed by one
20
+ fixed signal per projection whose updates are allocation-free
21
+ - `isOverlaid` / `overlaidCount` / `peek` (untracked diagnostics)
22
+ - `forEachOverlay` (iterate overlaid keys) and `reconcileAll(policy?)`
23
+ (full-snapshot reconciliation)
24
+ - **The three properties**, each test-covered: granular (overlaying A never
25
+ re-runs a consumer of B), derived (revert / source changes flow through), and
26
+ non-mutating with **masking** (a source change under an overlay is suppressed
27
+ by the engine's `Object.is` short-circuit — the optimistic value is stable
28
+ under source noise).
29
+ - **`createProjector(reg)`** — bind the primitives to any lite-signal registry
30
+ (default namespace, or an isolated `createRegistry` graph).
31
+ - **`keyedStore(initial?)`** — a minimal built-in keyed reactive source.
32
+ - **Reconciliation:** `confirmOnEcho` (default echo policy) and
33
+ `makeReconciler(view, policy?)` (per-key event handler for sources with an
34
+ incoming-update channel).
35
+ - **Source adapters:** `fromAccessors`, `fromProxy`.
36
+ - **Library adapters:** `projectStore` (per-key-granular drafts over
37
+ `@zakkster/lite-store`, commit-writes-through) and `projectRoom`
38
+ (presentation-only optimistic drafts over a `@zakkster/lite-room` LWW-Map;
39
+ subscribes through the coarse `entries` signal and auto-reconciles, with
40
+ `commit()` promoting via `room.storage.set`).
41
+
42
+ ### Verified
43
+
44
+ - Zero steady-state allocation: 200k overlay toggles on warmed keys leave
45
+ `poolGrowths` and `totalAllocations` flat. Documented non-claim: the first
46
+ touch of a new key allocates its slot + two pooled nodes.
47
+ - 20 tests under `node:test` (core projection, integration helpers, and the
48
+ store / room adapters against faithful stand-ins of their documented surfaces).
49
+
50
+ ### Notes
51
+
52
+ - Peer dependency `@zakkster/lite-signal` `^1.5.0` (requires `createRoot`).
53
+ - Derived-shape lenses (`select` / `filter` / `map`) are planned for a future
54
+ minor on the same per-key-computed substrate.
55
+
56
+ [1.0.0]: https://github.com/PeshoVurtoleta/lite-project/releases/tag/v1.0.0
package/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/Project.d.ts ADDED
@@ -0,0 +1,187 @@
1
+ // Type declarations for @zakkster/lite-project v1.0.0
2
+ // Zero-GC projections for @zakkster/lite-signal.
3
+ // (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com> -- MIT
4
+
5
+ /**
6
+ * A reactive keyed source a projection can wrap. Any object with a reactive
7
+ * `get(key)` and a `set(key, value)` qualifies (the built-in `keyedStore`, a
8
+ * lite-store proxy via `fromProxy`, a plain pair via `fromAccessors`, ...).
9
+ */
10
+ export interface ProjectionSource<K extends PropertyKey = PropertyKey, V = unknown> {
11
+ /** Reactive read: tracks the cell backing `key`. */
12
+ get(key: K): V;
13
+ /** Write the underlying value for `key`. */
14
+ set(key: K, value: V): void;
15
+ }
16
+
17
+ /**
18
+ * Reconciliation policy. Given the current authoritative value, the staged
19
+ * overlay value, and the key, return `true` to DROP the overlay (it is
20
+ * "confirmed" -- the source has caught up), `false` to keep it (a conflict the
21
+ * view should keep masking). Defaults to {@link confirmOnEcho}.
22
+ */
23
+ export type ReconcilePolicy<K extends PropertyKey = PropertyKey, V = unknown> =
24
+ (authoritative: V, overlayValue: V, key: K) => boolean;
25
+
26
+ /**
27
+ * A projection handle: a granular, derived, non-mutating draft overlay over a
28
+ * keyed source. Each touched key owns one overlay signal + one projected
29
+ * computed, created lazily and recycled by {@link Projection.dispose}.
30
+ */
31
+ export interface Projection<K extends PropertyKey = PropertyKey, V = unknown> {
32
+ /** Reactive: the overlay value if one is staged for `key`, else the source value. */
33
+ get(key: K): V;
34
+ /** Stage an EPHEMERAL overlay for `key`. The source is NOT mutated. */
35
+ set(key: K, value: V): void;
36
+ /** Drop one key's overlay (revert that key to the source). */
37
+ clear(key: K): void;
38
+ /** Untracked diagnostic: is `key` currently overlaid? */
39
+ isOverlaid(key: K): boolean;
40
+ /** Untracked diagnostic: number of currently-overlaid keys. */
41
+ overlaidCount(): number;
42
+ /**
43
+ * TRACKED reactive count of staged overlays. Read it inside an effect/computed
44
+ * to drive an "unsaved changes" badge or enable/disable Save without polling.
45
+ * (Backed by one fixed signal per projection; updates are allocation-free.)
46
+ */
47
+ dirtyCount(): number;
48
+ /** TRACKED reactive `dirtyCount() > 0`. */
49
+ isDirty(): boolean;
50
+ /** Untracked effective read (overlay if staged, else source) -- does not subscribe. */
51
+ peek(key: K): V;
52
+ /** Iterate currently-overlaid keys with their overlay values (untracked). */
53
+ forEachOverlay(fn: (key: K, value: V) => void): void;
54
+ /**
55
+ * Full-snapshot reconciliation: drop every overlay the policy considers
56
+ * confirmed against the current (untracked) source value. Presentation-only --
57
+ * the source owns the real write.
58
+ */
59
+ reconcileAll(policy?: ReconcilePolicy<K, V>): void;
60
+ /** Write staged overlays into the source, then clear them. With `key`, commits just that key. */
61
+ commit(key?: K): void;
62
+ /** Drop all overlays. */
63
+ revert(): void;
64
+ /** Recycle every projection-owned node back to the lite-signal pool. */
65
+ dispose(): void;
66
+ }
67
+
68
+ /**
69
+ * Minimal built-in keyed reactive source: one lazily-created signal per key.
70
+ * Provided so a projection has something to wrap out of the box; for richer
71
+ * needs use a lite-store proxy (via {@link fromProxy}) instead.
72
+ */
73
+ export interface KeyedStore<K extends PropertyKey = PropertyKey, V = unknown> {
74
+ /** Reactive read (tracks `key`'s signal). `undefined` until set. */
75
+ get(key: K): V | undefined;
76
+ /** Write `key`'s signal (fires consumers through Object.is). */
77
+ set(key: K, value: V): void;
78
+ /** Untracked: has `key` ever been touched? */
79
+ has(key: K): boolean;
80
+ /** Untracked: iterator over touched keys. */
81
+ keys(): IterableIterator<K>;
82
+ }
83
+
84
+ /**
85
+ * The subset of a lite-signal registry the projector needs. Pass the default
86
+ * namespace, or a `createRegistry({...})` result for an isolated graph.
87
+ */
88
+ export interface ProjectorRegistry {
89
+ signal(initial: unknown, opts?: unknown): unknown;
90
+ computed(fn: () => unknown, opts?: unknown): unknown;
91
+ createRoot<T>(fn: () => T): T;
92
+ dispose(handle: unknown): void;
93
+ untrack<T>(fn: () => T): T;
94
+ }
95
+
96
+ /** The registry-bound projection primitives returned by {@link createProjector}. */
97
+ export interface Projector {
98
+ project<K extends PropertyKey = PropertyKey, V = unknown>(
99
+ source: ProjectionSource<K, V>,
100
+ ): Projection<K, V>;
101
+ keyedStore<K extends PropertyKey = PropertyKey, V = unknown>(
102
+ initial?: Record<PropertyKey, V>,
103
+ ): KeyedStore<K, V>;
104
+ }
105
+
106
+ /** Bind the projection primitives to a lite-signal registry. */
107
+ export function createProjector(reg: ProjectorRegistry): Projector;
108
+
109
+ /** Project a keyed source (default registry). */
110
+ export function project<K extends PropertyKey = PropertyKey, V = unknown>(
111
+ source: ProjectionSource<K, V>,
112
+ ): Projection<K, V>;
113
+
114
+ /** Minimal built-in keyed reactive source (default registry). */
115
+ export function keyedStore<K extends PropertyKey = PropertyKey, V = unknown>(
116
+ initial?: Record<PropertyKey, V>,
117
+ ): KeyedStore<K, V>;
118
+
119
+ /**
120
+ * Default reconciliation policy: an overlay is confirmed once the authoritative
121
+ * value equals the optimistic one (the source echoed it back). `Object.is`.
122
+ */
123
+ export function confirmOnEcho(authoritative: unknown, overlayValue: unknown): boolean;
124
+
125
+ /** Shape a plain accessor pair into a projection source. */
126
+ export function fromAccessors<K extends PropertyKey = PropertyKey, V = unknown>(
127
+ get: (key: K) => V,
128
+ set: (key: K, value: V) => void,
129
+ ): ProjectionSource<K, V>;
130
+
131
+ /**
132
+ * Shape a property-style reactive store (a Proxy, or a lite-store proxy) into a
133
+ * projection source. `obj[key]` must be a TRACKED read and `obj[key] = v` a write.
134
+ */
135
+ export function fromProxy<V = unknown>(
136
+ obj: Record<PropertyKey, V>,
137
+ ): ProjectionSource<PropertyKey, V>;
138
+
139
+ /**
140
+ * Per-key reconciler for an authoritative source with an incoming-update event
141
+ * (a CRDT room, a socket). Wire the returned handler to that event: when an
142
+ * update arrives for an overlaid key and `policy` confirms it, the overlay is
143
+ * dropped. Presentation-only -- the source owns the real write and merge.
144
+ */
145
+ export function makeReconciler<K extends PropertyKey = PropertyKey, V = unknown>(
146
+ view: Pick<Projection<K, V>, "isOverlaid" | "peek" | "clear">,
147
+ policy?: ReconcilePolicy<K, V>,
148
+ ): (key: K, authoritativeValue: V) => void;
149
+
150
+ // ---- library adapters --------------------------------------------------------
151
+
152
+ /**
153
+ * Project a @zakkster/lite-store proxy as a DRAFT overlay. Inherits lite-store's
154
+ * per-key granularity; `commit()` writes drafts through to the store. Projects
155
+ * the top-level keys of the given proxy (pass a nested proxy to project deeper).
156
+ */
157
+ export function projectStore<V = unknown>(
158
+ store: Record<PropertyKey, V>,
159
+ ): Projection<PropertyKey, V>;
160
+
161
+ /** The subset of a @zakkster/lite-room handle that {@link projectRoom} consumes. */
162
+ export interface RoomLike {
163
+ storage: {
164
+ get(key: string): unknown;
165
+ set(key: string, value: unknown): boolean | void;
166
+ /** The coarse `entries` change signal (read to subscribe). */
167
+ entries(): unknown;
168
+ };
169
+ }
170
+
171
+ /** Options for {@link projectRoom}. */
172
+ export interface ProjectRoomOptions {
173
+ /** Reconciliation policy; defaults to {@link confirmOnEcho}. */
174
+ policy?: ReconcilePolicy<string, unknown>;
175
+ }
176
+
177
+ /**
178
+ * Project a @zakkster/lite-room's LWW-Map (`room.storage`) as a DRAFT layer for
179
+ * optimistic / tentative UI. `set` stages a local draft, `commit()` promotes it
180
+ * via `room.storage.set` (writes + syncs), and an auto-reconcile drops drafts the
181
+ * authoritative state catches up to while leaving conflicts masked. The returned
182
+ * handle's `dispose()` also stops the reconcile effect.
183
+ */
184
+ export function projectRoom(
185
+ room: RoomLike,
186
+ opts?: ProjectRoomOptions,
187
+ ): Projection<string, unknown>;
package/Project.js ADDED
@@ -0,0 +1,419 @@
1
+ /**
2
+ * @zakkster/lite-project v1.0.0 -- zero-GC projections for @zakkster/lite-signal.
3
+ * -----------------------------------------------------------------------------
4
+ * A projection is a granular, derived, NON-MUTATING reactive view over a keyed
5
+ * source: a lens that can carry ephemeral overlays (optimistic edits, merges,
6
+ * "pending" state) without touching the underlying data, then commit() those
7
+ * overlays into the source or revert() them. It is the "Beyond Signals"
8
+ * projection primitive, built on lite-signal's pool so the steady state allocates
9
+ * nothing the engine can avoid.
10
+ *
11
+ * -- THE THREE PROPERTIES --
12
+ * granular reading key K subscribes only to K's effective value; overlaying
13
+ * key A never re-runs a consumer of key B (each projected key is its
14
+ * own computed).
15
+ * derived get(key) is reactive: it tracks BOTH the overlay and the source
16
+ * cell, so a revert (or a source change after a revert) flows through.
17
+ * non-mutating set(key, v) writes an overlay ONLY; the source is untouched until
18
+ * commit(). revert() drops the overlay layer entirely. While a key is
19
+ * overlaid, a source change to that key is masked (the projected value
20
+ * stays the overlay) and -- thanks to the engine's Object.is short-
21
+ * circuit -- does NOT churn downstream consumers. The optimistic value
22
+ * is stable under source noise.
23
+ *
24
+ * -- OWNERSHIP (why createRoot) --
25
+ * Per-key nodes are created LAZILY, on the first get/set of a key -- which happens
26
+ * inside whatever consumer effect first reads that key. Without detachment the
27
+ * projected computed would be ADOPTED by that consumer and cascade-disposed on its
28
+ * next re-run, silently breaking the projection. createRoot() detaches owner +
29
+ * observer for the creation, so the nodes are unowned and survive; the projection
30
+ * disposes them itself (dispose()). The overlay signal is safe regardless (plain
31
+ * signals are never adopted), but the computed needs the detached scope.
32
+ *
33
+ * -- ZERO-GC, AND THE HONEST NON-CLAIM --
34
+ * PASS: get / set / clear on an ALREADY-TOUCHED key allocate nothing -- a computed
35
+ * read, or a Map.get + a signal write. Overlay churn over a bounded key set
36
+ * (toggle pending on/off, the realistic optimistic-UI shape) pulls no node from
37
+ * the pool and never grows it.
38
+ * NOT claimed: the FIRST touch of a brand-new key. It allocates a slot record + a Map
39
+ * entry (JS handles) and two pooled nodes (overlay signal + projected computed).
40
+ * The nodes come from the pool (poolGrowth 0 after warm-up); the slot/Map entry
41
+ * are the public-handle cost, the same split @zakkster/lite-signal itself draws
42
+ * between pooled internals and escaping handles. Warm the keys you will churn.
43
+ *
44
+ * Registry-parametric: createProjector(reg) binds to any registry (the default one,
45
+ * or a createRegistry({...}) for isolated tests). Default-bound `project` /
46
+ * `keyedStore` are exported for the common case.
47
+ *
48
+ * MIT (c) Zahary Shinikchiev
49
+ */
50
+
51
+ import {
52
+ signal as _signal,
53
+ computed as _computed,
54
+ createRoot as _createRoot,
55
+ dispose as _dispose,
56
+ untrack as _untrack,
57
+ effect as _effect,
58
+ batch as _batch,
59
+ } from "@zakkster/lite-signal";
60
+
61
+ // Module-level sentinel for "this key has no overlay". A unique symbol, never a
62
+ // per-operation allocation. Stored directly in the overlay signal's value slot, so
63
+ // "absent" and "present with value V" share one node and one field -- no boxing.
64
+ const ABSENT = Symbol("projection.absent");
65
+
66
+ /**
67
+ * Bind the projection primitives to a registry. Pass the default-registry
68
+ * namespace for normal use, or a `createRegistry({...})` result for an isolated
69
+ * graph (tests, the zero-GC gate).
70
+ *
71
+ * @param {{signal:Function, computed:Function, createRoot:Function, dispose:Function}} reg
72
+ * @returns {{project:Function, keyedStore:Function}}
73
+ */
74
+ export function createProjector(reg) {
75
+ const signal = reg.signal;
76
+ const computed = reg.computed;
77
+ const createRoot = reg.createRoot;
78
+ const dispose = reg.dispose;
79
+ const untrack = reg.untrack;
80
+ // commit / revert / reconcileAll write many signals in a loop; batch coalesces
81
+ // them (and the dirty-counter write) into ONE propagation so a multi-key
82
+ // consumer never observes a torn, partially-applied snapshot. Fallback keeps a
83
+ // minimal custom registry working (unbatched == synchronous, as before).
84
+ const batch = reg.batch || ((fn) => { fn(); });
85
+
86
+ /**
87
+ * Minimal keyed reactive source: one lazily-created signal per key. Provided so
88
+ * a projection has something to wrap out of the box; any object with reactive
89
+ * `get(key)` and `set(key, value)` works equally (e.g. lite-store).
90
+ *
91
+ * @param {Record<PropertyKey, unknown>} [initial] Optional seed entries.
92
+ * @returns {{get:(key:PropertyKey)=>unknown, set:(key:PropertyKey, v:unknown)=>void,
93
+ * has:(key:PropertyKey)=>boolean, keys:()=>IterableIterator<PropertyKey>}}
94
+ */
95
+ function keyedStore(initial) {
96
+ const cells = new Map();
97
+ const cell = (key) => {
98
+ let c = cells.get(key);
99
+ if (c === undefined) { c = signal(undefined); cells.set(key, c); }
100
+ return c;
101
+ };
102
+ if (initial !== undefined) for (const k in initial) cell(k).set(initial[k]);
103
+ return {
104
+ get: (key) => cell(key)(),
105
+ set: (key, v) => cell(key).set(v),
106
+ has: (key) => cells.has(key),
107
+ keys: () => cells.keys(),
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Project a keyed source. The returned handle reads through an ephemeral overlay
113
+ * and can commit / revert it.
114
+ *
115
+ * @param {{get:(key:PropertyKey)=>unknown, set:(key:PropertyKey, v:unknown)=>void}} source
116
+ * @returns {{
117
+ * get:(key:PropertyKey)=>unknown, // reactive: overlay value if set, else source
118
+ * set:(key:PropertyKey, v:unknown)=>void, // stage an EPHEMERAL overlay (source untouched)
119
+ * clear:(key:PropertyKey)=>void, // drop one key's overlay (revert that key)
120
+ * isOverlaid:(key:PropertyKey)=>boolean, // untracked diagnostic
121
+ * overlaidCount:()=>number, // untracked diagnostic
122
+ * dirtyCount:()=>number, // TRACKED: count of staged overlays (reactive)
123
+ * isDirty:()=>boolean, // TRACKED: any staged overlays? (reactive)
124
+ * peek:(key:PropertyKey)=>unknown, // untracked effective read (no subscribe)
125
+ * forEachOverlay:(fn:(key:PropertyKey, value:unknown)=>void)=>void, // iterate overlaid keys (untracked)
126
+ * reconcileAll:(policy?:(authoritative:unknown, overlayValue:unknown, key:PropertyKey)=>boolean)=>void, // drop confirmed overlays
127
+ * commit:(key?:PropertyKey)=>void, // write one key's overlay, or all, into the source then clear
128
+ * revert:()=>void, // drop all overlays
129
+ * dispose:()=>void, // recycle every projection-owned node to the pool
130
+ * }}
131
+ */
132
+ function project(source) {
133
+ // key -> { ov: overlay signal (ABSENT | value), read: projected computed }.
134
+ // Lazily populated. One entry per touched key, retained until dispose().
135
+ const slots = new Map();
136
+
137
+ const slotFor = (key) => {
138
+ let s = slots.get(key);
139
+ if (s === undefined) {
140
+ // Detach owner+observer for creation: these nodes outlive the consumer
141
+ // that first reads `key`, and the projection -- not that consumer --
142
+ // owns their disposal. (See header: OWNERSHIP.)
143
+ s = createRoot(() => {
144
+ const ov = signal(ABSENT);
145
+ const read = computed(() => {
146
+ const o = ov(); // track the overlay
147
+ const base = source.get(key); // track the source cell too
148
+ return o === ABSENT ? base : o;
149
+ });
150
+ return { ov, read };
151
+ });
152
+ slots.set(key, s);
153
+ }
154
+ return s;
155
+ };
156
+
157
+ // Reactive dirty state. ONE fixed signal per projection (created detached so
158
+ // dispose() owns its teardown, like the per-key nodes). `dirty` is the
159
+ // source-of-truth count of staged overlays, mirrored into the signal on every
160
+ // presence transition (absent<->value). Bumping it allocates nothing -- a
161
+ // number set, marking subscribers without allocation -- so the zero-GC churn
162
+ // property holds even with a Save button subscribed to isDirty().
163
+ const dirtySig = createRoot(() => signal(0));
164
+ let dirty = 0;
165
+
166
+ return {
167
+ get: (key) => slotFor(key).read(),
168
+ set: (key, v) => {
169
+ const s = slotFor(key);
170
+ const wasAbsent = s.ov.peek() === ABSENT;
171
+ s.ov.set(v);
172
+ if (wasAbsent) { dirty++; dirtySig.set(dirty); }
173
+ },
174
+ clear: (key) => {
175
+ const s = slots.get(key);
176
+ if (s !== undefined && s.ov.peek() !== ABSENT) {
177
+ s.ov.set(ABSENT);
178
+ dirty--; dirtySig.set(dirty);
179
+ }
180
+ },
181
+ isOverlaid: (key) => {
182
+ const s = slots.get(key);
183
+ return s !== undefined && s.ov.peek() !== ABSENT;
184
+ },
185
+ // Reactive dirty state (tracked) -- for "unsaved changes" badges and
186
+ // enabling/disabling Save without polling. isOverlaid / overlaidCount above
187
+ // stay UNTRACKED for diagnostic reads that must not subscribe.
188
+ dirtyCount: () => dirtySig(),
189
+ isDirty: () => dirtySig() > 0,
190
+ // Untracked effective read (overlay if set, else source) -- for
191
+ // reconciliation policies and imperative inspection, without subscribing.
192
+ peek: (key) => {
193
+ const s = slots.get(key);
194
+ if (s === undefined) return untrack(() => source.get(key));
195
+ const o = s.ov.peek();
196
+ return o === ABSENT ? untrack(() => source.get(key)) : o;
197
+ },
198
+ // Iterate currently-overlaid keys (untracked). Cold path.
199
+ forEachOverlay: (fn) => {
200
+ for (const [key, s] of slots) {
201
+ const o = s.ov.peek();
202
+ if (o !== ABSENT) fn(key, o);
203
+ }
204
+ },
205
+ // Full-snapshot reconciliation: drop every overlay the policy considers
206
+ // confirmed against the CURRENT (untracked) source value. For sources that
207
+ // sync wholesale rather than per-key. Presentation-only -- the source owns
208
+ // the real write; this only decides what the local view stops overriding.
209
+ reconcileAll: (policy) => {
210
+ const pol = policy || confirmOnEcho;
211
+ batch(() => {
212
+ let dropped = 0;
213
+ for (const [key, s] of slots) {
214
+ const o = s.ov.peek();
215
+ if (o !== ABSENT) {
216
+ const authoritative = untrack(() => source.get(key));
217
+ if (pol(authoritative, o, key)) { s.ov.set(ABSENT); dropped++; }
218
+ }
219
+ }
220
+ if (dropped) { dirty -= dropped; dirtySig.set(dirty); }
221
+ });
222
+ },
223
+ overlaidCount: () => {
224
+ let n = 0;
225
+ for (const s of slots.values()) if (s.ov.peek() !== ABSENT) n++;
226
+ return n;
227
+ },
228
+ commit: (key) => {
229
+ // Cold path (a user "save"): may iterate + write the source freely.
230
+ // commit(key) writes one overlay; commit() writes all. (Keys are
231
+ // PropertyKey, never undefined, so `key === undefined` means "all".)
232
+ batch(() => {
233
+ if (key !== undefined) {
234
+ const s = slots.get(key);
235
+ if (s !== undefined) {
236
+ const o = s.ov.peek();
237
+ if (o !== ABSENT) {
238
+ source.set(key, o); s.ov.set(ABSENT);
239
+ dirty--; dirtySig.set(dirty);
240
+ }
241
+ }
242
+ return;
243
+ }
244
+ let changed = false;
245
+ for (const [k, s] of slots) {
246
+ const o = s.ov.peek();
247
+ if (o !== ABSENT) { source.set(k, o); s.ov.set(ABSENT); changed = true; }
248
+ }
249
+ if (changed) { dirty = 0; dirtySig.set(0); }
250
+ });
251
+ },
252
+ revert: () => {
253
+ batch(() => {
254
+ let changed = false;
255
+ for (const s of slots.values()) {
256
+ if (s.ov.peek() !== ABSENT) { s.ov.set(ABSENT); changed = true; }
257
+ }
258
+ if (changed) { dirty = 0; dirtySig.set(0); }
259
+ });
260
+ },
261
+ dispose: () => {
262
+ // createRoot left these unowned, so nothing auto-disposes them.
263
+ // Dispose the computed before its overlay so the read never re-evaluates
264
+ // against a recycled signal.
265
+ for (const s of slots.values()) { dispose(s.read); dispose(s.ov); }
266
+ dispose(dirtySig);
267
+ slots.clear();
268
+ dirty = 0;
269
+ },
270
+ };
271
+ }
272
+
273
+ return { project, keyedStore };
274
+ }
275
+
276
+ // Default-registry convenience: project / keyedStore bound to the default registry,
277
+ // for the common single-registry case.
278
+ const _default = createProjector({
279
+ signal: _signal,
280
+ computed: _computed,
281
+ createRoot: _createRoot,
282
+ dispose: _dispose,
283
+ untrack: _untrack,
284
+ batch: _batch,
285
+ });
286
+
287
+ export const project = _default.project;
288
+ export const keyedStore = _default.keyedStore;
289
+
290
+ // ---- integration helpers (registry-independent) ------------------------------
291
+
292
+ /**
293
+ * Default reconciliation policy: an overlay is confirmed once the authoritative
294
+ * value equals the optimistic one (the source echoed it back). Object.is.
295
+ *
296
+ * @param {unknown} authoritative The value the source now holds.
297
+ * @param {unknown} overlayValue The optimistic value staged in the projection.
298
+ * @returns {boolean} true => drop the overlay.
299
+ */
300
+ export function confirmOnEcho(authoritative, overlayValue) {
301
+ return Object.is(authoritative, overlayValue);
302
+ }
303
+
304
+ /**
305
+ * Shape a plain accessor pair into a projection source.
306
+ * @param {(key:PropertyKey)=>unknown} get Reactive read.
307
+ * @param {(key:PropertyKey, v:unknown)=>void} set Write.
308
+ */
309
+ export function fromAccessors(get, set) { return { get, set }; }
310
+
311
+ /**
312
+ * Shape a property-style reactive store (e.g. a proxy, or lite-store's proxy
313
+ * surface) into a projection source. `obj[key]` must be a TRACKED read and
314
+ * `obj[key] = v` a write.
315
+ * @param {object} obj
316
+ */
317
+ export function fromProxy(obj) {
318
+ return { get: (k) => obj[k], set: (k, v) => { obj[k] = v; } };
319
+ }
320
+
321
+ /**
322
+ * Per-key reconciler for an authoritative source that emits incoming-update
323
+ * events (a CRDT room, a socket). Wire the returned handler to that event: when
324
+ * an update arrives for an overlaid key and `policy` considers it confirmed, the
325
+ * optimistic overlay is dropped. Presentation-only -- the source / CRDT owns the
326
+ * real write and the merge; this only decides when the local view stops overriding.
327
+ *
328
+ * @example
329
+ * const onUpdate = makeReconciler(view); // echo policy
330
+ * room.onUpdate(onUpdate); // room fires (key, authoritativeValue)
331
+ *
332
+ * @param {{isOverlaid:Function, peek:Function, clear:Function}} view A project() handle.
333
+ * @param {(authoritative:unknown, overlayValue:unknown, key:PropertyKey)=>boolean} [policy]
334
+ * @returns {(key:PropertyKey, authoritativeValue:unknown)=>void}
335
+ */
336
+ export function makeReconciler(view, policy) {
337
+ const pol = policy || confirmOnEcho;
338
+ return (key, authoritativeValue) => {
339
+ if (view.isOverlaid(key) && pol(authoritativeValue, view.peek(key), key)) view.clear(key);
340
+ };
341
+ }
342
+
343
+ // ---- library adapters (default registry) -------------------------------------
344
+ // lite-store and lite-room both bind the default lite-signal registry, so these
345
+ // adapters use the default-bound `project` and `_effect`. They are written
346
+ // against the published surfaces of @zakkster/lite-store v1.0.0 and
347
+ // @zakkster/lite-room.
348
+
349
+ /**
350
+ * Project a @zakkster/lite-store proxy as a DRAFT overlay. lite-store gives
351
+ * per-key signals (a property becomes reactive the first time it is read in a
352
+ * reactive scope), so the projection inherits that granularity: overlaying or
353
+ * committing one key only re-runs consumers of that key.
354
+ *
355
+ * - set(key, value) stage a draft (the store is NOT mutated)
356
+ * - commit() write drafts through (`store[key] = draft`), firing the
357
+ * store's per-key signals
358
+ * - revert() discard all drafts
359
+ * - clear(key) discard one draft
360
+ *
361
+ * Projects the TOP-LEVEL keys of the given proxy. To project a nested object,
362
+ * pass the nested proxy: `projectStore(s.user)` (lite-store hands out a child
363
+ * proxy on property access, and that child has its own per-key signals).
364
+ *
365
+ * @param {object} store A lite-store proxy from `store(...)`.
366
+ * @returns {object} A projection handle (get/set/clear/commit/revert/isOverlaid/peek/...).
367
+ */
368
+ export function projectStore(store) {
369
+ return project(fromProxy(store));
370
+ }
371
+
372
+ /**
373
+ * Project a @zakkster/lite-room's LWW-Map (`room.storage`) as a DRAFT layer for
374
+ * optimistic / tentative UI. Room storage is authoritative and CRDT-merged, so
375
+ * the projection never participates in the merge -- it only decides what the
376
+ * local view tentatively overrides:
377
+ *
378
+ * - set(key, value) stage a draft -- local only, NOT synced, CRDT untouched
379
+ * - commit() promote drafts via `room.storage.set` (writes + syncs)
380
+ * - revert() discard drafts
381
+ * - auto-reconcile whenever authoritative storage changes, drafts the policy
382
+ * considers confirmed are dropped; a CONFLICTING
383
+ * authoritative value leaves the draft masked (the engine's
384
+ * Object.is short-circuit suppresses the flicker)
385
+ *
386
+ * room.storage is COARSE -- a single `entries` signal fires on any change and
387
+ * `get(key)` is a plain (non-reactive) Map read -- so the source adapter
388
+ * subscribes through `entries()` before reading, and the projection inherits
389
+ * that coarse granularity (any storage change re-evaluates every projected key).
390
+ * Per-key room signals would refine both layers at once. Only `room.storage` is
391
+ * projectable this way; sets / lists / texts have non-keyed shapes.
392
+ *
393
+ * @param {object} room A room handle from `createRoom(...)`.
394
+ * @param {{policy?: (authoritative:unknown, draft:unknown, key:PropertyKey)=>boolean}} [opts]
395
+ * Reconciliation policy; defaults to confirmOnEcho (drop when authoritative === draft).
396
+ * @returns {object} A projection handle whose dispose() also stops the reconcile effect.
397
+ */
398
+ export function projectRoom(room, opts) {
399
+ const policy = (opts && opts.policy) || confirmOnEcho;
400
+ const source = {
401
+ // Subscribe to the coarse `entries` signal so the projected read reacts
402
+ // to any authoritative change, then return the current value for `key`.
403
+ get: (key) => { room.storage.entries(); return room.storage.get(key); },
404
+ set: (key, value) => room.storage.set(key, value),
405
+ };
406
+ const view = project(source);
407
+ // Drop confirmed drafts whenever authoritative state changes. The effect
408
+ // tracks `entries` (not overlays/projected computeds), so view.clear() inside
409
+ // reconcileAll never re-triggers it -> no loop. reconcileAll reads the source
410
+ // untracked, so it adds no dependency.
411
+ const stopReconcile = _effect(() => {
412
+ room.storage.entries();
413
+ view.reconcileAll(policy);
414
+ });
415
+ return {
416
+ ...view,
417
+ dispose: () => { stopReconcile(); view.dispose(); },
418
+ };
419
+ }
package/README.md ADDED
@@ -0,0 +1,162 @@
1
+ # @zakkster/lite-project
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@zakkster/lite-project.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-project)
4
+ ![Zero-GC](https://img.shields.io/badge/Zero--GC-Hot%20path-00C853?style=for-the-badge&logo=leaf&logoColor=white)
5
+ [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
6
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-project?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-project)
7
+ [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-project?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-project)
8
+ [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-project?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-project)
9
+ [![lite-signal peer](https://img.shields.io/badge/peer-lite--signal-blue?style=for-the-badge)](https://github.com/PeshoVurtoleta/lite-signal)
10
+ ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
11
+ ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
12
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
13
+
14
+ **Zero-GC projections for [@zakkster/lite-signal](https://www.npmjs.com/package/@zakkster/lite-signal).**
15
+
16
+ A *projection* is a granular, derived, **non-mutating** reactive view over a keyed source — a lens that carries ephemeral overlays (optimistic edits, drafts, "pending" state) without touching the underlying data, then `commit()`s those overlays into the source or `revert()`s them. It is the "Beyond Signals" projection primitive, built on lite-signal's node pool so the steady state allocates nothing the engine can avoid.
17
+
18
+ ```mermaid
19
+ flowchart LR
20
+ subgraph per-key
21
+ O["overlay signal<br/>(ABSENT | value)"]
22
+ S["source cell"]
23
+ P{{"projected computed<br/>overlay ?? source"}}
24
+ O -- tracked --> P
25
+ S -- tracked --> P
26
+ end
27
+ P --> C["consumer<br/>(effect / UI)"]
28
+ C -. "set(key, v)" .-> O
29
+ O -. "commit()" .-> S
30
+ S -. "clear() / reconcile" .-> O
31
+ ```
32
+
33
+ Each touched key owns **one overlay signal + one projected computed**. Reading a key tracks its effective value (overlay if staged, else source); overlaying key `A` never re-runs a consumer of key `B`.
34
+
35
+ ---
36
+
37
+ ## Install
38
+
39
+ ```sh
40
+ npm i @zakkster/lite-project
41
+ ```
42
+
43
+ Peer dependency: `@zakkster/lite-signal` `^1.5.0` (the projection relies on `createRoot`, which landed in 1.5.0).
44
+
45
+ ## Quick start
46
+
47
+ ```js
48
+ import { project, keyedStore } from "@zakkster/lite-project";
49
+ import { effect } from "@zakkster/lite-signal";
50
+
51
+ const store = keyedStore({ title: "untitled" }); // any reactive get/set source
52
+ const draft = project(store);
53
+
54
+ effect(() => console.log("showing:", draft.get("title"))); // "untitled"
55
+
56
+ draft.set("title", "Draft name"); // optimistic: prints "Draft name"
57
+ store.get("title"); // still "untitled" -- source untouched
58
+
59
+ draft.commit(); // writes the overlay into the store
60
+ draft.isOverlaid("title"); // false
61
+ ```
62
+
63
+ ## The three properties
64
+
65
+ - **granular** — reading key `K` subscribes only to `K`'s effective value (each projected key is its own computed). Overlaying one key never re-runs another key's consumer.
66
+ - **derived** — `get(key)` is reactive: it tracks **both** the overlay and the source cell, so a `revert()` (or a source change after a revert) flows through.
67
+ - **non-mutating** — `set(key, v)` writes an overlay **only**; the source is untouched until `commit()`. While a key is overlaid, a source change to it is *masked* (the projected value stays the overlay) and, thanks to the engine's `Object.is` short-circuit, does **not** churn downstream consumers. The optimistic value is stable under source noise — no flicker.
68
+
69
+ ## Reactive dirty state
70
+
71
+ `dirtyCount()` and `isDirty()` are **tracked** — read them in an effect/computed to drive an "unsaved changes" badge or a Save button with no polling. (`isOverlaid` / `overlaidCount` stay untracked for diagnostic reads that must not subscribe.)
72
+
73
+ ```js
74
+ effect(() => { saveButton.disabled = !draft.isDirty(); }); // re-runs only on clean<->dirty flips
75
+
76
+ draft.set("title", "x"); // -> isDirty() true, button enabled
77
+ draft.commit("title"); // commit just one field; -> back to clean
78
+ ```
79
+
80
+ Updating the dirty count is allocation-free (a single fixed signal per projection, bumped on each presence transition), so the zero-GC property holds even with the Save effect subscribed.
81
+
82
+ ## Zero-GC
83
+
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
+
86
+ ## API
87
+
88
+ ### `createProjector(reg) -> { project, keyedStore }`
89
+
90
+ Bind the primitives to a lite-signal registry. Pass the default namespace for normal use, or a `createRegistry({...})` result for an isolated graph (tests, the zero-GC gate). The package also exports `project` and `keyedStore` pre-bound to the default registry for the common case.
91
+
92
+ ### `project(source) -> Projection`
93
+
94
+ `source` is any object with a reactive `get(key)` and a `set(key, value)`. Returns a handle:
95
+
96
+ | method | description |
97
+ | --- | --- |
98
+ | `get(key)` | reactive: overlay value if staged, else the source value |
99
+ | `set(key, value)` | stage an **ephemeral** overlay (source untouched) |
100
+ | `clear(key)` | drop one key's overlay (revert that key) |
101
+ | `commit(key?)` | write one key's overlay (or, with no arg, all) into the source, then clear |
102
+ | `revert()` | drop all overlays |
103
+ | `dirtyCount()` | **tracked / reactive**: count of staged overlays — wire a Save badge to it |
104
+ | `isDirty()` | **tracked / reactive**: `dirtyCount() > 0` |
105
+ | `isOverlaid(key)` | untracked diagnostic: is the key overlaid? |
106
+ | `overlaidCount()` | untracked diagnostic: number of overlaid keys |
107
+ | `peek(key)` | untracked effective read (no subscribe) |
108
+ | `forEachOverlay(fn)` | iterate overlaid keys + values (untracked) |
109
+ | `reconcileAll(policy?)` | drop overlays the policy confirms against the current source |
110
+ | `dispose()` | recycle every projection-owned node back to the pool |
111
+
112
+ ### `keyedStore(initial?) -> { get, set, has, keys }`
113
+
114
+ A minimal built-in keyed reactive source: one lazily-created signal per key. Handy when you do not need a full lite-store.
115
+
116
+ ### Reconciliation helpers
117
+
118
+ - `confirmOnEcho(authoritative, overlay)` — default policy: confirmed once `Object.is(authoritative, overlay)` (the source echoed the optimistic value back).
119
+ - `makeReconciler(view, policy?)` — returns a per-key handler `(key, authoritativeValue) => void` for a source that emits incoming-update events. When an update arrives for an overlaid key and the policy confirms it, the overlay is dropped.
120
+
121
+ ### Source adapters
122
+
123
+ - `fromAccessors(get, set)` — shape a plain accessor pair into a source.
124
+ - `fromProxy(obj)` — shape a property-style reactive store (a Proxy, a lite-store proxy) into a source; `obj[key]` must be a tracked read.
125
+
126
+ ## Library adapters
127
+
128
+ ### `projectStore(store)` — drafts over [lite-store](https://www.npmjs.com/package/@zakkster/lite-store)
129
+
130
+ ```js
131
+ import { projectStore } from "@zakkster/lite-project";
132
+ import { store } from "@zakkster/lite-store";
133
+
134
+ const s = store({ name: "alice", age: 30 });
135
+ const draft = projectStore(s);
136
+
137
+ draft.set("name", "bob"); // draft only; s.name is still "alice"
138
+ draft.commit(); // s.name === "bob"
139
+ ```
140
+
141
+ lite-store gives per-key signals, so the projection stays granular: overlaying or committing one key only re-runs that key's consumers. Projects the **top-level** keys of the given proxy — pass a nested proxy (`projectStore(s.user)`) to project deeper.
142
+
143
+ ### `projectRoom(room, { policy })` — optimistic drafts over [lite-room](https://www.npmjs.com/package/@zakkster/lite-room)
144
+
145
+ ```js
146
+ import { projectRoom } from "@zakkster/lite-project";
147
+
148
+ const draft = projectRoom(room); // over room.storage (LWW-Map)
149
+
150
+ draft.set("cell:A1", "=SUM(B:B)"); // local optimistic edit, NOT synced
151
+ draft.commit(); // promotes via room.storage.set (writes + syncs to peers)
152
+ ```
153
+
154
+ 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
+
156
+ ## Conventions
157
+
158
+ ESM only. ASCII source. `node:test`. MIT.
159
+
160
+ ## License
161
+
162
+ MIT (c) 2026 Zahary Shinikchiev
package/llms.txt ADDED
@@ -0,0 +1,74 @@
1
+ # @zakkster/lite-project
2
+
3
+ > Zero-GC projections for @zakkster/lite-signal: granular, derived, non-mutating
4
+ > reactive overlays over a keyed source, with commit / revert / reconcile, plus
5
+ > draft adapters for lite-store and lite-room.
6
+
7
+ A projection is a lens over a keyed reactive source. It carries EPHEMERAL
8
+ overlays (optimistic edits, drafts, pending state) without mutating the source,
9
+ then commit()s them in or revert()s them out. Each touched key gets its own
10
+ overlay signal + projected computed, so the view is granular and the steady
11
+ state allocates nothing the engine can avoid.
12
+
13
+ Peer dependency: @zakkster/lite-signal ^1.5.0 (uses createRoot). ESM only. MIT.
14
+
15
+ ## Mental model
16
+
17
+ - overlay signal per key: ABSENT sentinel | value.
18
+ - projected computed per key: reads overlay if set, else source.get(key); tracks BOTH.
19
+ - get(key) is reactive. set(key,v) writes the OVERLAY only (source untouched).
20
+ - commit() writes overlays into source then clears. revert() drops all overlays.
21
+ - masking: while a key is overlaid, a source change to it is suppressed by the
22
+ engine's Object.is short-circuit -> the optimistic value is stable, no flicker.
23
+
24
+ ## Entry points
25
+
26
+ - createProjector(reg) -> { project, keyedStore } // bind to any lite-signal registry
27
+ - project(source) -> Projection // default registry
28
+ - keyedStore(initial?) -> { get, set, has, keys } // minimal built-in source
29
+
30
+ ## Projection handle
31
+
32
+ get(key) | set(key,value) | clear(key) | commit(key?) [one key or all] | revert() |
33
+ dirtyCount() [TRACKED reactive] | isDirty() [TRACKED reactive] |
34
+ isOverlaid(key) | overlaidCount() | peek(key) [untracked effective read] |
35
+ forEachOverlay(fn) | reconcileAll(policy?) | dispose() [recycle all owned nodes]
36
+
37
+ ## Source = any { get(key): reactive, set(key, value) }
38
+
39
+ - fromAccessors(get, set) -> source
40
+ - fromProxy(obj) -> source // obj[key] tracked read, obj[key]=v write
41
+
42
+ ## Reconciliation
43
+
44
+ - confirmOnEcho(authoritative, overlay) -> Object.is(...) // default policy
45
+ - makeReconciler(view, policy?) -> (key, authoritativeValue) => void
46
+ // per-key handler for a source with an incoming-update event; drops the
47
+ // overlay when the policy confirms it. Conflicts stay masked.
48
+
49
+ ## Library adapters
50
+
51
+ - projectStore(store) // lite-store proxy. Per-key granular drafts;
52
+ // commit() writes through store[key]=v.
53
+ // Top-level keys; pass a nested proxy to go deeper.
54
+ - projectRoom(room, {policy?}) // lite-room room.storage (LWW-Map).
55
+ // Presentation-only optimistic drafts: set = local
56
+ // draft (not synced), commit() = room.storage.set
57
+ // (writes + syncs), auto-reconcile drops confirmed
58
+ // drafts via the coarse `entries` signal; conflicts
59
+ // stay masked. dispose() stops the reconcile effect.
60
+ // CRDT owns the merge; the projection never joins it.
61
+
62
+ ## Zero-GC
63
+
64
+ Steady state: re-overlaying a warmed key reuses pooled nodes (200k toggles ->
65
+ poolGrowths/totalAllocations flat). First touch of a NEW key allocates its slot,
66
+ a Map entry, and two pooled nodes. Warm the keys you churn.
67
+
68
+ ## Gotchas
69
+
70
+ - createRoot is REQUIRED (1.5.0+): per-key nodes are created lazily inside the
71
+ consumer that first reads the key; without detachment they would be adopted by
72
+ that consumer and cascade-disposed on its next re-run. createRoot detaches them.
73
+ - A projection only refines what the source exposes: over lite-room's coarse
74
+ storage it is coarse (any storage change re-evaluates every projected key).
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@zakkster/lite-project",
3
+ "version": "1.0.0",
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
+ "type": "module",
6
+ "main": "./Project.js",
7
+ "module": "./Project.js",
8
+ "types": "./Project.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "node": "./Project.js",
12
+ "types": "./Project.d.ts",
13
+ "import": "./Project.js",
14
+ "default": "./Project.js"
15
+ }
16
+ },
17
+ "sideEffects": false,
18
+ "files": [
19
+ "Project.js",
20
+ "Project.d.ts",
21
+ "README.md",
22
+ "CHANGELOG.md",
23
+ "llms.txt",
24
+ "LICENSE.txt"
25
+ ],
26
+ "scripts": {
27
+ "test": "node --test"
28
+ },
29
+ "peerDependencies": {
30
+ "@zakkster/lite-signal": ">=1.5.0-alpha"
31
+ },
32
+ "keywords": [
33
+ "reactive",
34
+ "signals",
35
+ "projection",
36
+ "lens",
37
+ "optimistic-ui",
38
+ "overlay",
39
+ "zero-gc",
40
+ "esm",
41
+ "lite-signal"
42
+ ],
43
+ "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
44
+ "license": "MIT",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/PeshoVurtoleta/lite-project.git"
48
+ },
49
+ "homepage": "https://github.com/PeshoVurtoleta/lite-project#readme",
50
+ "funding": {
51
+ "type": "github",
52
+ "url": "https://github.com/sponsors/PeshoVurtoleta"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
57
+ "engines": {
58
+ "node": ">=18"
59
+ }
60
+ }