@zakkster/lite-project 1.2.0 → 1.4.1

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,179 @@
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.1] - 2026-09-05
7
+
8
+ ### Fixed
9
+
10
+ - **Hot-path transient allocation (~40 B/op on `get`/`peek`/`set`).** The slot-
11
+ creation closure lived inline in `slotFor`'s cold miss branch and captured
12
+ `key`, so V8 allocated a context object on EVERY `slotFor` call -- hit or
13
+ miss -- taxing the three hottest operations ~40 B/op each (measured: warm
14
+ `get` 2,002,808 B over 50,000 ops). `peek` had the same defect twice over via
15
+ inline `untrack(() => source.get(key))` closures, allocating even on the warm
16
+ overlaid path that never takes the fallthrough; `reconcileAll` once per
17
+ overlaid key. Fixes: slot creation hoisted to `_createSlot(key)` (context now
18
+ allocated only on the cold miss), `peek` and `reconcileAll` ride the hoisted
19
+ `_pk`/`_readSrc` scratch that `forEachPatch` already used. Measured after:
20
+ 0.04-0.15 B/op fixed noise across all warm windows. No API or behaviour
21
+ change.
22
+
23
+ ### Added
24
+
25
+ - **T6 Proof 0, the transient witness.** Warm `get` / `peek` / `set` /
26
+ `set-clear toggle` / `get+set+clear` triangle windows are now hard-gated by
27
+ the V8 new-space used-bytes delta over a GC-free 50,000-op window
28
+ (<= 16,384 B total each). Every prior lane -- the gc-profiler heap gate, the
29
+ retained-bytes bracket, the pool census -- is structurally blind to per-op
30
+ garbage that never survives a collection, which is how the 40 B/op defect
31
+ above passed the full gate. The GATE line now reports `transient=<n> B/op`
32
+ (triangle; measured 0.131 B/op). Falsified: reverting the fix makes the gate
33
+ exit 1 naming the window.
34
+
35
+ ## [1.4.0] - 2026-09-05
36
+
37
+ ### Added
38
+
39
+ - **`projectCRDT(map, opts?)`** -- a draft-overlay adapter for a
40
+ `@zakkster/lite-crdt` LWW-Map (`doc.map(name)`). Unlike `projectRoom` (which
41
+ wraps lite-room's **coarse** storage -- one `entries` signal, any change re-runs
42
+ every projected key), an LWW-Map has **fine-grained** reactive `get(key)`, so
43
+ `projectCRDT` is truly granular: overlaying or committing one cell never re-runs
44
+ a consumer of another. `set(key, value)` stages a local draft (the CRDT is
45
+ untouched); `commit(key?)` promotes drafts via `map.set` (one op per committed
46
+ key -- LWW ops are commutative + idempotent, so N frames are semantically one);
47
+ an auto-reconcile drops drafts the authoritative cell catches up to (a local
48
+ echo or a remote `applyOp`) while leaving conflicts -- and a concurrent
49
+ authoritative **delete** (reads as `undefined`) -- masked. The reconcile trigger
50
+ is ONE effect that reads `dirtyCount()` (tracked -- re-derives the dependency set
51
+ on every overlay-set transition) plus `map.get(k)` for each currently-overlaid
52
+ key, then calls `reconcileAll(policy)`; it re-runs only on overlay-set
53
+ transitions and on authoritative changes to overlaid keys. The map is consumed
54
+ **structurally** (any `{ get, set }` with a fine-grained reactive `get`), so
55
+ there is no hard dependency on lite-crdt, and the adapter never touches the doc,
56
+ `map.store`, or the coarse reads (`keys`/`values`/`entries`/`size`).
57
+ - **`opts.transact`** -- an optional hook (e.g. `doc.transact`) that wraps both
58
+ `commit` and `commitWhere` so an N-key burst coalesces into ONE ops frame + one
59
+ change (measured: staging 3 keys emits 0 ops; committing emits 3 ops / 3 frames
60
+ with no `transact`, 3 ops / **1** frame under `transact`). The branch is resolved
61
+ once at construction; a supplied-but-non-function `transact` throws before any
62
+ node is created. `LWWMapLike`, `ProjectCRDTOptions`, and the `projectCRDT`
63
+ declaration added to `Project.d.ts`; `decisions/0003-project-crdt.md` records the
64
+ design.
65
+
66
+ ### Notes (recorded contracts, not bugs)
67
+
68
+ - **Read-only object wrapper.** lite-crdt's `get(key)` returns a deep **read-only
69
+ wrapper** for object/array values (a different reference than the one passed to
70
+ `set`, WeakMap-cached and stable across reads). So `confirmOnEcho` (`Object.is`)
71
+ can **never** auto-confirm an object-valued draft over `projectCRDT`, even on a
72
+ genuine local echo -- use a `{ ttl }` draft (the shipped self-heal) or a
73
+ caller-supplied **structural** policy (whose reads pass through the wrapper
74
+ transparently). A policy must never attempt to mutate the authoritative value it
75
+ is handed for an object -- it is that read-only wrapper and lite-crdt throws
76
+ `readonly`. Scalars confirm normally.
77
+ - **String-coercion key aliasing.** lite-crdt coerces every map key to a string,
78
+ but projection slots are keyed by `PropertyKey`. Drafts on `5` and `"5"` are TWO
79
+ projection slots that commit into ONE CRDT cell (last write wins), and
80
+ `dirtyCount()` never reveals the collision -- stage under one key type. A
81
+ `"__proto__"` map key is not usable in lite-crdt (`map.set` throws
82
+ `CRDTError("misconfigured")`); the adapter does not wrap that policy -- a commit
83
+ of a `"__proto__"` draft propagates the CRDT's own error with the draft still
84
+ staged and `dirtyCount()` consistent (fail closed).
85
+ - **Dispose order.** `doc.dispose()` makes subsequent mutations silent no-ops, so a
86
+ commit **after** the doc is disposed writes nothing yet still clears the drafts
87
+ (an inherited dead-source data-loss class). Dispose the projection **before** the
88
+ doc.
89
+
90
+ ### Tests
91
+
92
+ - `test/crdt_test.mjs` (19 tests, real `@zakkster/lite-crdt` on the default
93
+ registry): op-counter (stage/commit/transact frames), the wrapper pin (object
94
+ draft after a genuine echo stays overlaid; a structural policy drops it), TTL
95
+ heal over `projectCRDT`, numeric/symbol key-alias pins, `"__proto__"` commit
96
+ fail-closed, granularity (a consumer of `get("b")` runs once across 10 commits to
97
+ `"a"`; the reconcile effect does not fire on non-overlaid-key writes), missing-key
98
+ draft (`from === undefined`; a remote `applyOp` re-runs the projected read),
99
+ authoritative-delete conflict, dispose ordering, and the post-`doc.dispose()`
100
+ commit hazard.
101
+ - Torture: `makeFakeMap` (a registry-parametric structural fake LWW-Map) drives new
102
+ `T4` (echo/conflict/late-overlay per key), `T5` (`projectCRDT` fuzz oracle + the
103
+ granularity law), `T6` (a warm echo-drop reconcile pass -- retains 0 B/call),
104
+ and `T9` controls `(j)` (a coarse-read effect fails the granularity law) and
105
+ `(k)` (a peek-only stale-deps effect misses a late-overlay echo). GATE unchanged:
106
+ `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | retained=0.00 B/op growths=0`.
107
+
108
+ ## [1.3.0] - 2026-09-05
109
+
110
+ ### Added
111
+
112
+ - **Overlay TTL -- `set(key, value, { ttl })`.** Stage an overlay that
113
+ auto-**reverts** at `now() + ttl` (a finite number > 0, in the clock's units):
114
+ the draft is dropped and the source is **never** touched -- "the optimistic edit
115
+ expired; fall back to authoritative". One re-armed platform timer per projection
116
+ (each slot stores its own deadline; arm/fire do an `O(slots)` cold scan -- no
117
+ side `Map`, no per-`set` allocation on the warm path). A bad `ttl`
118
+ (`0`, `-1`, `NaN`, `Infinity`, `"5"`, `null`, ...) throws **before** staging. A
119
+ re-set **with** `ttl` re-arms (an earlier deadline re-arms eagerly; a later one
120
+ lets the armed timer fire spuriously and re-arm); a re-set **without** `ttl`
121
+ cancels the pending expiry -- each `set` fully specifies its overlay's lifetime.
122
+ Every transition to un-overlaid (`clear`, `commit(key)`, `commit()`, `revert`,
123
+ a `reconcileAll` drop, `commitWhere`, `clearWhere`, and the fire itself) cancels
124
+ that key's expiry, and `dispose()` cancels any pending handle.
125
+ - **Injectable clock -- `project(source, { now, setTimer, clearTimer })`.**
126
+ All-or-none: supply all three (each a function) or none. A **mixed** clock is a
127
+ `TypeError` (it would compute deadlines on one timeline and arm on another --
128
+ fail closed). Defaults wrap `performance.now` / `setTimeout` / `clearTimeout`.
129
+ Forwarded by `projectStore(store, opts?)`, `projectRoom(room, opts?)`, and
130
+ `projectQuery(qc, key, opts?)` (the flat bag; `project` reads only the clock
131
+ keys). `SetOptions`, `ProjectionClock`, and `ProjectOptions` added to
132
+ `Project.d.ts`.
133
+ - **`Projection.commitWhere(pred)` / `Projection.clearWhere(pred)`** -- predicate-
134
+ scoped partial save / discard. `pred(key, stagedValue)` (the `forEachOverlay`
135
+ callback order, not `ReconcilePolicy`'s), visited in slots order, one reactive
136
+ propagation each. `commitWhere` writes and clears only the matching overlays;
137
+ `clearWhere` drops them with **zero** source writes. A throwing `pred` is
138
+ non-atomic on the core handle (already-committed keys stay committed and
139
+ `dirtyCount() === overlaidCount()`). `projectQuery` **overrides** `commitWhere`
140
+ to keep the single-write law: one `setQueryData(key, prev => merge(prev,
141
+ overlays))` for the matching fields, then the committed fields are cleared
142
+ per-key -- the non-matching drafts survive (it does **not** reuse the `commit()`
143
+ override's `revert()`, which would drop them too).
144
+
145
+ **F-03 recorded.** `confirmOnEcho` is reference-equality (`Object.is`), so an
146
+ object-valued draft can never echo-confirm against a structurally-equal source
147
+ value of a different reference. The fix is a **caller-supplied** structural
148
+ policy (`reconcileAll(policy)` and the `forEachPatch` skip param accept one);
149
+ this library ships **no** deep-equal helper (a naive structural equal is a
150
+ fail-open trap). The TTL is the shipped safety net: a stuck object draft
151
+ self-heals on its deadline. Recorded in `decisions/0002-overlay-ttl.md`
152
+ (dev-only; not shipped).
153
+
154
+ ### Verified
155
+
156
+ - 30 new `test/ttl_test.mjs` cases (fire at / not-before the deadline, byte-
157
+ identical source after a fire, re-arm + one-handle, plain re-set cancels,
158
+ `null` / `{}` / `{policy}` bags behave as plain `set` and still cancel,
159
+ cancellation at every ABSENT-transition site, the F-03 self-heal, the ttl +
160
+ mixed-clock + non-object-`project`-opts `TypeError`s, `commitWhere` /
161
+ `clearWhere` exact match + one
162
+ propagation + throwing-pred consistency, a set-with-ttl honoured inside a fire
163
+ subscriber, post-dispose inertness, and the four adapters incl. the
164
+ `projectQuery` single-write `commitWhere`); **114 tests total**, `node --test`.
165
+ - Torture green (default seed):
166
+ `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 |
167
+ alloc=n/a retained=0.00 B/op growths=0` (the binding channels are `major=0`,
168
+ `retained=0.00`, `growths=0`; the `alloc=` per-op bracket prints as-is, `n/a`
169
+ when the profiler's heap window was inconclusive). T4 gains the TTL door
170
+ (deterministic fake clock: expire / not-before / re-arm / cancel + the F-03
171
+ object heal). T6 gains Proof 5 -- warm ttl re-set + `commitWhere` + `clearWhere`
172
+ under an injected no-op clock retain `0 B/call` at `maxBytesPerCall 0` (the warm
173
+ no-ttl triangle passes the P0 gates unchanged). T7 gains a 1000-TTL sub-soak:
174
+ `maxOutstanding() === 1` at every instant, `0` after the drain fire and after
175
+ `dispose()`, tracker back to `size()===0`. T9 gains controls (h) a default-clock
176
+ projection tripping the deterministic expiry assertion, and (i) a leaky per-key
177
+ timer tripping the one-handle bound.
178
+
6
179
  ## [1.2.0] - 2026-09-05
7
180
 
8
181
  ### Added
package/Project.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Type declarations for @zakkster/lite-project v1.2.0
1
+ // Type declarations for @zakkster/lite-project v1.4.1
2
2
  // Zero-GC projections for @zakkster/lite-signal.
3
3
  // (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com> -- MIT
4
4
 
@@ -25,6 +25,30 @@ 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
+
28
52
  /**
29
53
  * One staged draft as a patch entry: the current source value (`from`) and the
30
54
  * staged overlay value (`to`) for `key`. The materialized shape returned by
@@ -44,8 +68,12 @@ export interface Patch<K extends PropertyKey = PropertyKey, V = unknown> {
44
68
  export interface Projection<K extends PropertyKey = PropertyKey, V = unknown> {
45
69
  /** Reactive: the overlay value if one is staged for `key`, else the source value. */
46
70
  get(key: K): V;
47
- /** Stage an EPHEMERAL overlay for `key`. The source is NOT mutated. */
48
- 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;
49
77
  /** Drop one key's overlay (revert that key to the source). */
50
78
  clear(key: K): void;
51
79
  /** Untracked diagnostic: is `key` currently overlaid? */
@@ -92,6 +120,18 @@ export interface Projection<K extends PropertyKey = PropertyKey, V = unknown> {
92
120
  reconcileAll(policy?: ReconcilePolicy<K, V>): void;
93
121
  /** Write staged overlays into the source, then clear them. With `key`, commits just that key. */
94
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;
95
135
  /** Drop all overlays. */
96
136
  revert(): void;
97
137
  /**
@@ -160,6 +200,7 @@ export interface ProjectorRegistry {
160
200
  export interface Projector {
161
201
  project<K extends PropertyKey = PropertyKey, V = unknown>(
162
202
  source: ProjectionSource<K, V>,
203
+ opts?: ProjectOptions,
163
204
  ): Projection<K, V>;
164
205
  keyedStore<K extends PropertyKey = PropertyKey, V = unknown>(
165
206
  initial?: Record<PropertyKey, V>,
@@ -172,6 +213,7 @@ export function createProjector(reg: ProjectorRegistry): Projector;
172
213
  /** Project a keyed source (default registry). */
173
214
  export function project<K extends PropertyKey = PropertyKey, V = unknown>(
174
215
  source: ProjectionSource<K, V>,
216
+ opts?: ProjectOptions,
175
217
  ): Projection<K, V>;
176
218
 
177
219
  /** Minimal built-in keyed reactive source (default registry). */
@@ -219,6 +261,7 @@ export function makeReconciler<K extends PropertyKey = PropertyKey, V = unknown>
219
261
  */
220
262
  export function projectStore<V = unknown>(
221
263
  store: Record<PropertyKey, V>,
264
+ opts?: ProjectOptions,
222
265
  ): Projection<PropertyKey, V>;
223
266
 
224
267
  /** The subset of a @zakkster/lite-room handle that {@link projectRoom} consumes. */
@@ -231,8 +274,8 @@ export interface RoomLike {
231
274
  };
232
275
  }
233
276
 
234
- /** Options for {@link projectRoom}. */
235
- export interface ProjectRoomOptions {
277
+ /** Options for {@link projectRoom}. Extends the injectable clock for overlay TTL. */
278
+ export interface ProjectRoomOptions extends Partial<ProjectionClock> {
236
279
  /** Reconciliation policy; defaults to {@link confirmOnEcho}. */
237
280
  policy?: ReconcilePolicy<string, unknown>;
238
281
  }
@@ -257,8 +300,9 @@ export interface QueryClientLike {
257
300
  setQueryData(key: unknown, valueOrUpdater: unknown | ((prev: unknown) => unknown)): unknown;
258
301
  }
259
302
 
260
- /** Options for {@link projectQuery}. */
261
- 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> {
262
306
  /**
263
307
  * The query's reactive data accessor (e.g. `query.data`). When supplied,
264
308
  * projected reads track the cache and auto-reconcile is armed. Omit to degrade
@@ -287,3 +331,53 @@ export function projectQuery<V extends object = Record<PropertyKey, unknown>>(
287
331
  key: unknown,
288
332
  opts?: ProjectQueryOptions<V>,
289
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.2.0 -- zero-GC projections for @zakkster/lite-signal.
2
+ * @zakkster/lite-project v1.4.1 -- 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,
@@ -25,6 +25,23 @@
25
25
  * (key, from, to) stream for a save/sync trigger. It is READ-ONLY and UNTRACKED:
26
26
  * it never touches the source or the overlays and subscribes the caller to nothing.
27
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
+ *
28
45
  * -- OWNERSHIP (why createRoot) --
29
46
  * Per-key nodes are created LAZILY, on the first get/set of a key -- which happens
30
47
  * inside whatever consumer effect first reads that key. Without detachment the
@@ -45,6 +62,15 @@
45
62
  * are the public-handle cost, the same split @zakkster/lite-signal itself draws
46
63
  * between pooled internals and escaping handles. Warm the keys you will churn.
47
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
+ *
48
74
  * Registry-parametric: createProjector(reg) binds to any registry (the default one,
49
75
  * or a createRegistry({...}) for isolated tests). Default-bound `project` /
50
76
  * `keyedStore` are exported for the common case.
@@ -63,7 +89,7 @@ import {
63
89
  hasObservers as _hasObservers,
64
90
  } from "@zakkster/lite-signal";
65
91
 
66
- export const VERSION = "1.2.0";
92
+ export const VERSION = "1.4.1";
67
93
 
68
94
  // Module-level sentinel for "this key has no overlay". A unique symbol, never a
69
95
  // per-operation allocation. Stored directly in the overlay signal's value slot, so
@@ -123,9 +149,12 @@ export function createProjector(reg) {
123
149
  * and can commit / revert it.
124
150
  *
125
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.
126
155
  * @returns {{
127
156
  * get:(key:PropertyKey)=>unknown, // reactive: overlay value if set, else source
128
- * 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)
129
158
  * clear:(key:PropertyKey)=>void, // drop one key's overlay (revert that key)
130
159
  * isOverlaid:(key:PropertyKey)=>boolean, // untracked diagnostic
131
160
  * overlaidCount:()=>number, // untracked diagnostic
@@ -137,34 +166,157 @@ export function createProjector(reg) {
137
166
  * toPatch:(skip?:Function)=>Array<{key:PropertyKey, from:unknown, to:unknown}>, // materialized patch (cold convenience)
138
167
  * reconcileAll:(policy?:(authoritative:unknown, overlayValue:unknown, key:PropertyKey)=>boolean)=>void, // drop confirmed overlays
139
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)
140
171
  * revert:()=>void, // drop all overlays
141
172
  * dispose:()=>void, // recycle every projection-owned node to the pool
142
173
  * }}
143
174
  */
144
- function project(source) {
145
- // key -> { ov: overlay signal (ABSENT | value), read: projected computed }.
146
- // 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.
147
202
  const slots = new Map();
148
203
 
204
+ // Slot creation lives in its OWN function, never inline in slotFor: the
205
+ // creation closure captures `key`, and a capture inside slotFor's scope
206
+ // would make V8 allocate a context object on EVERY slotFor call -- hit or
207
+ // miss -- taxing get/peek/set ~40 B/op. Here the context is allocated only
208
+ // on the cold miss. (Bytes in a hot body: a closure in a cold branch still
209
+ // costs the hot branch its context.)
210
+ const _createSlot = (key) => {
211
+ // Detach owner+observer for creation: these nodes outlive the consumer
212
+ // that first reads `key`, and the projection -- not that consumer --
213
+ // owns their disposal. (See header: OWNERSHIP.)
214
+ return createRoot(() => {
215
+ const ov = signal(ABSENT);
216
+ const read = computed(() => {
217
+ const o = ov(); // track the overlay
218
+ const base = source.get(key); // track the source cell too
219
+ return o === ABSENT ? base : o;
220
+ });
221
+ return { ov, read, exp: 0 };
222
+ });
223
+ };
149
224
  const slotFor = (key) => {
150
225
  let s = slots.get(key);
151
- if (s === undefined) {
152
- // Detach owner+observer for creation: these nodes outlive the consumer
153
- // that first reads `key`, and the projection -- not that consumer --
154
- // owns their disposal. (See header: OWNERSHIP.)
155
- s = createRoot(() => {
156
- const ov = signal(ABSENT);
157
- const read = computed(() => {
158
- const o = ov(); // track the overlay
159
- const base = source.get(key); // track the source cell too
160
- return o === ABSENT ? base : o;
161
- });
162
- return { ov, read };
163
- });
164
- slots.set(key, s);
165
- }
226
+ if (s === undefined) { s = _createSlot(key); slots.set(key, s); }
227
+ return s;
228
+ };
229
+
230
+ // Reactive dirty state. ONE fixed signal per projection (created detached so
231
+ // dispose() owns its teardown, like the per-key nodes). `dirty` is the
232
+ // source-of-truth count of staged overlays, mirrored into the signal on every
233
+ // presence transition (absent<->value). Bumping it allocates nothing -- a
234
+ // number set, marking subscribers without allocation -- so the zero-GC churn
235
+ // property holds even with a Save button subscribed to isDirty().
236
+ const dirtySig = createRoot(() => signal(0));
237
+ let dirty = 0;
238
+
239
+ // -- Overlay TTL (per-projection). ONE platform timer, re-armed; each slot
240
+ // stores its own deadline in `exp`, and arm/fire do an O(slots) cold scan.
241
+ // A min-heap would allocate per push -- rejected. `armedAt` is the deadline
242
+ // the live handle is set for; clear-before-set in _armAt bounds outstanding
243
+ // handles to exactly 1 (T7). ttlCount is the count of slots with exp !== 0.
244
+ let timerHandle = null; let armedAt = 0; let ttlCount = 0;
245
+ const _cancelTimer = () => {
246
+ if (timerHandle !== null) { _clearTimer(timerHandle); timerHandle = null; armedAt = 0; }
247
+ };
248
+ const _armAt = (d) => {
249
+ if (timerHandle !== null) _clearTimer(timerHandle); // clear-before-set: <= 1 handle
250
+ armedAt = d;
251
+ const ms = d - _now();
252
+ timerHandle = _setTimer(_fire, ms > 0 ? ms : 0); // clamp delta >= 0
253
+ };
254
+ // THE single cancellation helper: every transition to ABSENT calls it, so a
255
+ // stale deadline can never outlive its overlay. A hoisted function
256
+ // declaration (not an arrow) so it is defined before the closures above
257
+ // that reference it.
258
+ function _dropExp(s) {
259
+ if (s.exp !== 0) { s.exp = 0; if (--ttlCount === 0) _cancelTimer(); }
260
+ }
261
+ // The existing plain-set body, hoisted: stage `v` as an overlay and keep the
262
+ // dirty bookkeeping. Returns the slot so callers can set/clear its expiry.
263
+ const _stage = (key, v) => {
264
+ const s = slotFor(key);
265
+ const wasAbsent = s.ov.peek() === ABSENT;
266
+ s.ov.set(v);
267
+ if (wasAbsent) { dirty++; dirtySig.set(dirty); }
166
268
  return s;
167
269
  };
270
+ // The fire handler: ONE hoisted per-projection closure (the _readSrc
271
+ // precedent). Reverts exactly the keys due (exp <= now), in one batch, with
272
+ // per-drop dirty bookkeeping (clear()'s fail-closed pattern: a throwing
273
+ // _now/registry leaves dirty == overlaid count). The source is NEVER touched.
274
+ // _rearm runs AFTER the batch, so a subscriber's set(k,v,{ttl}) during the
275
+ // flush is honoured; _armAt clears first, so still <= 1 handle (T1).
276
+ const _fire = () => {
277
+ timerHandle = null; armedAt = 0;
278
+ if (ttlCount === 0) return; // spurious after a full cancel / post-dispose
279
+ const t = _now();
280
+ batch(() => {
281
+ for (const s of slots.values()) {
282
+ const e = s.exp;
283
+ if (e === 0 || e > t) continue; // not due -> untouched
284
+ _dropExp(s);
285
+ if (s.ov.peek() !== ABSENT) { s.ov.set(ABSENT); dirty--; dirtySig.set(dirty); }
286
+ }
287
+ });
288
+ _rearm();
289
+ };
290
+ const _rearm = () => {
291
+ if (ttlCount === 0) { _cancelTimer(); return; }
292
+ let min = Infinity;
293
+ for (const s of slots.values()) { const e = s.exp; if (e !== 0 && e < min) min = e; }
294
+ if (min === Infinity) { ttlCount = 0; _cancelTimer(); return; } // defensive, fail closed
295
+ _armAt(min);
296
+ };
297
+ // Cold ttl branch of set(). A {}/{policy} bag (ttl === undefined) behaves as
298
+ // plain set AND still cancels a prior expiry. A bad ttl throws BEFORE staging,
299
+ // so the bag and source stay untouched. A re-set with an EARLIER deadline
300
+ // re-arms; a LATER one does not (the armed earlier timer fires spuriously and
301
+ // re-arms -- that is the contract).
302
+ const _setWithOpts = (key, v, o) => {
303
+ // A null bag is tolerated (old 2-arg-era `set(k, v, null)` behaved as
304
+ // a plain set), mirroring project(source, null). Non-null non-object
305
+ // bags degrade to a plain set via undefined member reads.
306
+ const ttl = o == null ? undefined : o.ttl;
307
+ if (ttl === undefined) { _dropExp(_stage(key, v)); return; }
308
+ if (!Number.isFinite(ttl) || ttl <= 0) {
309
+ throw new TypeError("set: ttl must be a finite number > 0");
310
+ }
311
+ const d = _now() + ttl;
312
+ if (!Number.isFinite(d)) {
313
+ throw new TypeError("project: clock now() must return a finite number");
314
+ }
315
+ const s = _stage(key, v);
316
+ if (s.exp === 0) ttlCount++;
317
+ s.exp = d;
318
+ if (timerHandle === null || d < armedAt) _armAt(d);
319
+ };
168
320
 
169
321
  // Patch emission: iterate exactly the overlaid keys, handing scalars
170
322
  // (key, from, to) to `fn` -- `from` is the UNTRACKED current source value,
@@ -188,15 +340,6 @@ export function createProjector(reg) {
188
340
  }
189
341
  };
190
342
 
191
- // Reactive dirty state. ONE fixed signal per projection (created detached so
192
- // dispose() owns its teardown, like the per-key nodes). `dirty` is the
193
- // source-of-truth count of staged overlays, mirrored into the signal on every
194
- // presence transition (absent<->value). Bumping it allocates nothing -- a
195
- // number set, marking subscribers without allocation -- so the zero-GC churn
196
- // property holds even with a Save button subscribed to isDirty().
197
- const dirtySig = createRoot(() => signal(0));
198
- let dirty = 0;
199
-
200
343
  // Hoisted scratch for forEachPatch's untracked source read: ONE closure
201
344
  // per projection, never per key/call, so the per-key emit body allocates
202
345
  // nothing. `untrack` needs a function; _readSrc is it.
@@ -205,17 +348,22 @@ export function createProjector(reg) {
205
348
 
206
349
  return {
207
350
  get: (key) => slotFor(key).read(),
208
- set: (key, v) => {
209
- const s = slotFor(key);
210
- const wasAbsent = s.ov.peek() === ABSENT;
211
- s.ov.set(v);
212
- if (wasAbsent) { dirty++; dirtySig.set(dirty); }
351
+ // Hot path (warm no-ttl): one arg load + !== undefined compare (not
352
+ // taken), one _stage (identical work to the old body), one _dropExp
353
+ // (one field load + !== 0 compare, not taken). Zero allocation, zero
354
+ // branches taken. All ttl logic lives in the cold _setWithOpts. The
355
+ // opts branch also STILL cancels a prior expiry (set(k,v,{}) -> plain).
356
+ set: (key, v, opts) => {
357
+ if (opts !== undefined) { _setWithOpts(key, v, opts); return; }
358
+ const s = _stage(key, v);
359
+ _dropExp(s);
213
360
  },
214
361
  clear: (key) => {
215
362
  const s = slots.get(key);
216
363
  if (s !== undefined && s.ov.peek() !== ABSENT) {
217
364
  s.ov.set(ABSENT);
218
365
  dirty--; dirtySig.set(dirty);
366
+ _dropExp(s);
219
367
  }
220
368
  },
221
369
  isOverlaid: (key) => {
@@ -229,11 +377,17 @@ export function createProjector(reg) {
229
377
  isDirty: () => dirtySig() > 0,
230
378
  // Untracked effective read (overlay if set, else source) -- for
231
379
  // reconciliation policies and imperative inspection, without subscribing.
380
+ // Source fallthrough rides the hoisted _pk/_readSrc scratch (the
381
+ // forEachPatch precedent): an inline untrack closure would capture
382
+ // `key` and cost EVERY peek a context allocation -- including the warm
383
+ // overlaid path that never takes the fallthrough.
232
384
  peek: (key) => {
233
385
  const s = slots.get(key);
234
- if (s === undefined) return untrack(() => source.get(key));
386
+ if (s === undefined) { _pk = key; return untrack(_readSrc); }
235
387
  const o = s.ov.peek();
236
- return o === ABSENT ? untrack(() => source.get(key)) : o;
388
+ if (o !== ABSENT) return o;
389
+ _pk = key;
390
+ return untrack(_readSrc);
237
391
  },
238
392
  // Iterate currently-overlaid keys (untracked). Cold path.
239
393
  forEachOverlay: (fn) => {
@@ -265,8 +419,11 @@ export function createProjector(reg) {
265
419
  for (const [key, s] of slots) {
266
420
  const o = s.ov.peek();
267
421
  if (o !== ABSENT) {
268
- const authoritative = untrack(() => source.get(key));
269
- if (pol(authoritative, o, key)) { s.ov.set(ABSENT); dropped++; }
422
+ // _pk/_readSrc scratch (the forEachPatch precedent): an
423
+ // inline untrack closure would allocate per overlaid key.
424
+ _pk = key;
425
+ const authoritative = untrack(_readSrc);
426
+ if (pol(authoritative, o, key)) { s.ov.set(ABSENT); _dropExp(s); dropped++; }
270
427
  }
271
428
  }
272
429
  if (dropped) { dirty -= dropped; dirtySig.set(dirty); }
@@ -288,7 +445,7 @@ export function createProjector(reg) {
288
445
  const o = s.ov.peek();
289
446
  if (o !== ABSENT) {
290
447
  source.set(key, o); s.ov.set(ABSENT);
291
- dirty--; dirtySig.set(dirty);
448
+ dirty--; dirtySig.set(dirty); _dropExp(s);
292
449
  }
293
450
  }
294
451
  return;
@@ -296,7 +453,7 @@ export function createProjector(reg) {
296
453
  let changed = false;
297
454
  for (const [k, s] of slots) {
298
455
  const o = s.ov.peek();
299
- if (o !== ABSENT) { source.set(k, o); s.ov.set(ABSENT); changed = true; }
456
+ if (o !== ABSENT) { source.set(k, o); s.ov.set(ABSENT); _dropExp(s); changed = true; }
300
457
  }
301
458
  if (changed) { dirty = 0; dirtySig.set(0); }
302
459
  });
@@ -305,11 +462,39 @@ export function createProjector(reg) {
305
462
  batch(() => {
306
463
  let changed = false;
307
464
  for (const s of slots.values()) {
308
- if (s.ov.peek() !== ABSENT) { s.ov.set(ABSENT); changed = true; }
465
+ if (s.ov.peek() !== ABSENT) { s.ov.set(ABSENT); _dropExp(s); changed = true; }
309
466
  }
310
467
  if (changed) { dirty = 0; dirtySig.set(0); }
311
468
  });
312
469
  },
470
+ // Predicate-scoped partial ops (S7). pred(key, stagedValue) -- the
471
+ // forEachOverlay callback order, NOT ReconcilePolicy's. Visit slots in
472
+ // insertion order, ONE batch, per-drop dirty bookkeeping so a throwing
473
+ // pred leaves dirtyCount() === overlaidCount() (batch's finally still
474
+ // flushes): already-committed keys stay committed, no rollback.
475
+ commitWhere: (pred) => {
476
+ if (typeof pred !== "function") throw new TypeError("commitWhere: pred must be a function");
477
+ batch(() => {
478
+ for (const [k, s] of slots) {
479
+ const o = s.ov.peek();
480
+ if (o === ABSENT) continue;
481
+ if (!pred(k, o)) continue;
482
+ source.set(k, o);
483
+ s.ov.set(ABSENT); dirty--; dirtySig.set(dirty); _dropExp(s);
484
+ }
485
+ });
486
+ },
487
+ clearWhere: (pred) => {
488
+ if (typeof pred !== "function") throw new TypeError("clearWhere: pred must be a function");
489
+ batch(() => {
490
+ for (const [k, s] of slots) {
491
+ const o = s.ov.peek();
492
+ if (o === ABSENT) continue;
493
+ if (!pred(k, o)) continue;
494
+ s.ov.set(ABSENT); dirty--; dirtySig.set(dirty); _dropExp(s);
495
+ }
496
+ });
497
+ },
313
498
  /**
314
499
  * Reclaim slots for keys that are no longer in use.
315
500
  *
@@ -354,6 +539,9 @@ export function createProjector(reg) {
354
539
  dispose(dirtySig);
355
540
  slots.clear();
356
541
  dirty = 0;
542
+ // Cancel any pending TTL handle: a live setTimeout would hold the
543
+ // event loop open for up to `ttl` ms and retain this closure (T6).
544
+ _cancelTimer(); ttlCount = 0;
357
545
  },
358
546
  };
359
547
  }
@@ -454,8 +642,8 @@ export function makeReconciler(view, policy) {
454
642
  * @param {object} store A lite-store proxy from `store(...)`.
455
643
  * @returns {object} A projection handle (get/set/clear/commit/revert/isOverlaid/peek/...).
456
644
  */
457
- export function projectStore(store) {
458
- return project(fromProxy(store));
645
+ export function projectStore(store, opts) {
646
+ return project(fromProxy(store), opts);
459
647
  }
460
648
 
461
649
  /**
@@ -492,7 +680,9 @@ export function projectRoom(room, opts) {
492
680
  get: (key) => { room.storage.entries(); return room.storage.get(key); },
493
681
  set: (key, value) => room.storage.set(key, value),
494
682
  };
495
- const view = project(source);
683
+ // Forward the flat opts bag: project reads only the clock keys (now/setTimer/
684
+ // clearTimer); policy is consumed above and ignored there.
685
+ const view = project(source, opts);
496
686
  // Drop confirmed drafts whenever authoritative state changes. The effect
497
687
  // tracks `entries` (not overlays/projected computeds), so view.clear() inside
498
688
  // reconcileAll never re-triggers it -> no loop. reconcileAll reads the source
@@ -615,7 +805,9 @@ export function projectQuery(qc, key, opts) {
615
805
  return merge(prev, one);
616
806
  }),
617
807
  };
618
- const view = project(source);
808
+ // Forward the flat opts bag: project reads only the clock keys (now/setTimer/
809
+ // clearTimer); data/policy/merge are consumed above and ignored there.
810
+ const view = project(source, opts);
619
811
 
620
812
  // Auto-reconcile: only meaningful when the record read is reactive. Tracks
621
813
  // `data()` (never the overlays), so clearing drafts inside reconcileAll does
@@ -647,6 +839,120 @@ export function projectQuery(qc, key, opts) {
647
839
  qc.setQueryData(key, (prev) => merge(prev, overlays));
648
840
  view.revert();
649
841
  },
842
+ // Partial commit as ONE cache write (mirrors commit() above). NOT
843
+ // view.commitWhere: that would issue one setQueryData per matching field,
844
+ // breaking the single-write law. And NOT view.revert() after the write:
845
+ // revert drops the NON-matching drafts too (silent data loss) and skips
846
+ // per-key exp cancellation -- so clear ONLY the committed fields, in a
847
+ // batch, which folds the N dirtySig writes into one propagation.
848
+ commitWhere: (pred) => {
849
+ if (typeof pred !== "function") throw new TypeError("commitWhere: pred must be a function");
850
+ const overlays = Object.create(null);
851
+ let any = false;
852
+ view.forEachOverlay((f, v) => { if (pred(f, v)) { _put(overlays, f, v); any = true; } });
853
+ if (!any) return;
854
+ qc.setQueryData(key, (prev) => merge(prev, overlays));
855
+ _batch(() => {
856
+ const ks = _ownEnumerableKeys(overlays);
857
+ for (let i = 0; i < ks.length; i++) view.clear(ks[i]);
858
+ });
859
+ },
650
860
  dispose: () => { if (stopReconcile) stopReconcile(); view.dispose(); },
651
861
  };
652
862
  }
863
+
864
+ /**
865
+ * Project a @zakkster/lite-crdt LWW-Map (`doc.map(name)`) as a per-key DRAFT
866
+ * overlay for optimistic / tentative UI. Unlike projectRoom (which wraps
867
+ * lite-room's COARSE storage -- one entries signal, any change re-runs every
868
+ * projected key), an LWW-Map has FINE-GRAINED reactive `get(key)`, so this
869
+ * adapter is truly granular: overlaying or committing one cell never re-runs a
870
+ * consumer of another. The projection never joins the merge -- it only decides
871
+ * what the local view tentatively overrides:
872
+ *
873
+ * - set(key, value) stage a draft -- local only, NOT synced, the CRDT untouched
874
+ * - commit(key?) promote drafts via `map.set` (emits ops + syncs). One op
875
+ * per committed key (N ops / N frames); LWW ops are
876
+ * commutative + idempotent so N frames == 1 semantically.
877
+ * Pass opts.transact (e.g. doc.transact) to coalesce a burst
878
+ * into ONE ops frame + one change (commit and commitWhere both
879
+ * wrap through it)
880
+ * - revert() discard drafts
881
+ * - auto-reconcile whenever an OVERLAID key's authoritative cell changes (local
882
+ * echo or a remote applyOp), drafts the policy considers
883
+ * confirmed are dropped; a CONFLICTING authoritative value --
884
+ * including a concurrent authoritative DELETE (reads as
885
+ * undefined) -- leaves the draft masked
886
+ *
887
+ * The map handle is consumed STRUCTURALLY: any object exposing a fine-grained
888
+ * reactive `get(key)` and a `set(key, value)` works, so this adapter adds no hard
889
+ * dependency on lite-crdt and never touches the doc, `map.store`, or the coarse
890
+ * reads (keys/values/entries/size). The reconcile effect tracks exactly the
891
+ * source cell of each currently-overlaid key (via `view.dirtyCount()` for the
892
+ * overlay-set dependency + a bare `map.get(k)` per overlaid key), so it re-runs
893
+ * on overlay-set transitions and on authoritative changes to overlaid keys only.
894
+ *
895
+ * -- READ-ONLY WRAPPER (object values), the recorded contract --
896
+ * lite-crdt's `get(key)` returns a deep READ-ONLY WRAPPER for object/array values
897
+ * (a different reference than the one passed to set), WeakMap-cached so it is
898
+ * stable across reads. Two consequences: (1) confirmOnEcho (Object.is) can NEVER
899
+ * auto-confirm an OBJECT-valued draft over projectCRDT, even on a genuine local
900
+ * echo -- the wrapper breaks reference equality (scalars confirm normally); use a
901
+ * {ttl} draft (the shipped heal) or a caller-supplied STRUCTURAL policy, whose
902
+ * reads pass through the wrapper transparently. (2) A policy must NEVER attempt to
903
+ * MUTATE the authoritative argument it is handed for an object value -- it is that
904
+ * read-only wrapper and lite-crdt throws `readonly`.
905
+ *
906
+ * -- STRING-COERCION KEY ALIASING (caller hazard) --
907
+ * lite-crdt coerces every map key to a string, but projection slots are keyed by
908
+ * PropertyKey. So drafts on `5` and `"5"` (or a Symbol coerced elsewhere) are TWO
909
+ * projection slots that commit into ONE CRDT cell -- last write wins, and
910
+ * dirtyCount() never reveals the collision. Stage under ONE key type. A
911
+ * `"__proto__"` map key is not usable in lite-crdt: `map.set` throws
912
+ * CRDTError("misconfigured"). The adapter does NOT wrap that policy -- a commit of
913
+ * a `"__proto__"` draft propagates the CRDT's own error with the draft still
914
+ * staged and dirtyCount() consistent (fail closed).
915
+ *
916
+ * -- DISPOSE ORDER --
917
+ * `doc.dispose()` makes subsequent mutations SILENT no-ops, so a commit AFTER the
918
+ * doc is disposed writes nothing yet still clears the drafts (an inherited
919
+ * dead-source data-loss class). Dispose the PROJECTION before the doc.
920
+ *
921
+ * @param {{get:(key:string)=>unknown, set:(key:string, value:unknown)=>void, delete?:(key:string)=>void}} map
922
+ * A lite-crdt LWW-Map (`doc.map(name)`) or any structural equivalent.
923
+ * @param {{policy?: (authoritative:unknown, draft:unknown, key:string)=>boolean,
924
+ * transact?: <T>(fn:()=>T)=>T,
925
+ * now?:Function, setTimer?:Function, clearTimer?:Function}} [opts]
926
+ * Reconciliation policy (default confirmOnEcho), an optional transact hook
927
+ * that wraps commit/commitWhere, and the injectable overlay-TTL clock.
928
+ * @returns {object} A projection handle whose dispose() also stops the reconcile effect.
929
+ */
930
+ export function projectCRDT(map, opts) {
931
+ if (map == null || typeof map.get !== "function" || typeof map.set !== "function") {
932
+ throw new TypeError("projectCRDT: map must expose get(key) and set(key, value)");
933
+ }
934
+ const policy = (opts && opts.policy) || confirmOnEcho;
935
+ // Fail closed: a supplied-but-not-a-function transact throws BEFORE any node
936
+ // is created. The branch is resolved once here, never per commit call.
937
+ const tx = opts && opts.transact !== undefined ? opts.transact : null;
938
+ if (tx !== null && typeof tx !== "function") {
939
+ throw new TypeError("projectCRDT: transact must be a function");
940
+ }
941
+ const source = { get: (k) => map.get(k), set: (k, v) => map.set(k, v) };
942
+ // Forward the flat opts bag: project reads only the clock keys (now/setTimer/
943
+ // clearTimer); policy/transact are consumed above and ignored there.
944
+ const view = project(source, opts);
945
+
946
+ // ONE hoisted per-adapter closure: forEachOverlay's callback, never a
947
+ // per-run arrow. The second callback arg (the staged value) is ignored.
948
+ const _trackSrc = (k) => { map.get(k); };
949
+ const stopReconcile = _effect(() => {
950
+ view.dirtyCount(); // TRACKED: re-establishes deps on every overlay-set change
951
+ view.forEachOverlay(_trackSrc); // TRACKED map.get per overlaid key (overlay side peeks)
952
+ view.reconcileAll(policy); // untracked reads inside -> adds no deps
953
+ });
954
+
955
+ const commit = tx === null ? view.commit : (key) => { tx(() => view.commit(key)); };
956
+ const commitWhere = tx === null ? view.commitWhere : (pred) => { tx(() => view.commitWhere(pred)); };
957
+ return { ...view, commit, commitWhere, dispose: () => { stopReconcile(); view.dispose(); } };
958
+ }
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` |
@@ -177,6 +179,27 @@ Projects a single query entry's data **object**, exposing its **fields** as the
177
179
 
178
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.
179
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
+
180
203
  ## Patch emission <sub>1.2</sub>
181
204
 
182
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.
@@ -201,6 +224,37 @@ An overlaid key is emitted whether or not `Object.is(from, to)`: the visit set s
201
224
 
202
225
  Present on the `projectStore` / `projectRoom` / `projectQuery` handles too (for `projectQuery`, `from` is the cached record's field value).
203
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
+
204
258
  ## Conventions
205
259
 
206
260
  ESM only. ASCII source. `node:test`. MIT.
package/llms.txt CHANGED
@@ -24,16 +24,38 @@ 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
29
31
  - VERSION -> string // shipped package version, synced to package.json
30
32
 
31
33
  ## Projection handle
32
34
 
33
- 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() |
34
36
  dirtyCount() [TRACKED reactive] | isDirty() [TRACKED reactive] |
35
37
  isOverlaid(key) | overlaidCount() | peek(key) [untracked effective read] |
36
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.] |
37
59
  forEachPatch(fn, skip?) [1.2] | toPatch(skip?) -> [{key,from,to}] [1.2]
38
60
  [patch emission: iterate exactly the overlaid keys as fn(key, from, to) -- from =
39
61
  UNTRACKED current source value, to = staged overlay. Read-only + untracked: safe
@@ -66,10 +88,10 @@ prune() -> number [1.1] [release slots that are BOTH un-overlaid AND unobserved;
66
88
 
67
89
  ## Library adapters
68
90
 
69
- - 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;
70
92
  // commit() writes through store[key]=v.
71
93
  // Top-level keys; pass a nested proxy to go deeper.
72
- - 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).
73
95
  // Presentation-only optimistic drafts: set = local
74
96
  // draft (not synced), commit() = room.storage.set
75
97
  // (writes + syncs), auto-reconcile drops confirmed
@@ -95,12 +117,41 @@ prune() -> number [1.1] [release slots that are BOTH un-overlaid AND unobserved;
95
117
  // retargeting the prototype, inherited props on prev are
96
118
  // not absorbed, and symbol-keyed drafts survive commit.
97
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).
98
145
 
99
146
  ## Zero-GC
100
147
 
101
148
  Steady state: re-overlaying a warmed key reuses pooled nodes (200k toggles ->
102
149
  poolGrowths/totalAllocations flat). First touch of a NEW key allocates its slot,
103
- a Map entry, and two pooled nodes. Warm the keys you churn.
150
+ a Map entry, and two pooled nodes. Warm the keys you churn. Gated transient-clean
151
+ since 1.4.1: warm get/peek/set/toggle windows measured by V8 new-space delta
152
+ (<= 16384 B total per 50k-op window; measured ~0.13 B/op triangle noise). 1.4.0
153
+ allocated ~40 B/op on get/peek/set via a hot-path closure context -- fixed and
154
+ now impossible to reintroduce silently.
104
155
 
105
156
  ## Gotchas
106
157
 
@@ -108,4 +159,6 @@ a Map entry, and two pooled nodes. Warm the keys you churn.
108
159
  consumer that first reads the key; without detachment they would be adopted by
109
160
  that consumer and cascade-disposed on its next re-run. createRoot detaches them.
110
161
  - A projection only refines what the source exposes: over lite-room's coarse
111
- storage it is coarse (any storage change re-evaluates every projected key).
162
+ storage it is coarse (any storage change re-evaluates every projected key). Over
163
+ a lite-crdt LWW-Map (projectCRDT) it is FINE-GRAINED -- per-key reactive get(key)
164
+ -- 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.2.0",
3
+ "version": "1.4.1",
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",