@zakkster/lite-project 1.1.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,200 @@
3
3
  All notable changes to `@zakkster/lite-project` are documented here. The format
4
4
  follows Keep a Changelog; this project adheres to semantic versioning.
5
5
 
6
+ ## [1.4.0] - 2026-09-05
7
+
8
+ ### Added
9
+
10
+ - **`projectCRDT(map, opts?)`** -- a draft-overlay adapter for a
11
+ `@zakkster/lite-crdt` LWW-Map (`doc.map(name)`). Unlike `projectRoom` (which
12
+ wraps lite-room's **coarse** storage -- one `entries` signal, any change re-runs
13
+ every projected key), an LWW-Map has **fine-grained** reactive `get(key)`, so
14
+ `projectCRDT` is truly granular: overlaying or committing one cell never re-runs
15
+ a consumer of another. `set(key, value)` stages a local draft (the CRDT is
16
+ untouched); `commit(key?)` promotes drafts via `map.set` (one op per committed
17
+ key -- LWW ops are commutative + idempotent, so N frames are semantically one);
18
+ an auto-reconcile drops drafts the authoritative cell catches up to (a local
19
+ echo or a remote `applyOp`) while leaving conflicts -- and a concurrent
20
+ authoritative **delete** (reads as `undefined`) -- masked. The reconcile trigger
21
+ is ONE effect that reads `dirtyCount()` (tracked -- re-derives the dependency set
22
+ on every overlay-set transition) plus `map.get(k)` for each currently-overlaid
23
+ key, then calls `reconcileAll(policy)`; it re-runs only on overlay-set
24
+ transitions and on authoritative changes to overlaid keys. The map is consumed
25
+ **structurally** (any `{ get, set }` with a fine-grained reactive `get`), so
26
+ there is no hard dependency on lite-crdt, and the adapter never touches the doc,
27
+ `map.store`, or the coarse reads (`keys`/`values`/`entries`/`size`).
28
+ - **`opts.transact`** -- an optional hook (e.g. `doc.transact`) that wraps both
29
+ `commit` and `commitWhere` so an N-key burst coalesces into ONE ops frame + one
30
+ change (measured: staging 3 keys emits 0 ops; committing emits 3 ops / 3 frames
31
+ with no `transact`, 3 ops / **1** frame under `transact`). The branch is resolved
32
+ once at construction; a supplied-but-non-function `transact` throws before any
33
+ node is created. `LWWMapLike`, `ProjectCRDTOptions`, and the `projectCRDT`
34
+ declaration added to `Project.d.ts`; `decisions/0003-project-crdt.md` records the
35
+ design.
36
+
37
+ ### Notes (recorded contracts, not bugs)
38
+
39
+ - **Read-only object wrapper.** lite-crdt's `get(key)` returns a deep **read-only
40
+ wrapper** for object/array values (a different reference than the one passed to
41
+ `set`, WeakMap-cached and stable across reads). So `confirmOnEcho` (`Object.is`)
42
+ can **never** auto-confirm an object-valued draft over `projectCRDT`, even on a
43
+ genuine local echo -- use a `{ ttl }` draft (the shipped self-heal) or a
44
+ caller-supplied **structural** policy (whose reads pass through the wrapper
45
+ transparently). A policy must never attempt to mutate the authoritative value it
46
+ is handed for an object -- it is that read-only wrapper and lite-crdt throws
47
+ `readonly`. Scalars confirm normally.
48
+ - **String-coercion key aliasing.** lite-crdt coerces every map key to a string,
49
+ but projection slots are keyed by `PropertyKey`. Drafts on `5` and `"5"` are TWO
50
+ projection slots that commit into ONE CRDT cell (last write wins), and
51
+ `dirtyCount()` never reveals the collision -- stage under one key type. A
52
+ `"__proto__"` map key is not usable in lite-crdt (`map.set` throws
53
+ `CRDTError("misconfigured")`); the adapter does not wrap that policy -- a commit
54
+ of a `"__proto__"` draft propagates the CRDT's own error with the draft still
55
+ staged and `dirtyCount()` consistent (fail closed).
56
+ - **Dispose order.** `doc.dispose()` makes subsequent mutations silent no-ops, so a
57
+ commit **after** the doc is disposed writes nothing yet still clears the drafts
58
+ (an inherited dead-source data-loss class). Dispose the projection **before** the
59
+ doc.
60
+
61
+ ### Tests
62
+
63
+ - `test/crdt_test.mjs` (19 tests, real `@zakkster/lite-crdt` on the default
64
+ registry): op-counter (stage/commit/transact frames), the wrapper pin (object
65
+ draft after a genuine echo stays overlaid; a structural policy drops it), TTL
66
+ heal over `projectCRDT`, numeric/symbol key-alias pins, `"__proto__"` commit
67
+ fail-closed, granularity (a consumer of `get("b")` runs once across 10 commits to
68
+ `"a"`; the reconcile effect does not fire on non-overlaid-key writes), missing-key
69
+ draft (`from === undefined`; a remote `applyOp` re-runs the projected read),
70
+ authoritative-delete conflict, dispose ordering, and the post-`doc.dispose()`
71
+ commit hazard.
72
+ - Torture: `makeFakeMap` (a registry-parametric structural fake LWW-Map) drives new
73
+ `T4` (echo/conflict/late-overlay per key), `T5` (`projectCRDT` fuzz oracle + the
74
+ granularity law), `T6` (a warm echo-drop reconcile pass -- retains 0 B/call),
75
+ and `T9` controls `(j)` (a coarse-read effect fails the granularity law) and
76
+ `(k)` (a peek-only stale-deps effect misses a late-overlay echo). GATE unchanged:
77
+ `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | retained=0.00 B/op growths=0`.
78
+
79
+ ## [1.3.0] - 2026-09-05
80
+
81
+ ### Added
82
+
83
+ - **Overlay TTL -- `set(key, value, { ttl })`.** Stage an overlay that
84
+ auto-**reverts** at `now() + ttl` (a finite number > 0, in the clock's units):
85
+ the draft is dropped and the source is **never** touched -- "the optimistic edit
86
+ expired; fall back to authoritative". One re-armed platform timer per projection
87
+ (each slot stores its own deadline; arm/fire do an `O(slots)` cold scan -- no
88
+ side `Map`, no per-`set` allocation on the warm path). A bad `ttl`
89
+ (`0`, `-1`, `NaN`, `Infinity`, `"5"`, `null`, ...) throws **before** staging. A
90
+ re-set **with** `ttl` re-arms (an earlier deadline re-arms eagerly; a later one
91
+ lets the armed timer fire spuriously and re-arm); a re-set **without** `ttl`
92
+ cancels the pending expiry -- each `set` fully specifies its overlay's lifetime.
93
+ Every transition to un-overlaid (`clear`, `commit(key)`, `commit()`, `revert`,
94
+ a `reconcileAll` drop, `commitWhere`, `clearWhere`, and the fire itself) cancels
95
+ that key's expiry, and `dispose()` cancels any pending handle.
96
+ - **Injectable clock -- `project(source, { now, setTimer, clearTimer })`.**
97
+ All-or-none: supply all three (each a function) or none. A **mixed** clock is a
98
+ `TypeError` (it would compute deadlines on one timeline and arm on another --
99
+ fail closed). Defaults wrap `performance.now` / `setTimeout` / `clearTimeout`.
100
+ Forwarded by `projectStore(store, opts?)`, `projectRoom(room, opts?)`, and
101
+ `projectQuery(qc, key, opts?)` (the flat bag; `project` reads only the clock
102
+ keys). `SetOptions`, `ProjectionClock`, and `ProjectOptions` added to
103
+ `Project.d.ts`.
104
+ - **`Projection.commitWhere(pred)` / `Projection.clearWhere(pred)`** -- predicate-
105
+ scoped partial save / discard. `pred(key, stagedValue)` (the `forEachOverlay`
106
+ callback order, not `ReconcilePolicy`'s), visited in slots order, one reactive
107
+ propagation each. `commitWhere` writes and clears only the matching overlays;
108
+ `clearWhere` drops them with **zero** source writes. A throwing `pred` is
109
+ non-atomic on the core handle (already-committed keys stay committed and
110
+ `dirtyCount() === overlaidCount()`). `projectQuery` **overrides** `commitWhere`
111
+ to keep the single-write law: one `setQueryData(key, prev => merge(prev,
112
+ overlays))` for the matching fields, then the committed fields are cleared
113
+ per-key -- the non-matching drafts survive (it does **not** reuse the `commit()`
114
+ override's `revert()`, which would drop them too).
115
+
116
+ **F-03 recorded.** `confirmOnEcho` is reference-equality (`Object.is`), so an
117
+ object-valued draft can never echo-confirm against a structurally-equal source
118
+ value of a different reference. The fix is a **caller-supplied** structural
119
+ policy (`reconcileAll(policy)` and the `forEachPatch` skip param accept one);
120
+ this library ships **no** deep-equal helper (a naive structural equal is a
121
+ fail-open trap). The TTL is the shipped safety net: a stuck object draft
122
+ self-heals on its deadline. Recorded in `decisions/0002-overlay-ttl.md`
123
+ (dev-only; not shipped).
124
+
125
+ ### Verified
126
+
127
+ - 30 new `test/ttl_test.mjs` cases (fire at / not-before the deadline, byte-
128
+ identical source after a fire, re-arm + one-handle, plain re-set cancels,
129
+ `null` / `{}` / `{policy}` bags behave as plain `set` and still cancel,
130
+ cancellation at every ABSENT-transition site, the F-03 self-heal, the ttl +
131
+ mixed-clock + non-object-`project`-opts `TypeError`s, `commitWhere` /
132
+ `clearWhere` exact match + one
133
+ propagation + throwing-pred consistency, a set-with-ttl honoured inside a fire
134
+ subscriber, post-dispose inertness, and the four adapters incl. the
135
+ `projectQuery` single-write `commitWhere`); **114 tests total**, `node --test`.
136
+ - Torture green (default seed):
137
+ `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 |
138
+ alloc=n/a retained=0.00 B/op growths=0` (the binding channels are `major=0`,
139
+ `retained=0.00`, `growths=0`; the `alloc=` per-op bracket prints as-is, `n/a`
140
+ when the profiler's heap window was inconclusive). T4 gains the TTL door
141
+ (deterministic fake clock: expire / not-before / re-arm / cancel + the F-03
142
+ object heal). T6 gains Proof 5 -- warm ttl re-set + `commitWhere` + `clearWhere`
143
+ under an injected no-op clock retain `0 B/call` at `maxBytesPerCall 0` (the warm
144
+ no-ttl triangle passes the P0 gates unchanged). T7 gains a 1000-TTL sub-soak:
145
+ `maxOutstanding() === 1` at every instant, `0` after the drain fire and after
146
+ `dispose()`, tracker back to `size()===0`. T9 gains controls (h) a default-clock
147
+ projection tripping the deterministic expiry assertion, and (i) a leaky per-key
148
+ timer tripping the one-handle bound.
149
+
150
+ ## [1.2.0] - 2026-09-05
151
+
152
+ ### Added
153
+
154
+ - **`Projection.forEachPatch(fn, skip?)`** -- emit the staged drafts as a
155
+ `(key, from, to)` stream, where `from` is the **untracked** current source
156
+ value and `to` the staged overlay. Read-only and untracked: it touches neither
157
+ the source nor the overlays and subscribes the caller to nothing, so it is safe
158
+ inside an effect. Visits exactly the overlaid keys, in `forEachOverlay` order,
159
+ with a **zero-allocation** per-key body (the source read is hoisted through one
160
+ closure per projection, never one per key). The optional `skip` reuses the
161
+ `ReconcilePolicy` shape `(from, to, key) => boolean` -- pass `confirmOnEcho` to
162
+ drop unchanged drafts. A throwing `source.get` propagates on the offending key
163
+ with the overlay bag intact (no writes happen anywhere in the call).
164
+ - **`Projection.toPatch(skip?)`** -- the cold convenience that materializes the
165
+ same stream as `[{ key, from, to }, ...]` (same visit set, order, and values).
166
+ The per-key record is this form's documented allocation; reach for
167
+ `forEachPatch` when you need the zero-alloc callback. Both methods are present
168
+ on the `projectStore` / `projectRoom` / `projectQuery` handles (for
169
+ `projectQuery`, `from` is the cached record's field value; for `projectRoom`,
170
+ `room.storage.get`).
171
+ - **`Patch<K, V>`** interface (`{ key, from, to }`) exported from `Project.d.ts`.
172
+
173
+ **The decision -- unchanged drafts are emitted by default.** An overlaid key is
174
+ emitted whether or not `Object.is(from, to)`. This keeps the visit set
175
+ definitionally equal to `forEachOverlay`, `dirtyCount()`, and `commit()`'s write
176
+ set, so `toPatch().length === dirtyCount()` always holds; a patch consumer is a
177
+ protocol (an LWW-Map op, a CRDT timestamp bump, an HTTP PATCH field), not a diff
178
+ viewer, so dropping an unchanged key would be silent data loss one layer out.
179
+ Callers who want the filter pass `forEachPatch(fn, confirmOnEcho)`. Recorded in
180
+ `decisions/0001-patch-emission.md` (dev-only; not shipped).
181
+
182
+ ### Verified
183
+
184
+ - 17 new `test/patch_test.mjs` cases (visit-set exactness + order, from/to vs
185
+ source and overlay, `toPatch()` == the callback stream, the emit-by-default and
186
+ echo-skip pins, the tracking contract, `__proto__` / symbol / numeric keys,
187
+ `undefined` / `NaN` / `-0` under `Object.is`, fail-closed on a throwing
188
+ `source.get`, patch-apply == commit, and all four adapter handles); **84 tests
189
+ total**, `node --test`.
190
+ - Torture green (default seed):
191
+ `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 |
192
+ alloc=n/a retained=0.00 B/op growths=0`. T5 gains the metamorphic law (the
193
+ emitted patch applied to a fresh source copy == `commit()` into it, across the
194
+ fuzz corpus incl. object drafts). T6 gains Proof 4 -- `forEachPatch` over a
195
+ warm overlaid set passes both the heap gate (`maxMajor 0`, `maxPauseMs 4`,
196
+ `maxArrayBuffersGrowth 0`) and the zero-retention gate (`maxBytesPerCall 0`).
197
+ T9 gains control (g): a per-visit-allocating emitter body demonstrably trips the
198
+ retained-alloc gate through the same helper.
199
+
6
200
  ## [1.1.1] - 2026-09-03
7
201
 
8
202
  ### Added
package/Project.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Type declarations for @zakkster/lite-project v1.1.1
1
+ // Type declarations for @zakkster/lite-project v1.4.0
2
2
  // Zero-GC projections for @zakkster/lite-signal.
3
3
  // (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com> -- MIT
4
4
 
@@ -25,6 +25,41 @@ export interface ProjectionSource<K extends PropertyKey = PropertyKey, V = unkno
25
25
  export type ReconcilePolicy<K extends PropertyKey = PropertyKey, V = unknown> =
26
26
  (authoritative: V, overlayValue: V, key: K) => boolean;
27
27
 
28
+ /**
29
+ * Options for a single {@link Projection.set}. `ttl` (a finite number > 0, in the
30
+ * clock's units) auto-REVERTS the staged overlay at `now() + ttl` -- the source is
31
+ * never touched. A bad `ttl` throws before staging; a re-set without `ttl` cancels
32
+ * a prior expiry (each set fully specifies its overlay's lifetime).
33
+ */
34
+ export interface SetOptions {
35
+ ttl?: number;
36
+ }
37
+
38
+ /**
39
+ * An injectable clock for overlay TTL. `now()` returns a monotonic number,
40
+ * `setTimer(fn, ms)` schedules `fn` after `ms` and returns a handle, and
41
+ * `clearTimer(handle)` cancels it. All-or-none: supply all three or none.
42
+ */
43
+ export interface ProjectionClock {
44
+ now(): number;
45
+ setTimer(fn: () => void, ms: number): unknown;
46
+ clearTimer(handle: unknown): void;
47
+ }
48
+
49
+ /** Options for {@link project} / {@link Projector.project}: an optional injectable clock. */
50
+ export interface ProjectOptions extends Partial<ProjectionClock> {}
51
+
52
+ /**
53
+ * One staged draft as a patch entry: the current source value (`from`) and the
54
+ * staged overlay value (`to`) for `key`. The materialized shape returned by
55
+ * {@link Projection.toPatch}.
56
+ */
57
+ export interface Patch<K extends PropertyKey = PropertyKey, V = unknown> {
58
+ key: K;
59
+ from: V;
60
+ to: V;
61
+ }
62
+
28
63
  /**
29
64
  * A projection handle: a granular, derived, non-mutating draft overlay over a
30
65
  * keyed source. Each touched key owns one overlay signal + one projected
@@ -33,8 +68,12 @@ export type ReconcilePolicy<K extends PropertyKey = PropertyKey, V = unknown> =
33
68
  export interface Projection<K extends PropertyKey = PropertyKey, V = unknown> {
34
69
  /** Reactive: the overlay value if one is staged for `key`, else the source value. */
35
70
  get(key: K): V;
36
- /** Stage an EPHEMERAL overlay for `key`. The source is NOT mutated. */
37
- set(key: K, value: V): void;
71
+ /**
72
+ * Stage an EPHEMERAL overlay for `key`. The source is NOT mutated. Pass
73
+ * `{ ttl }` to auto-revert the overlay at `now() + ttl`; a re-set without
74
+ * `ttl` cancels a pending expiry.
75
+ */
76
+ set(key: K, value: V, opts?: SetOptions): void;
38
77
  /** Drop one key's overlay (revert that key to the source). */
39
78
  clear(key: K): void;
40
79
  /** Untracked diagnostic: is `key` currently overlaid? */
@@ -53,6 +92,26 @@ export interface Projection<K extends PropertyKey = PropertyKey, V = unknown> {
53
92
  peek(key: K): V;
54
93
  /** Iterate currently-overlaid keys with their overlay values (untracked). */
55
94
  forEachOverlay(fn: (key: K, value: V) => void): void;
95
+ /**
96
+ * Emit the staged drafts as a patch stream `fn(key, from, to)` -- `from` is
97
+ * the UNTRACKED current source value, `to` the staged overlay. Read-only and
98
+ * untracked: it touches neither the source nor the overlays and subscribes the
99
+ * caller to nothing, so it is safe inside an effect. Visits exactly the
100
+ * overlaid keys, in {@link Projection.forEachOverlay} order, with a zero-alloc
101
+ * per-key body. An overlaid key is emitted whether or not `Object.is(from, to)`
102
+ * (the visit set stays equal to `dirtyCount()`); pass `skip` -- the same
103
+ * predicate shape reconcile uses, e.g. {@link confirmOnEcho} -- to drop
104
+ * unchanged drafts. A throwing `source.get` propagates on that key with the
105
+ * overlay bag intact (callers needing atomicity use {@link Projection.toPatch}).
106
+ */
107
+ forEachPatch(fn: (key: K, from: V, to: V) => void, skip?: ReconcilePolicy<K, V>): void;
108
+ /**
109
+ * Cold convenience over {@link Projection.forEachPatch}: materialize the drafts
110
+ * as `[{ key, from, to }, ...]` -- same visit set, order, and values. The
111
+ * per-key record is this form's allocation; reach for `forEachPatch` when you
112
+ * need the zero-alloc callback.
113
+ */
114
+ toPatch(skip?: ReconcilePolicy<K, V>): Array<Patch<K, V>>;
56
115
  /**
57
116
  * Full-snapshot reconciliation: drop every overlay the policy considers
58
117
  * confirmed against the current (untracked) source value. Presentation-only --
@@ -61,6 +120,18 @@ export interface Projection<K extends PropertyKey = PropertyKey, V = unknown> {
61
120
  reconcileAll(policy?: ReconcilePolicy<K, V>): void;
62
121
  /** Write staged overlays into the source, then clear them. With `key`, commits just that key. */
63
122
  commit(key?: K): void;
123
+ /**
124
+ * Predicate-scoped partial commit: write and clear only the overlaid keys for
125
+ * which `pred(key, stagedValue)` returns true (the {@link Projection.forEachOverlay}
126
+ * callback order), in one propagation. A throwing `pred` propagates with the
127
+ * already-committed keys committed and `dirtyCount() === overlaidCount()`.
128
+ */
129
+ commitWhere(pred: (key: K, value: V) => boolean): void;
130
+ /**
131
+ * Predicate-scoped partial discard: drop only the overlaid keys for which
132
+ * `pred(key, stagedValue)` returns true. The source is never touched.
133
+ */
134
+ clearWhere(pred: (key: K, value: V) => boolean): void;
64
135
  /** Drop all overlays. */
65
136
  revert(): void;
66
137
  /**
@@ -129,6 +200,7 @@ export interface ProjectorRegistry {
129
200
  export interface Projector {
130
201
  project<K extends PropertyKey = PropertyKey, V = unknown>(
131
202
  source: ProjectionSource<K, V>,
203
+ opts?: ProjectOptions,
132
204
  ): Projection<K, V>;
133
205
  keyedStore<K extends PropertyKey = PropertyKey, V = unknown>(
134
206
  initial?: Record<PropertyKey, V>,
@@ -141,6 +213,7 @@ export function createProjector(reg: ProjectorRegistry): Projector;
141
213
  /** Project a keyed source (default registry). */
142
214
  export function project<K extends PropertyKey = PropertyKey, V = unknown>(
143
215
  source: ProjectionSource<K, V>,
216
+ opts?: ProjectOptions,
144
217
  ): Projection<K, V>;
145
218
 
146
219
  /** Minimal built-in keyed reactive source (default registry). */
@@ -188,6 +261,7 @@ export function makeReconciler<K extends PropertyKey = PropertyKey, V = unknown>
188
261
  */
189
262
  export function projectStore<V = unknown>(
190
263
  store: Record<PropertyKey, V>,
264
+ opts?: ProjectOptions,
191
265
  ): Projection<PropertyKey, V>;
192
266
 
193
267
  /** The subset of a @zakkster/lite-room handle that {@link projectRoom} consumes. */
@@ -200,8 +274,8 @@ export interface RoomLike {
200
274
  };
201
275
  }
202
276
 
203
- /** Options for {@link projectRoom}. */
204
- export interface ProjectRoomOptions {
277
+ /** Options for {@link projectRoom}. Extends the injectable clock for overlay TTL. */
278
+ export interface ProjectRoomOptions extends Partial<ProjectionClock> {
205
279
  /** Reconciliation policy; defaults to {@link confirmOnEcho}. */
206
280
  policy?: ReconcilePolicy<string, unknown>;
207
281
  }
@@ -226,8 +300,9 @@ export interface QueryClientLike {
226
300
  setQueryData(key: unknown, valueOrUpdater: unknown | ((prev: unknown) => unknown)): unknown;
227
301
  }
228
302
 
229
- /** Options for {@link projectQuery}. */
230
- export interface ProjectQueryOptions<V extends object = Record<PropertyKey, unknown>> {
303
+ /** Options for {@link projectQuery}. Extends the injectable clock for overlay TTL. */
304
+ export interface ProjectQueryOptions<V extends object = Record<PropertyKey, unknown>>
305
+ extends Partial<ProjectionClock> {
231
306
  /**
232
307
  * The query's reactive data accessor (e.g. `query.data`). When supplied,
233
308
  * projected reads track the cache and auto-reconcile is armed. Omit to degrade
@@ -256,3 +331,53 @@ export function projectQuery<V extends object = Record<PropertyKey, unknown>>(
256
331
  key: unknown,
257
332
  opts?: ProjectQueryOptions<V>,
258
333
  ): Projection<keyof V, unknown>;
334
+
335
+ /**
336
+ * The subset of a @zakkster/lite-crdt LWW-Map (`doc.map(name)`) that
337
+ * {@link projectCRDT} consumes. `get(key)` must be a FINE-GRAINED reactive read
338
+ * (re-runs only when that key's cell changes); `set(key, value)` emits a CRDT op.
339
+ * Keys are string-coerced by lite-crdt.
340
+ */
341
+ export interface LWWMapLike {
342
+ /** Fine-grained reactive read: tracks the cell backing `key`. */
343
+ get(key: string): unknown;
344
+ /** Write the cell for `key` (emits a CRDT op). */
345
+ set(key: string, value: unknown): void;
346
+ /** Optional authoritative delete (emits a tombstone op). */
347
+ delete?(key: string): void;
348
+ }
349
+
350
+ /** Options for {@link projectCRDT}. Extends the injectable clock for overlay TTL. */
351
+ export interface ProjectCRDTOptions extends Partial<ProjectionClock> {
352
+ /** Reconciliation policy for auto-reconcile; defaults to {@link confirmOnEcho}. */
353
+ policy?: ReconcilePolicy<string, unknown>;
354
+ /**
355
+ * Optional transact hook (e.g. `doc.transact`) that wraps `commit` and
356
+ * `commitWhere` so an N-key burst coalesces into ONE ops frame + one change.
357
+ * A supplied-but-non-function value throws before any node is created.
358
+ */
359
+ transact?: <T>(fn: () => T) => T;
360
+ }
361
+
362
+ /**
363
+ * Project a @zakkster/lite-crdt LWW-Map (`doc.map(name)`) as a per-key DRAFT
364
+ * overlay. Inherits the map's fine-grained granularity, so overlaying or
365
+ * committing one cell never re-runs a consumer of another. `set` stages a local
366
+ * draft, `commit(key?)` promotes drafts via `map.set` (one op per key; pass
367
+ * `opts.transact` to coalesce a burst into one frame), and an auto-reconcile drops
368
+ * drafts the authoritative cell catches up to while leaving conflicts (and
369
+ * concurrent authoritative deletes) masked.
370
+ *
371
+ * TWO recorded hazards: (1) lite-crdt's `get` returns a deep READ-ONLY WRAPPER for
372
+ * object values, so `confirmOnEcho` (Object.is) never auto-confirms an object
373
+ * draft -- use a `{ ttl }` draft or a structural policy, and never mutate the
374
+ * authoritative value a policy is handed. (2) Keys are string-coerced, so drafts
375
+ * on `5` and `"5"` are two slots committing into one cell (last write wins).
376
+ * Consumed structurally (no hard dependency on lite-crdt) and never touches the
377
+ * doc or `map.store`. Dispose the projection BEFORE the doc. The returned handle's
378
+ * `dispose()` also stops the reconcile effect.
379
+ */
380
+ export function projectCRDT(
381
+ map: LWWMapLike,
382
+ opts?: ProjectCRDTOptions,
383
+ ): Projection<string, unknown>;
package/Project.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @zakkster/lite-project v1.1.1 -- zero-GC projections for @zakkster/lite-signal.
2
+ * @zakkster/lite-project v1.4.0 -- zero-GC projections for @zakkster/lite-signal.
3
3
  * -----------------------------------------------------------------------------
4
4
  * A projection is a granular, derived, NON-MUTATING reactive view over a keyed
5
5
  * source: a lens that can carry ephemeral overlays (optimistic edits, merges,
@@ -21,6 +21,27 @@
21
21
  * circuit -- does NOT churn downstream consumers. The optimistic value
22
22
  * is stable under source noise.
23
23
  *
24
+ * Patch emission (forEachPatch / toPatch) exposes the staged drafts as a
25
+ * (key, from, to) stream for a save/sync trigger. It is READ-ONLY and UNTRACKED:
26
+ * it never touches the source or the overlays and subscribes the caller to nothing.
27
+ *
28
+ * -- OVERLAY TTL + PARTIAL COMMIT (1.3) --
29
+ * set(key, v, {ttl}) stages an overlay that auto-REVERTS at now()+ttl (drop the
30
+ * overlay, source untouched -- "the optimistic edit expired, fall back to
31
+ * authoritative"). ONE re-armed platform timer per projection; each slot stores its
32
+ * own deadline; every transition to ABSENT cancels that key's expiry. The clock is
33
+ * injectable and all-or-none via project(source, {now, setTimer, clearTimer}) --
34
+ * a mixed clock is a TypeError. commitWhere(pred) / clearWhere(pred) apply a
35
+ * predicate-scoped partial save / discard: pred(key, stagedValue), the
36
+ * forEachOverlay callback order.
37
+ *
38
+ * -- F-03 (recorded) -- confirmOnEcho is reference-equality (Object.is): an
39
+ * object-valued draft can never echo-confirm against a structurally-equal source
40
+ * value of a different reference. The fix is a CALLER-supplied structural policy
41
+ * (reconcileAll(policy) / the forEachPatch skip param accept one); this library
42
+ * ships NO deep-equal helper (a naive structural equal is a fail-open trap). The
43
+ * TTL is the shipped safety net: a stuck object draft self-heals on its deadline.
44
+ *
24
45
  * -- OWNERSHIP (why createRoot) --
25
46
  * Per-key nodes are created LAZILY, on the first get/set of a key -- which happens
26
47
  * inside whatever consumer effect first reads that key. Without detachment the
@@ -41,6 +62,15 @@
41
62
  * are the public-handle cost, the same split @zakkster/lite-signal itself draws
42
63
  * between pooled internals and escaping handles. Warm the keys you will churn.
43
64
  *
65
+ * -- ADAPTERS -- projectStore / projectRoom / projectQuery / projectCRDT [1.4].
66
+ * projectCRDT wraps a @zakkster/lite-crdt LWW-Map's FINE-GRAINED reactive get(key)
67
+ * for true per-key drafts. TWO recorded hazards: (1) get(key) returns a deep
68
+ * READ-ONLY WRAPPER for object values, so confirmOnEcho (Object.is) can never
69
+ * auto-confirm an object draft (use {ttl} or a structural policy; never mutate the
70
+ * authoritative wrapper); (2) lite-crdt STRING-COERCES keys, so drafts on `5` and
71
+ * `"5"` are two slots that commit into one cell (last write wins) -- stage under
72
+ * one key type. See the projectCRDT JSDoc for the full contract.
73
+ *
44
74
  * Registry-parametric: createProjector(reg) binds to any registry (the default one,
45
75
  * or a createRegistry({...}) for isolated tests). Default-bound `project` /
46
76
  * `keyedStore` are exported for the common case.
@@ -59,7 +89,7 @@ import {
59
89
  hasObservers as _hasObservers,
60
90
  } from "@zakkster/lite-signal";
61
91
 
62
- export const VERSION = "1.1.1";
92
+ export const VERSION = "1.4.0";
63
93
 
64
94
  // Module-level sentinel for "this key has no overlay". A unique symbol, never a
65
95
  // per-operation allocation. Stored directly in the overlay signal's value slot, so
@@ -119,9 +149,12 @@ export function createProjector(reg) {
119
149
  * and can commit / revert it.
120
150
  *
121
151
  * @param {{get:(key:PropertyKey)=>unknown, set:(key:PropertyKey, v:unknown)=>void}} source
152
+ * @param {{now?:Function, setTimer?:Function, clearTimer?:Function}} [opts] Optional
153
+ * injectable clock for overlay TTL (all-or-none: if any is supplied, all three
154
+ * must be functions). Defaults wrap performance.now / setTimeout / clearTimeout.
122
155
  * @returns {{
123
156
  * get:(key:PropertyKey)=>unknown, // reactive: overlay value if set, else source
124
- * set:(key:PropertyKey, v:unknown)=>void, // stage an EPHEMERAL overlay (source untouched)
157
+ * set:(key:PropertyKey, v:unknown, opts?:{ttl?:number})=>void, // stage an EPHEMERAL overlay; {ttl} auto-reverts it (source untouched)
125
158
  * clear:(key:PropertyKey)=>void, // drop one key's overlay (revert that key)
126
159
  * isOverlaid:(key:PropertyKey)=>boolean, // untracked diagnostic
127
160
  * overlaidCount:()=>number, // untracked diagnostic
@@ -129,15 +162,43 @@ export function createProjector(reg) {
129
162
  * isDirty:()=>boolean, // TRACKED: any staged overlays? (reactive)
130
163
  * peek:(key:PropertyKey)=>unknown, // untracked effective read (no subscribe)
131
164
  * forEachOverlay:(fn:(key:PropertyKey, value:unknown)=>void)=>void, // iterate overlaid keys (untracked)
165
+ * forEachPatch:(fn:(key:PropertyKey, from:unknown, to:unknown)=>void, skip?:Function)=>void, // patch stream (untracked, read-only)
166
+ * toPatch:(skip?:Function)=>Array<{key:PropertyKey, from:unknown, to:unknown}>, // materialized patch (cold convenience)
132
167
  * reconcileAll:(policy?:(authoritative:unknown, overlayValue:unknown, key:PropertyKey)=>boolean)=>void, // drop confirmed overlays
133
168
  * commit:(key?:PropertyKey)=>void, // write one key's overlay, or all, into the source then clear
169
+ * commitWhere:(pred:(key:PropertyKey, value:unknown)=>boolean)=>void, // write + clear only the matching overlays
170
+ * clearWhere:(pred:(key:PropertyKey, value:unknown)=>boolean)=>void, // drop only the matching overlays (source untouched)
134
171
  * revert:()=>void, // drop all overlays
135
172
  * dispose:()=>void, // recycle every projection-owned node to the pool
136
173
  * }}
137
174
  */
138
- function project(source) {
139
- // key -> { ov: overlay signal (ABSENT | value), read: projected computed }.
140
- // Lazily populated. One entry per touched key, retained until dispose().
175
+ function project(source, opts) {
176
+ // Injectable, all-or-none clock (S1). now/setTimer/clearTimer let the TTL
177
+ // run on a deterministic timeline in tests. A MIXED clock computes
178
+ // deadlines on one timeline and arms on another -> fail closed. Defaults
179
+ // wrap the platform globals in arrows: bare `setTimeout` refs throw
180
+ // "Illegal invocation" in browsers, and performance.now is monotonic so an
181
+ // NTP step cannot make a deadline unreachable (setTimer takes a delta).
182
+ if (opts != null && typeof opts !== "object") {
183
+ throw new TypeError("project: opts must be an object");
184
+ }
185
+ let _now, _setTimer, _clearTimer;
186
+ if (opts != null && (opts.now !== undefined || opts.setTimer !== undefined || opts.clearTimer !== undefined)) {
187
+ if (typeof opts.now !== "function" || typeof opts.setTimer !== "function" || typeof opts.clearTimer !== "function") {
188
+ throw new TypeError("project: now/setTimer/clearTimer must all be functions (all-or-none clock)");
189
+ }
190
+ _now = opts.now; _setTimer = opts.setTimer; _clearTimer = opts.clearTimer;
191
+ } else {
192
+ _now = () => performance.now();
193
+ _setTimer = (fn, ms) => setTimeout(fn, ms);
194
+ _clearTimer = (h) => clearTimeout(h);
195
+ }
196
+
197
+ // key -> { ov: overlay signal (ABSENT | value), read: projected computed,
198
+ // exp: deadline (0 == no expiry) }. Lazily populated. One entry per touched
199
+ // key, retained until dispose(). `exp` is a THIRD field at birth so the
200
+ // hidden class is stable; a ttl set is one field write, never a side Map.
201
+ // Invariant: exp !== 0 implies overlaid, so prune() never orphans a deadline.
141
202
  const slots = new Map();
142
203
 
143
204
  const slotFor = (key) => {
@@ -153,7 +214,7 @@ export function createProjector(reg) {
153
214
  const base = source.get(key); // track the source cell too
154
215
  return o === ABSENT ? base : o;
155
216
  });
156
- return { ov, read };
217
+ return { ov, read, exp: 0 };
157
218
  });
158
219
  slots.set(key, s);
159
220
  }
@@ -169,19 +230,134 @@ export function createProjector(reg) {
169
230
  const dirtySig = createRoot(() => signal(0));
170
231
  let dirty = 0;
171
232
 
233
+ // -- Overlay TTL (per-projection). ONE platform timer, re-armed; each slot
234
+ // stores its own deadline in `exp`, and arm/fire do an O(slots) cold scan.
235
+ // A min-heap would allocate per push -- rejected. `armedAt` is the deadline
236
+ // the live handle is set for; clear-before-set in _armAt bounds outstanding
237
+ // handles to exactly 1 (T7). ttlCount is the count of slots with exp !== 0.
238
+ let timerHandle = null; let armedAt = 0; let ttlCount = 0;
239
+ const _cancelTimer = () => {
240
+ if (timerHandle !== null) { _clearTimer(timerHandle); timerHandle = null; armedAt = 0; }
241
+ };
242
+ const _armAt = (d) => {
243
+ if (timerHandle !== null) _clearTimer(timerHandle); // clear-before-set: <= 1 handle
244
+ armedAt = d;
245
+ const ms = d - _now();
246
+ timerHandle = _setTimer(_fire, ms > 0 ? ms : 0); // clamp delta >= 0
247
+ };
248
+ // THE single cancellation helper: every transition to ABSENT calls it, so a
249
+ // stale deadline can never outlive its overlay. A hoisted function
250
+ // declaration (not an arrow) so it is defined before the closures above
251
+ // that reference it.
252
+ function _dropExp(s) {
253
+ if (s.exp !== 0) { s.exp = 0; if (--ttlCount === 0) _cancelTimer(); }
254
+ }
255
+ // The existing plain-set body, hoisted: stage `v` as an overlay and keep the
256
+ // dirty bookkeeping. Returns the slot so callers can set/clear its expiry.
257
+ const _stage = (key, v) => {
258
+ const s = slotFor(key);
259
+ const wasAbsent = s.ov.peek() === ABSENT;
260
+ s.ov.set(v);
261
+ if (wasAbsent) { dirty++; dirtySig.set(dirty); }
262
+ return s;
263
+ };
264
+ // The fire handler: ONE hoisted per-projection closure (the _readSrc
265
+ // precedent). Reverts exactly the keys due (exp <= now), in one batch, with
266
+ // per-drop dirty bookkeeping (clear()'s fail-closed pattern: a throwing
267
+ // _now/registry leaves dirty == overlaid count). The source is NEVER touched.
268
+ // _rearm runs AFTER the batch, so a subscriber's set(k,v,{ttl}) during the
269
+ // flush is honoured; _armAt clears first, so still <= 1 handle (T1).
270
+ const _fire = () => {
271
+ timerHandle = null; armedAt = 0;
272
+ if (ttlCount === 0) return; // spurious after a full cancel / post-dispose
273
+ const t = _now();
274
+ batch(() => {
275
+ for (const s of slots.values()) {
276
+ const e = s.exp;
277
+ if (e === 0 || e > t) continue; // not due -> untouched
278
+ _dropExp(s);
279
+ if (s.ov.peek() !== ABSENT) { s.ov.set(ABSENT); dirty--; dirtySig.set(dirty); }
280
+ }
281
+ });
282
+ _rearm();
283
+ };
284
+ const _rearm = () => {
285
+ if (ttlCount === 0) { _cancelTimer(); return; }
286
+ let min = Infinity;
287
+ for (const s of slots.values()) { const e = s.exp; if (e !== 0 && e < min) min = e; }
288
+ if (min === Infinity) { ttlCount = 0; _cancelTimer(); return; } // defensive, fail closed
289
+ _armAt(min);
290
+ };
291
+ // Cold ttl branch of set(). A {}/{policy} bag (ttl === undefined) behaves as
292
+ // plain set AND still cancels a prior expiry. A bad ttl throws BEFORE staging,
293
+ // so the bag and source stay untouched. A re-set with an EARLIER deadline
294
+ // re-arms; a LATER one does not (the armed earlier timer fires spuriously and
295
+ // re-arms -- that is the contract).
296
+ const _setWithOpts = (key, v, o) => {
297
+ // A null bag is tolerated (old 2-arg-era `set(k, v, null)` behaved as
298
+ // a plain set), mirroring project(source, null). Non-null non-object
299
+ // bags degrade to a plain set via undefined member reads.
300
+ const ttl = o == null ? undefined : o.ttl;
301
+ if (ttl === undefined) { _dropExp(_stage(key, v)); return; }
302
+ if (!Number.isFinite(ttl) || ttl <= 0) {
303
+ throw new TypeError("set: ttl must be a finite number > 0");
304
+ }
305
+ const d = _now() + ttl;
306
+ if (!Number.isFinite(d)) {
307
+ throw new TypeError("project: clock now() must return a finite number");
308
+ }
309
+ const s = _stage(key, v);
310
+ if (s.exp === 0) ttlCount++;
311
+ s.exp = d;
312
+ if (timerHandle === null || d < armedAt) _armAt(d);
313
+ };
314
+
315
+ // Patch emission: iterate exactly the overlaid keys, handing scalars
316
+ // (key, from, to) to `fn` -- `from` is the UNTRACKED current source value,
317
+ // `to` the staged overlay. Read-only: overlays via .peek(), source under
318
+ // untrack, so calling this inside an effect subscribes to nothing. The
319
+ // visit set / order is byte-identical to forEachOverlay. Optional `skip`
320
+ // reuses ReconcilePolicy: (from, to, key) => true drops that key from the
321
+ // stream (e.g. `forEachPatch(fn, confirmOnEcho)` skips echoes); default
322
+ // undefined emits every overlaid key, changed or not. A throwing
323
+ // source.get propagates on the offending key with no writes anywhere, so
324
+ // the overlay bag is intact by construction (fn may already have run for
325
+ // earlier keys; callers needing atomicity use toPatch()).
326
+ const forEachPatch = (fn, skip) => {
327
+ for (const [key, s] of slots) {
328
+ const to = s.ov.peek();
329
+ if (to === ABSENT) continue;
330
+ _pk = key;
331
+ const from = untrack(_readSrc);
332
+ if (skip !== undefined && skip(from, to, key)) continue;
333
+ fn(key, from, to);
334
+ }
335
+ };
336
+
337
+ // Hoisted scratch for forEachPatch's untracked source read: ONE closure
338
+ // per projection, never per key/call, so the per-key emit body allocates
339
+ // nothing. `untrack` needs a function; _readSrc is it.
340
+ let _pk;
341
+ const _readSrc = () => source.get(_pk);
342
+
172
343
  return {
173
344
  get: (key) => slotFor(key).read(),
174
- set: (key, v) => {
175
- const s = slotFor(key);
176
- const wasAbsent = s.ov.peek() === ABSENT;
177
- s.ov.set(v);
178
- if (wasAbsent) { dirty++; dirtySig.set(dirty); }
345
+ // Hot path (warm no-ttl): one arg load + !== undefined compare (not
346
+ // taken), one _stage (identical work to the old body), one _dropExp
347
+ // (one field load + !== 0 compare, not taken). Zero allocation, zero
348
+ // branches taken. All ttl logic lives in the cold _setWithOpts. The
349
+ // opts branch also STILL cancels a prior expiry (set(k,v,{}) -> plain).
350
+ set: (key, v, opts) => {
351
+ if (opts !== undefined) { _setWithOpts(key, v, opts); return; }
352
+ const s = _stage(key, v);
353
+ _dropExp(s);
179
354
  },
180
355
  clear: (key) => {
181
356
  const s = slots.get(key);
182
357
  if (s !== undefined && s.ov.peek() !== ABSENT) {
183
358
  s.ov.set(ABSENT);
184
359
  dirty--; dirtySig.set(dirty);
360
+ _dropExp(s);
185
361
  }
186
362
  },
187
363
  isOverlaid: (key) => {
@@ -208,6 +384,18 @@ export function createProjector(reg) {
208
384
  if (o !== ABSENT) fn(key, o);
209
385
  }
210
386
  },
387
+ // Emit the overlaid keys as a patch stream fn(key, from, to). Untracked,
388
+ // read-only, zero-alloc per-key body. See forEachPatch above.
389
+ forEachPatch,
390
+ // Cold convenience over forEachPatch: materialize the drafts as
391
+ // [{ key, from, to }, ...] (same visit set, order, values). The
392
+ // per-key record is the documented allocation of this form; reach for
393
+ // forEachPatch when you need the zero-alloc callback.
394
+ toPatch: (skip) => {
395
+ const out = [];
396
+ forEachPatch((key, from, to) => { out.push({ key, from, to }); }, skip);
397
+ return out;
398
+ },
211
399
  // Full-snapshot reconciliation: drop every overlay the policy considers
212
400
  // confirmed against the CURRENT (untracked) source value. For sources that
213
401
  // sync wholesale rather than per-key. Presentation-only -- the source owns
@@ -220,7 +408,7 @@ export function createProjector(reg) {
220
408
  const o = s.ov.peek();
221
409
  if (o !== ABSENT) {
222
410
  const authoritative = untrack(() => source.get(key));
223
- if (pol(authoritative, o, key)) { s.ov.set(ABSENT); dropped++; }
411
+ if (pol(authoritative, o, key)) { s.ov.set(ABSENT); _dropExp(s); dropped++; }
224
412
  }
225
413
  }
226
414
  if (dropped) { dirty -= dropped; dirtySig.set(dirty); }
@@ -242,7 +430,7 @@ export function createProjector(reg) {
242
430
  const o = s.ov.peek();
243
431
  if (o !== ABSENT) {
244
432
  source.set(key, o); s.ov.set(ABSENT);
245
- dirty--; dirtySig.set(dirty);
433
+ dirty--; dirtySig.set(dirty); _dropExp(s);
246
434
  }
247
435
  }
248
436
  return;
@@ -250,7 +438,7 @@ export function createProjector(reg) {
250
438
  let changed = false;
251
439
  for (const [k, s] of slots) {
252
440
  const o = s.ov.peek();
253
- if (o !== ABSENT) { source.set(k, o); s.ov.set(ABSENT); changed = true; }
441
+ if (o !== ABSENT) { source.set(k, o); s.ov.set(ABSENT); _dropExp(s); changed = true; }
254
442
  }
255
443
  if (changed) { dirty = 0; dirtySig.set(0); }
256
444
  });
@@ -259,11 +447,39 @@ export function createProjector(reg) {
259
447
  batch(() => {
260
448
  let changed = false;
261
449
  for (const s of slots.values()) {
262
- if (s.ov.peek() !== ABSENT) { s.ov.set(ABSENT); changed = true; }
450
+ if (s.ov.peek() !== ABSENT) { s.ov.set(ABSENT); _dropExp(s); changed = true; }
263
451
  }
264
452
  if (changed) { dirty = 0; dirtySig.set(0); }
265
453
  });
266
454
  },
455
+ // Predicate-scoped partial ops (S7). pred(key, stagedValue) -- the
456
+ // forEachOverlay callback order, NOT ReconcilePolicy's. Visit slots in
457
+ // insertion order, ONE batch, per-drop dirty bookkeeping so a throwing
458
+ // pred leaves dirtyCount() === overlaidCount() (batch's finally still
459
+ // flushes): already-committed keys stay committed, no rollback.
460
+ commitWhere: (pred) => {
461
+ if (typeof pred !== "function") throw new TypeError("commitWhere: pred must be a function");
462
+ batch(() => {
463
+ for (const [k, s] of slots) {
464
+ const o = s.ov.peek();
465
+ if (o === ABSENT) continue;
466
+ if (!pred(k, o)) continue;
467
+ source.set(k, o);
468
+ s.ov.set(ABSENT); dirty--; dirtySig.set(dirty); _dropExp(s);
469
+ }
470
+ });
471
+ },
472
+ clearWhere: (pred) => {
473
+ if (typeof pred !== "function") throw new TypeError("clearWhere: pred must be a function");
474
+ batch(() => {
475
+ for (const [k, s] of slots) {
476
+ const o = s.ov.peek();
477
+ if (o === ABSENT) continue;
478
+ if (!pred(k, o)) continue;
479
+ s.ov.set(ABSENT); dirty--; dirtySig.set(dirty); _dropExp(s);
480
+ }
481
+ });
482
+ },
267
483
  /**
268
484
  * Reclaim slots for keys that are no longer in use.
269
485
  *
@@ -308,6 +524,9 @@ export function createProjector(reg) {
308
524
  dispose(dirtySig);
309
525
  slots.clear();
310
526
  dirty = 0;
527
+ // Cancel any pending TTL handle: a live setTimeout would hold the
528
+ // event loop open for up to `ttl` ms and retain this closure (T6).
529
+ _cancelTimer(); ttlCount = 0;
311
530
  },
312
531
  };
313
532
  }
@@ -408,8 +627,8 @@ export function makeReconciler(view, policy) {
408
627
  * @param {object} store A lite-store proxy from `store(...)`.
409
628
  * @returns {object} A projection handle (get/set/clear/commit/revert/isOverlaid/peek/...).
410
629
  */
411
- export function projectStore(store) {
412
- return project(fromProxy(store));
630
+ export function projectStore(store, opts) {
631
+ return project(fromProxy(store), opts);
413
632
  }
414
633
 
415
634
  /**
@@ -446,7 +665,9 @@ export function projectRoom(room, opts) {
446
665
  get: (key) => { room.storage.entries(); return room.storage.get(key); },
447
666
  set: (key, value) => room.storage.set(key, value),
448
667
  };
449
- const view = project(source);
668
+ // Forward the flat opts bag: project reads only the clock keys (now/setTimer/
669
+ // clearTimer); policy is consumed above and ignored there.
670
+ const view = project(source, opts);
450
671
  // Drop confirmed drafts whenever authoritative state changes. The effect
451
672
  // tracks `entries` (not overlays/projected computeds), so view.clear() inside
452
673
  // reconcileAll never re-triggers it -> no loop. reconcileAll reads the source
@@ -569,7 +790,9 @@ export function projectQuery(qc, key, opts) {
569
790
  return merge(prev, one);
570
791
  }),
571
792
  };
572
- const view = project(source);
793
+ // Forward the flat opts bag: project reads only the clock keys (now/setTimer/
794
+ // clearTimer); data/policy/merge are consumed above and ignored there.
795
+ const view = project(source, opts);
573
796
 
574
797
  // Auto-reconcile: only meaningful when the record read is reactive. Tracks
575
798
  // `data()` (never the overlays), so clearing drafts inside reconcileAll does
@@ -601,6 +824,120 @@ export function projectQuery(qc, key, opts) {
601
824
  qc.setQueryData(key, (prev) => merge(prev, overlays));
602
825
  view.revert();
603
826
  },
827
+ // Partial commit as ONE cache write (mirrors commit() above). NOT
828
+ // view.commitWhere: that would issue one setQueryData per matching field,
829
+ // breaking the single-write law. And NOT view.revert() after the write:
830
+ // revert drops the NON-matching drafts too (silent data loss) and skips
831
+ // per-key exp cancellation -- so clear ONLY the committed fields, in a
832
+ // batch, which folds the N dirtySig writes into one propagation.
833
+ commitWhere: (pred) => {
834
+ if (typeof pred !== "function") throw new TypeError("commitWhere: pred must be a function");
835
+ const overlays = Object.create(null);
836
+ let any = false;
837
+ view.forEachOverlay((f, v) => { if (pred(f, v)) { _put(overlays, f, v); any = true; } });
838
+ if (!any) return;
839
+ qc.setQueryData(key, (prev) => merge(prev, overlays));
840
+ _batch(() => {
841
+ const ks = _ownEnumerableKeys(overlays);
842
+ for (let i = 0; i < ks.length; i++) view.clear(ks[i]);
843
+ });
844
+ },
604
845
  dispose: () => { if (stopReconcile) stopReconcile(); view.dispose(); },
605
846
  };
606
847
  }
848
+
849
+ /**
850
+ * Project a @zakkster/lite-crdt LWW-Map (`doc.map(name)`) as a per-key DRAFT
851
+ * overlay for optimistic / tentative UI. Unlike projectRoom (which wraps
852
+ * lite-room's COARSE storage -- one entries signal, any change re-runs every
853
+ * projected key), an LWW-Map has FINE-GRAINED reactive `get(key)`, so this
854
+ * adapter is truly granular: overlaying or committing one cell never re-runs a
855
+ * consumer of another. The projection never joins the merge -- it only decides
856
+ * what the local view tentatively overrides:
857
+ *
858
+ * - set(key, value) stage a draft -- local only, NOT synced, the CRDT untouched
859
+ * - commit(key?) promote drafts via `map.set` (emits ops + syncs). One op
860
+ * per committed key (N ops / N frames); LWW ops are
861
+ * commutative + idempotent so N frames == 1 semantically.
862
+ * Pass opts.transact (e.g. doc.transact) to coalesce a burst
863
+ * into ONE ops frame + one change (commit and commitWhere both
864
+ * wrap through it)
865
+ * - revert() discard drafts
866
+ * - auto-reconcile whenever an OVERLAID key's authoritative cell changes (local
867
+ * echo or a remote applyOp), drafts the policy considers
868
+ * confirmed are dropped; a CONFLICTING authoritative value --
869
+ * including a concurrent authoritative DELETE (reads as
870
+ * undefined) -- leaves the draft masked
871
+ *
872
+ * The map handle is consumed STRUCTURALLY: any object exposing a fine-grained
873
+ * reactive `get(key)` and a `set(key, value)` works, so this adapter adds no hard
874
+ * dependency on lite-crdt and never touches the doc, `map.store`, or the coarse
875
+ * reads (keys/values/entries/size). The reconcile effect tracks exactly the
876
+ * source cell of each currently-overlaid key (via `view.dirtyCount()` for the
877
+ * overlay-set dependency + a bare `map.get(k)` per overlaid key), so it re-runs
878
+ * on overlay-set transitions and on authoritative changes to overlaid keys only.
879
+ *
880
+ * -- READ-ONLY WRAPPER (object values), the recorded contract --
881
+ * lite-crdt's `get(key)` returns a deep READ-ONLY WRAPPER for object/array values
882
+ * (a different reference than the one passed to set), WeakMap-cached so it is
883
+ * stable across reads. Two consequences: (1) confirmOnEcho (Object.is) can NEVER
884
+ * auto-confirm an OBJECT-valued draft over projectCRDT, even on a genuine local
885
+ * echo -- the wrapper breaks reference equality (scalars confirm normally); use a
886
+ * {ttl} draft (the shipped heal) or a caller-supplied STRUCTURAL policy, whose
887
+ * reads pass through the wrapper transparently. (2) A policy must NEVER attempt to
888
+ * MUTATE the authoritative argument it is handed for an object value -- it is that
889
+ * read-only wrapper and lite-crdt throws `readonly`.
890
+ *
891
+ * -- STRING-COERCION KEY ALIASING (caller hazard) --
892
+ * lite-crdt coerces every map key to a string, but projection slots are keyed by
893
+ * PropertyKey. So drafts on `5` and `"5"` (or a Symbol coerced elsewhere) are TWO
894
+ * projection slots that commit into ONE CRDT cell -- last write wins, and
895
+ * dirtyCount() never reveals the collision. Stage under ONE key type. A
896
+ * `"__proto__"` map key is not usable in lite-crdt: `map.set` throws
897
+ * CRDTError("misconfigured"). The adapter does NOT wrap that policy -- a commit of
898
+ * a `"__proto__"` draft propagates the CRDT's own error with the draft still
899
+ * staged and dirtyCount() consistent (fail closed).
900
+ *
901
+ * -- DISPOSE ORDER --
902
+ * `doc.dispose()` makes subsequent mutations SILENT no-ops, so a commit AFTER the
903
+ * doc is disposed writes nothing yet still clears the drafts (an inherited
904
+ * dead-source data-loss class). Dispose the PROJECTION before the doc.
905
+ *
906
+ * @param {{get:(key:string)=>unknown, set:(key:string, value:unknown)=>void, delete?:(key:string)=>void}} map
907
+ * A lite-crdt LWW-Map (`doc.map(name)`) or any structural equivalent.
908
+ * @param {{policy?: (authoritative:unknown, draft:unknown, key:string)=>boolean,
909
+ * transact?: <T>(fn:()=>T)=>T,
910
+ * now?:Function, setTimer?:Function, clearTimer?:Function}} [opts]
911
+ * Reconciliation policy (default confirmOnEcho), an optional transact hook
912
+ * that wraps commit/commitWhere, and the injectable overlay-TTL clock.
913
+ * @returns {object} A projection handle whose dispose() also stops the reconcile effect.
914
+ */
915
+ export function projectCRDT(map, opts) {
916
+ if (map == null || typeof map.get !== "function" || typeof map.set !== "function") {
917
+ throw new TypeError("projectCRDT: map must expose get(key) and set(key, value)");
918
+ }
919
+ const policy = (opts && opts.policy) || confirmOnEcho;
920
+ // Fail closed: a supplied-but-not-a-function transact throws BEFORE any node
921
+ // is created. The branch is resolved once here, never per commit call.
922
+ const tx = opts && opts.transact !== undefined ? opts.transact : null;
923
+ if (tx !== null && typeof tx !== "function") {
924
+ throw new TypeError("projectCRDT: transact must be a function");
925
+ }
926
+ const source = { get: (k) => map.get(k), set: (k, v) => map.set(k, v) };
927
+ // Forward the flat opts bag: project reads only the clock keys (now/setTimer/
928
+ // clearTimer); policy/transact are consumed above and ignored there.
929
+ const view = project(source, opts);
930
+
931
+ // ONE hoisted per-adapter closure: forEachOverlay's callback, never a
932
+ // per-run arrow. The second callback arg (the staged value) is ignored.
933
+ const _trackSrc = (k) => { map.get(k); };
934
+ const stopReconcile = _effect(() => {
935
+ view.dirtyCount(); // TRACKED: re-establishes deps on every overlay-set change
936
+ view.forEachOverlay(_trackSrc); // TRACKED map.get per overlaid key (overlay side peeks)
937
+ view.reconcileAll(policy); // untracked reads inside -> adds no deps
938
+ });
939
+
940
+ const commit = tx === null ? view.commit : (key) => { tx(() => view.commit(key)); };
941
+ const commitWhere = tx === null ? view.commitWhere : (pred) => { tx(() => view.commitWhere(pred)); };
942
+ return { ...view, commit, commitWhere, dispose: () => { stopReconcile(); view.dispose(); } };
943
+ }
package/README.md CHANGED
@@ -93,16 +93,18 @@ In steady state the projection allocates nothing: toggling an overlay on a key y
93
93
 
94
94
  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.
95
95
 
96
- ### `project(source) -> Projection`
96
+ ### `project(source, opts?) -> Projection`
97
97
 
98
- `source` is any object with a reactive `get(key)` and a `set(key, value)`. Returns a handle:
98
+ `source` is any object with a reactive `get(key)` and a `set(key, value)`. `opts` is an optional injectable clock for overlay TTL -- `{ now, setTimer, clearTimer }`, all-or-none (a mixed clock is a `TypeError`); it defaults to `performance.now` / `setTimeout` / `clearTimeout`. Returns a handle:
99
99
 
100
100
  | method | description |
101
101
  | --- | --- |
102
102
  | `get(key)` | reactive: overlay value if staged, else the source value |
103
- | `set(key, value)` | stage an **ephemeral** overlay (source untouched) |
103
+ | `set(key, value, opts?)` | stage an **ephemeral** overlay (source untouched); pass `{ ttl }` to auto-revert it <sub>1.3</sub> |
104
104
  | `clear(key)` | drop one key's overlay (revert that key) |
105
105
  | `commit(key?)` | write one key's overlay (or, with no arg, all) into the source, then clear |
106
+ | `commitWhere(pred)` | write + clear only the overlays where `pred(key, value)` is true <sub>1.3</sub> |
107
+ | `clearWhere(pred)` | drop only the overlays where `pred(key, value)` is true (source untouched) <sub>1.3</sub> |
106
108
  | `revert()` | drop all overlays |
107
109
  | `dirtyCount()` | **tracked / reactive**: count of staged overlays — wire a Save badge to it |
108
110
  | `isDirty()` | **tracked / reactive**: `dirtyCount() > 0` |
@@ -110,6 +112,8 @@ Bind the primitives to a lite-signal registry. Pass the default namespace for no
110
112
  | `overlaidCount()` | untracked diagnostic: number of overlaid keys |
111
113
  | `peek(key)` | untracked effective read (no subscribe) |
112
114
  | `forEachOverlay(fn)` | iterate overlaid keys + values (untracked) |
115
+ | `forEachPatch(fn, skip?)` | emit staged drafts as a `(key, from, to)` stream (untracked, read-only, zero-alloc per key) <sub>1.2</sub> |
116
+ | `toPatch(skip?)` | materialize the drafts as `[{ key, from, to }, ...]` (cold convenience over `forEachPatch`) <sub>1.2</sub> |
113
117
  | `reconcileAll(policy?)` | drop overlays the policy confirms against the current source |
114
118
  | `prune()` | release slots for keys that are neither overlaid nor observed; returns how many were freed <sub>1.1</sub> |
115
119
  | `dispose()` | recycle every projection-owned node back to the pool |
@@ -175,6 +179,82 @@ Projects a single query entry's data **object**, exposing its **fields** as the
175
179
 
176
180
  The default merge copies **own enumerable** properties, symbols included, and defines them rather than assigning them. That matters for three field names you would otherwise lose silently: a field literally called `__proto__` lands as a real own key (assignment would retarget the prototype and drop it), inherited properties on `prev` are not absorbed into the record, and a symbol-keyed draft survives the commit instead of evaporating while `dirtyCount()` reports it saved. A custom `merge` is on its own for all three.
177
181
 
182
+ ### `projectCRDT(map, { policy, transact })` -- fine-grained drafts over [lite-crdt](https://www.npmjs.com/package/@zakkster/lite-crdt) <sub>1.4</sub>
183
+
184
+ ```js
185
+ import { projectCRDT } from "@zakkster/lite-project";
186
+
187
+ const doc = createCRDTDoc({ replicaId });
188
+ const map = doc.map("profile"); // a lite-crdt LWW-Map
189
+ const draft = projectCRDT(map, { transact: doc.transact });
190
+
191
+ draft.set("name", "Ada"); // optimistic edit, the CRDT is untouched
192
+ draft.set("city", "London");
193
+ draft.commit(); // both cells promoted in ONE ops frame (transact)
194
+ ```
195
+
196
+ Where `projectRoom` wraps lite-room's **coarse** storage (a single `entries` signal -- any change re-evaluates every projected key), a lite-crdt LWW-Map exposes a **fine-grained** reactive `get(key)`, so `projectCRDT` is truly granular: overlaying or committing one cell never re-runs a consumer of another. `set(key, value)` stages a local draft (no op emitted); `commit(key?)` promotes drafts through `map.set` -- one op per committed key (LWW ops are commutative and idempotent, so N frames are semantically one). Pass `transact` (e.g. `doc.transact`) to coalesce a burst into a single ops frame; it wraps both `commit` and `commitWhere`. An auto-reconcile drops a draft the authoritative cell catches up to (a local echo or a remote `applyOp`) while leaving **conflicts** -- and a concurrent authoritative **delete**, which reads as `undefined` -- masked. The map is consumed structurally (any `{ get, set }` whose `get` is fine-grained reactive), so there is no hard dependency on lite-crdt, and the projection never touches the doc or `map.store`. `dispose()` stops the reconcile effect.
197
+
198
+ Two hazards are recorded contracts, not bugs:
199
+
200
+ - **Read-only object wrapper.** lite-crdt's `get(key)` returns a deep **read-only wrapper** for object/array values (a different reference than the one you passed to `set`). So `confirmOnEcho` (`Object.is`) can never auto-confirm an **object-valued** draft, even on a genuine local echo -- the wrapper breaks reference equality. Use a `{ ttl }` draft (the shipped self-heal) or a caller-supplied **structural** policy, whose reads pass through the wrapper transparently; never mutate the authoritative value a policy is handed (it is read-only and lite-crdt throws). Scalars confirm normally.
201
+ - **String-coercion key aliasing.** lite-crdt coerces every map key to a string. Drafts on `5` and `"5"` are two projection slots that commit into one cell (last write wins), and `dirtyCount()` never reveals the collision -- stage under one key type. A `"__proto__"` map key throws `CRDTError` on commit (fail closed: the draft stays staged). And because `doc.dispose()` makes writes silent no-ops, a commit **after** the doc is disposed writes nothing yet still clears the drafts -- dispose the projection before the doc.
202
+
203
+ ## Patch emission <sub>1.2</sub>
204
+
205
+ The overlay bag already knows every staged draft's `to`; `forEachPatch` adds the source's `from` so a draft can cross the wire without re-walking the view.
206
+
207
+ ```js
208
+ const draft = project(source);
209
+ draft.set("name", "Ada");
210
+ draft.set("email", "ada@x.dev");
211
+
212
+ // Zero-alloc callback: hand each draft to a serializer / transport.
213
+ draft.forEachPatch((key, from, to) => {
214
+ wire.send({ op: "set", key, prev: from, next: to });
215
+ });
216
+
217
+ // Cold convenience: materialize the same deltas as an array.
218
+ const patch = draft.toPatch(); // [{ key: "name", from: undefined, to: "Ada" }, ...]
219
+ ```
220
+
221
+ `from` is the **untracked** current source value, `to` the staged overlay. Both methods are read-only and untracked -- calling them inside an effect subscribes it to nothing -- and visit exactly the overlaid keys, in `forEachOverlay` order. `forEachPatch` allocates nothing per key; `toPatch` is the cold convenience whose per-key record is its documented allocation.
222
+
223
+ An overlaid key is emitted whether or not `Object.is(from, to)`: the visit set stays equal to `dirtyCount()` and `commit()`'s write set, so a patch consumer (an LWW-Map op, a CRDT bump, an HTTP PATCH field) is never silently dropped. To suppress unchanged drafts, pass the same predicate shape reconcile uses -> `draft.forEachPatch(fn, confirmOnEcho)`. A throwing `source.get` propagates on the offending key with the overlay bag intact; callers needing atomicity use `toPatch()` (a partial array never escapes). The patch and `commit()` are two views of one delta: applying `toPatch()` to a copy of the source yields the same state `commit()` would write.
224
+
225
+ Present on the `projectStore` / `projectRoom` / `projectQuery` handles too (for `projectQuery`, `from` is the cached record's field value).
226
+
227
+ ## Overlay TTL + partial commit <sub>1.3</sub>
228
+
229
+ A pending overlay that never gets its ack has no way back. `set(key, value, { ttl })` gives it one: the overlay auto-**reverts** at `now() + ttl` (a finite number > 0), dropping the draft while the source stays untouched -- "the optimistic edit expired; fall back to authoritative".
230
+
231
+ ```js
232
+ const draft = project(source);
233
+
234
+ draft.set("status", "saving", { ttl: 5000 }); // reverts in 5s unless the ack clears it first
235
+ // ... the server confirms -> draft.clear("status") (or reconcile) cancels the expiry
236
+ ```
237
+
238
+ One re-armed timer runs per projection (each key stores its own deadline; arm and fire do an `O(slots)` cold scan, so the warm `set` path allocates nothing). A bad `ttl` throws **before** staging. A re-set **with** `ttl` re-arms; a re-set **without** `ttl` cancels the pending expiry -- each `set` fully specifies its overlay's lifetime. Every transition to un-overlaid (`clear`, `commit`, `revert`, a reconcile drop, `commitWhere` / `clearWhere`, and the fire itself) cancels that key's expiry, and `dispose()` cancels any pending handle.
239
+
240
+ For a **deterministic** TTL (tests, an animation clock, a server tick) pass an injectable clock -- all-or-none, or a mixed clock is a `TypeError`:
241
+
242
+ ```js
243
+ let t = 0;
244
+ const clock = { now: () => t, setTimer: (fn, ms) => schedule(fn, t + ms), clearTimer: cancel };
245
+ const draft = project(source, clock); // forwarded by projectStore/projectRoom/projectQuery too
246
+ ```
247
+
248
+ `commitWhere(pred)` and `clearWhere(pred)` are predicate-scoped partial saves: `pred(key, stagedValue)` (the `forEachOverlay` callback order) selects which overlays to act on, in one reactive propagation. `commitWhere` writes and clears only the matches; `clearWhere` discards them with zero source writes. A throwing `pred` is non-atomic on the core handle -- already-committed keys stay committed and `dirtyCount() === overlaidCount()`. On `projectQuery`, `commitWhere` is still a **single** `setQueryData` write for the matching fields, and the non-matching drafts survive.
249
+
250
+ ```js
251
+ draft.set("name", "Ada");
252
+ draft.set("email", "ada@x.dev");
253
+ draft.commitWhere((key) => key !== "email"); // save name, keep email staged
254
+ ```
255
+
256
+ **F-03.** `confirmOnEcho` is reference-equality (`Object.is`), so an object-valued draft can never echo-confirm against a structurally-equal source value of a different reference. The fix is a **caller-supplied** structural policy -- `reconcileAll(policy)` and the `forEachPatch` skip param both accept one; this library ships **no** deep-equal helper (a naive structural equal is a fail-open trap). The TTL is the shipped safety net: a stuck object draft self-heals on its deadline.
257
+
178
258
  ## Conventions
179
259
 
180
260
  ESM only. ASCII source. `node:test`. MIT.
package/llms.txt CHANGED
@@ -24,15 +24,48 @@ Peer dependency: @zakkster/lite-signal ^1.5.0 (uses createRoot). ESM only. MIT.
24
24
  ## Entry points
25
25
 
26
26
  - createProjector(reg) -> { project, keyedStore } // bind to any lite-signal registry
27
- - project(source) -> Projection // default registry
27
+ - project(source, opts?) -> Projection // default registry. [1.3] opts =
28
+ // {now?, setTimer?, clearTimer?} injectable
29
+ // clock for overlay TTL (all-or-none; mixed = TypeError)
28
30
  - keyedStore(initial?) -> { get, set, has, keys } // minimal built-in source
31
+ - VERSION -> string // shipped package version, synced to package.json
29
32
 
30
33
  ## Projection handle
31
34
 
32
- get(key) | set(key,value) | clear(key) | commit(key?) [one key or all] | revert() |
35
+ get(key) | set(key,value,opts?) | clear(key) | commit(key?) [one key or all] | revert() |
33
36
  dirtyCount() [TRACKED reactive] | isDirty() [TRACKED reactive] |
34
37
  isOverlaid(key) | overlaidCount() | peek(key) [untracked effective read] |
35
38
  forEachOverlay(fn) | reconcileAll(policy?) | dispose() [recycle all owned nodes] |
39
+ commitWhere(pred) [1.3] | clearWhere(pred) [1.3]
40
+ [predicate-scoped partial ops. pred(key, stagedValue) -- the forEachOverlay callback
41
+ order, NOT ReconcilePolicy's. Visit slots in insertion order, ONE reactive propagation
42
+ each. commitWhere writes source.set(key,value) + clears each matching overlay; clearWhere
43
+ drops matching overlays with ZERO source writes. A throwing pred is non-atomic on the core
44
+ handle: already-committed keys stay committed, dirtyCount()===overlaidCount(). projectQuery
45
+ OVERRIDES commitWhere to keep the single-write law (one setQueryData for the matching fields,
46
+ then clear them per-key; non-matching drafts survive -- it does NOT reuse commit()'s revert()).] |
47
+ set(key,value,{ttl}) [1.3]
48
+ [overlay TTL: schedules an auto-REVERT at now()+ttl (a finite number > 0, source untouched --
49
+ "the optimistic edit expired, fall back to authoritative"). ONE re-armed platform timer per
50
+ projection; each slot stores its own deadline; O(slots) cold scan on arm/fire (no side Map,
51
+ no per-set alloc on the warm path). Bad ttl (0/-1/NaN/Infinity/"5"/null) throws BEFORE staging.
52
+ Re-set WITH ttl re-arms (earlier deadline re-arms eagerly, later one fires spuriously + re-arms);
53
+ re-set WITHOUT ttl cancels the expiry (each set fully specifies its overlay's lifetime). EVERY
54
+ transition to un-overlaid (clear/commit(key)/commit()/revert/reconcileAll drop/commitWhere/
55
+ clearWhere/the fire) cancels that key's expiry; dispose() cancels the pending handle. F-03:
56
+ confirmOnEcho is reference-equality (Object.is) -- an object draft cannot echo-confirm across
57
+ references; the fix is a caller-supplied structural policy (reconcileAll/forEachPatch skip
58
+ accept one), NO deep-equal helper ships (fail-open trap), and the TTL is the shipped self-heal.] |
59
+ forEachPatch(fn, skip?) [1.2] | toPatch(skip?) -> [{key,from,to}] [1.2]
60
+ [patch emission: iterate exactly the overlaid keys as fn(key, from, to) -- from =
61
+ UNTRACKED current source value, to = staged overlay. Read-only + untracked: safe
62
+ inside an effect, subscribes to nothing. Same visit set/order as forEachOverlay,
63
+ zero-alloc per-key body. Emits UNCHANGED drafts by default (visit set stays equal
64
+ to dirtyCount()); pass a reconcile-policy predicate (from,to,key)=>bool, e.g.
65
+ confirmOnEcho, to skip echoes. A throwing source.get propagates on that key with
66
+ the overlay bag intact. toPatch() is the cold convenience that materializes the
67
+ stream (its per-key record is the documented allocation). Present on the
68
+ projectStore/projectRoom/projectQuery handles too.] |
36
69
  prune() -> number [1.1] [release slots that are BOTH un-overlaid AND unobserved;
37
70
  returns how many were freed. A slot = 1 overlay signal + 1 projected computed,
38
71
  created by the first READ of a key and retained until dispose() (its computed may
@@ -55,10 +88,10 @@ prune() -> number [1.1] [release slots that are BOTH un-overlaid AND unobserved;
55
88
 
56
89
  ## Library adapters
57
90
 
58
- - projectStore(store) // lite-store proxy. Per-key granular drafts;
91
+ - projectStore(store, opts?) // [1.3] opts forwards the injectable clock. lite-store proxy. Per-key granular drafts;
59
92
  // commit() writes through store[key]=v.
60
93
  // Top-level keys; pass a nested proxy to go deeper.
61
- - projectRoom(room, {policy?}) // lite-room room.storage (LWW-Map).
94
+ - projectRoom(room, {policy?}) // [1.3] opts also carry the clock keys (now/setTimer/clearTimer). lite-room room.storage (LWW-Map).
62
95
  // Presentation-only optimistic drafts: set = local
63
96
  // draft (not synced), commit() = room.storage.set
64
97
  // (writes + syncs), auto-reconcile drops confirmed
@@ -84,6 +117,31 @@ prune() -> number [1.1] [release slots that are BOTH un-overlaid AND unobserved;
84
117
  // retargeting the prototype, inherited props on prev are
85
118
  // not absorbed, and symbol-keyed drafts survive commit.
86
119
  // A custom merge owns all three concerns itself.
120
+ - projectCRDT(map, opts?) // [1.4] lite-crdt LWW-Map (doc.map(name)). FINE-GRAINED
121
+ // per-key drafts (unlike projectRoom's coarse storage):
122
+ // overlaying/committing one cell never re-runs a consumer
123
+ // of another. opts = {policy?, transact?, + clock keys}.
124
+ // set(key,v) stages a draft (CRDT untouched); commit(key?)
125
+ // promotes via map.set (one op per key / N frames; pass
126
+ // opts.transact = doc.transact to coalesce a burst into ONE
127
+ // ops frame + one change -- wraps commit AND commitWhere).
128
+ // auto-reconcile drops confirmed drafts on an overlaid key's
129
+ // authoritative change (local echo or remote applyOp);
130
+ // conflicts AND concurrent authoritative deletes (read as
131
+ // undefined) stay masked. map consumed structurally
132
+ // (get/set); no hard lite-crdt dep; never touches the doc or
133
+ // map.store. dispose() stops the reconcile effect.
134
+ // HAZARD (recorded): lite-crdt's get returns a deep READ-ONLY
135
+ // WRAPPER for object values -> Object.is confirmOnEcho can
136
+ // NEVER auto-confirm an object draft (use {ttl} or a
137
+ // structural policy; never mutate the authoritative wrapper).
138
+ // HAZARD: lite-crdt STRING-COERCES keys, so drafts on 5 and
139
+ // "5" are two projection slots committing into ONE cell (last
140
+ // write wins); dirtyCount() never reveals it -- stage under one
141
+ // key type. A "__proto__" map key throws CRDTError on commit
142
+ // (fail closed: the draft stays staged). Dispose the projection
143
+ // BEFORE the doc (a commit after doc.dispose() writes nothing
144
+ // yet still clears the drafts -- the dead-source hazard).
87
145
 
88
146
  ## Zero-GC
89
147
 
@@ -97,4 +155,6 @@ a Map entry, and two pooled nodes. Warm the keys you churn.
97
155
  consumer that first reads the key; without detachment they would be adopted by
98
156
  that consumer and cascade-disposed on its next re-run. createRoot detaches them.
99
157
  - A projection only refines what the source exposes: over lite-room's coarse
100
- storage it is coarse (any storage change re-evaluates every projected key).
158
+ storage it is coarse (any storage change re-evaluates every projected key). Over
159
+ a lite-crdt LWW-Map (projectCRDT) it is FINE-GRAINED -- per-key reactive get(key)
160
+ -- so committing one cell never re-runs a consumer of another. [1.4]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-project",
3
- "version": "1.1.1",
3
+ "version": "1.4.0",
4
4
  "description": "Zero-GC projections for @zakkster/lite-signal: granular, derived, non-mutating reactive overlays with commit / revert / reconcile, and draft adapters for lite-store and lite-room.",
5
5
  "type": "module",
6
6
  "main": "./Project.js",
@@ -33,9 +33,11 @@
33
33
  "@zakkster/lite-signal": "^1.5.0"
34
34
  },
35
35
  "devDependencies": {
36
+ "@zakkster/lite-crdt": "^2.0.0",
36
37
  "@zakkster/lite-gc-profiler": "^1.16.0",
37
38
  "@zakkster/lite-leak": "^1.10.0",
38
- "@zakkster/lite-signal": "^1.5.0"
39
+ "@zakkster/lite-signal": "^1.5.0",
40
+ "@zakkster/lite-store": "^1.2.1"
39
41
  },
40
42
  "keywords": [
41
43
  "reactive",