@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 +144 -0
- package/Project.d.ts +101 -7
- package/Project.js +320 -29
- package/README.md +57 -3
- package/llms.txt +54 -5
- package/package.json +4 -2
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.
|
|
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
|
-
/**
|
|
48
|
-
|
|
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
|
+
* @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.
|
|
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,
|
|
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
|
-
//
|
|
146
|
-
//
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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.
|
|
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",
|