@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 +56 -0
- package/LICENSE.txt +21 -0
- package/Project.d.ts +187 -0
- package/Project.js +419 -0
- package/README.md +162 -0
- package/llms.txt +74 -0
- package/package.json +60 -0
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
|
+
[](https://www.npmjs.com/package/@zakkster/lite-project)
|
|
4
|
+

|
|
5
|
+
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
6
|
+
[](https://bundlephobia.com/result?p=@zakkster/lite-project)
|
|
7
|
+
[](https://www.npmjs.com/package/@zakkster/lite-project)
|
|
8
|
+
[](https://www.npmjs.com/package/@zakkster/lite-project)
|
|
9
|
+
[](https://github.com/PeshoVurtoleta/lite-signal)
|
|
10
|
+

|
|
11
|
+

|
|
12
|
+
[](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
|
+
}
|