@zakkster/lite-project 1.2.0 → 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,150 @@
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
+
6
150
  ## [1.2.0] - 2026-09-05
7
151
 
8
152
  ### 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.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,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.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,
@@ -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.0";
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,13 +166,39 @@ 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
 
149
204
  const slotFor = (key) => {
@@ -159,13 +214,104 @@ export function createProjector(reg) {
159
214
  const base = source.get(key); // track the source cell too
160
215
  return o === ABSENT ? base : o;
161
216
  });
162
- return { ov, read };
217
+ return { ov, read, exp: 0 };
163
218
  });
164
219
  slots.set(key, s);
165
220
  }
166
221
  return s;
167
222
  };
168
223
 
224
+ // Reactive dirty state. ONE fixed signal per projection (created detached so
225
+ // dispose() owns its teardown, like the per-key nodes). `dirty` is the
226
+ // source-of-truth count of staged overlays, mirrored into the signal on every
227
+ // presence transition (absent<->value). Bumping it allocates nothing -- a
228
+ // number set, marking subscribers without allocation -- so the zero-GC churn
229
+ // property holds even with a Save button subscribed to isDirty().
230
+ const dirtySig = createRoot(() => signal(0));
231
+ let dirty = 0;
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
+
169
315
  // Patch emission: iterate exactly the overlaid keys, handing scalars
170
316
  // (key, from, to) to `fn` -- `from` is the UNTRACKED current source value,
171
317
  // `to` the staged overlay. Read-only: overlays via .peek(), source under
@@ -188,15 +334,6 @@ export function createProjector(reg) {
188
334
  }
189
335
  };
190
336
 
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
337
  // Hoisted scratch for forEachPatch's untracked source read: ONE closure
201
338
  // per projection, never per key/call, so the per-key emit body allocates
202
339
  // nothing. `untrack` needs a function; _readSrc is it.
@@ -205,17 +342,22 @@ export function createProjector(reg) {
205
342
 
206
343
  return {
207
344
  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); }
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);
213
354
  },
214
355
  clear: (key) => {
215
356
  const s = slots.get(key);
216
357
  if (s !== undefined && s.ov.peek() !== ABSENT) {
217
358
  s.ov.set(ABSENT);
218
359
  dirty--; dirtySig.set(dirty);
360
+ _dropExp(s);
219
361
  }
220
362
  },
221
363
  isOverlaid: (key) => {
@@ -266,7 +408,7 @@ export function createProjector(reg) {
266
408
  const o = s.ov.peek();
267
409
  if (o !== ABSENT) {
268
410
  const authoritative = untrack(() => source.get(key));
269
- if (pol(authoritative, o, key)) { s.ov.set(ABSENT); dropped++; }
411
+ if (pol(authoritative, o, key)) { s.ov.set(ABSENT); _dropExp(s); dropped++; }
270
412
  }
271
413
  }
272
414
  if (dropped) { dirty -= dropped; dirtySig.set(dirty); }
@@ -288,7 +430,7 @@ export function createProjector(reg) {
288
430
  const o = s.ov.peek();
289
431
  if (o !== ABSENT) {
290
432
  source.set(key, o); s.ov.set(ABSENT);
291
- dirty--; dirtySig.set(dirty);
433
+ dirty--; dirtySig.set(dirty); _dropExp(s);
292
434
  }
293
435
  }
294
436
  return;
@@ -296,7 +438,7 @@ export function createProjector(reg) {
296
438
  let changed = false;
297
439
  for (const [k, s] of slots) {
298
440
  const o = s.ov.peek();
299
- 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; }
300
442
  }
301
443
  if (changed) { dirty = 0; dirtySig.set(0); }
302
444
  });
@@ -305,11 +447,39 @@ export function createProjector(reg) {
305
447
  batch(() => {
306
448
  let changed = false;
307
449
  for (const s of slots.values()) {
308
- 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; }
309
451
  }
310
452
  if (changed) { dirty = 0; dirtySig.set(0); }
311
453
  });
312
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
+ },
313
483
  /**
314
484
  * Reclaim slots for keys that are no longer in use.
315
485
  *
@@ -354,6 +524,9 @@ export function createProjector(reg) {
354
524
  dispose(dirtySig);
355
525
  slots.clear();
356
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;
357
530
  },
358
531
  };
359
532
  }
@@ -454,8 +627,8 @@ export function makeReconciler(view, policy) {
454
627
  * @param {object} store A lite-store proxy from `store(...)`.
455
628
  * @returns {object} A projection handle (get/set/clear/commit/revert/isOverlaid/peek/...).
456
629
  */
457
- export function projectStore(store) {
458
- return project(fromProxy(store));
630
+ export function projectStore(store, opts) {
631
+ return project(fromProxy(store), opts);
459
632
  }
460
633
 
461
634
  /**
@@ -492,7 +665,9 @@ export function projectRoom(room, opts) {
492
665
  get: (key) => { room.storage.entries(); return room.storage.get(key); },
493
666
  set: (key, value) => room.storage.set(key, value),
494
667
  };
495
- 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);
496
671
  // Drop confirmed drafts whenever authoritative state changes. The effect
497
672
  // tracks `entries` (not overlays/projected computeds), so view.clear() inside
498
673
  // reconcileAll never re-triggers it -> no loop. reconcileAll reads the source
@@ -615,7 +790,9 @@ export function projectQuery(qc, key, opts) {
615
790
  return merge(prev, one);
616
791
  }),
617
792
  };
618
- 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);
619
796
 
620
797
  // Auto-reconcile: only meaningful when the record read is reactive. Tracks
621
798
  // `data()` (never the overlays), so clearing drafts inside reconcileAll does
@@ -647,6 +824,120 @@ export function projectQuery(qc, key, opts) {
647
824
  qc.setQueryData(key, (prev) => merge(prev, overlays));
648
825
  view.revert();
649
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
+ },
650
845
  dispose: () => { if (stopReconcile) stopReconcile(); view.dispose(); },
651
846
  };
652
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` |
@@ -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,6 +117,31 @@ 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
 
@@ -108,4 +155,6 @@ a Map entry, and two pooled nodes. Warm the keys you churn.
108
155
  consumer that first reads the key; without detachment they would be adopted by
109
156
  that consumer and cascade-disposed on its next re-run. createRoot detaches them.
110
157
  - 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).
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.2.0",
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",