@lazily-hub/lazily-js 0.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.
@@ -0,0 +1,413 @@
1
+ // Keyed cell collections: CellMap + LIS keyed reconciliation (cell-model.md
2
+ // § Keyed cell collections). Pure logic — no reactive graph. Like the state
3
+ // chart, this is compute that every binding (including this state-projection
4
+ // consumer) MUST implement; the conformance/collections/ fixtures pin behavior.
5
+ //
6
+ // Invalidation classes mirror the reactive independence contract: a value write
7
+ // touches only value readers of that key; an insert/remove touches membership +
8
+ // order readers; a pure reorder (atomic move) touches only order readers and
9
+ // keeps the entry's stable handle (never remove + re-mint).
10
+
11
+ function deepEqual(a, b) {
12
+ if (a === b) {
13
+ return true;
14
+ }
15
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") {
16
+ return false;
17
+ }
18
+ const aKeys = Object.keys(a);
19
+ const bKeys = Object.keys(b);
20
+ return (
21
+ aKeys.length === bKeys.length &&
22
+ aKeys.every((k) => Object.is(aKeys[k], bKeys[k])) &&
23
+ aKeys.every((k) => deepEqual(a[k], b[k]))
24
+ );
25
+ }
26
+
27
+ function indexOfKey(order, key) {
28
+ const index = order.indexOf(key);
29
+ if (index === -1) {
30
+ throw new RangeError(`CellMap key not present: ${String(key)}`);
31
+ }
32
+ return index;
33
+ }
34
+
35
+ // Longest strictly-increasing subsequence, returned as indices into `seq`.
36
+ // Classic patience-sorting reconstruction (O(n log n)); ties break toward the
37
+ // earliest equal value so the stable set is the longest possible.
38
+ function longestIncreasingSubsequence(seq) {
39
+ const n = seq.length;
40
+ if (n === 0) {
41
+ return [];
42
+ }
43
+ const tails = []; // tails[i] = index (into seq) of the smallest tail of an IS of length i+1
44
+ const prev = new Array(n).fill(-1);
45
+ for (let i = 0; i < n; i++) {
46
+ const value = seq[i];
47
+ let lo = 0;
48
+ let hi = tails.length;
49
+ while (lo < hi) {
50
+ const mid = (lo + hi) >>> 1;
51
+ if (seq[tails[mid]] < value) {
52
+ lo = mid + 1;
53
+ } else {
54
+ hi = mid;
55
+ }
56
+ }
57
+ if (lo > 0) {
58
+ prev[i] = tails[lo - 1];
59
+ }
60
+ tails[lo] = i;
61
+ }
62
+ const lis = [];
63
+ for (let cursor = tails[tails.length - 1]; cursor !== -1; cursor = prev[cursor]) {
64
+ lis.push(cursor);
65
+ }
66
+ return lis.reverse();
67
+ }
68
+
69
+ export class CellMap {
70
+ constructor(initial = {}) {
71
+ const order = Array.isArray(initial.order) ? [...initial.order] : [];
72
+ const values = initial.values ?? {};
73
+ this.order = order;
74
+ this.values = new Map();
75
+ this.handles = new Map();
76
+ this.#nextHandle = 0;
77
+ for (const key of order) {
78
+ this.values.set(key, values[key]);
79
+ this.handles.set(key, this.#nextHandle++);
80
+ }
81
+ Object.freeze(this);
82
+ }
83
+
84
+ #nextHandle;
85
+
86
+ static from(initial) {
87
+ return new CellMap(initial);
88
+ }
89
+
90
+ keys() {
91
+ return [...this.order];
92
+ }
93
+
94
+ has(key) {
95
+ return this.handles.has(key);
96
+ }
97
+
98
+ get(key) {
99
+ return this.values.get(key);
100
+ }
101
+
102
+ handle(key) {
103
+ return this.handles.get(key);
104
+ }
105
+
106
+ snapshot() {
107
+ const values = {};
108
+ for (const [key, value] of this.values) {
109
+ values[key] = value;
110
+ }
111
+ return { order: [...this.order], values };
112
+ }
113
+
114
+ // Dispatch a fixture op; returns the invalidation report
115
+ // { value: key[], membership: bool, order: bool }.
116
+ apply(op) {
117
+ switch (op.type) {
118
+ case "set_value":
119
+ return this.setValue(op.key, op.value);
120
+ case "insert":
121
+ return this.insert(op.key, op.value, op.at);
122
+ case "remove":
123
+ return this.remove(op.key);
124
+ case "move_to":
125
+ return this.moveTo(op.key, op.index);
126
+ case "move_before":
127
+ return this.moveBefore(op.key, op.before);
128
+ case "move_after":
129
+ return this.moveAfter(op.key, op.after);
130
+ default:
131
+ throw new TypeError(`unknown CellMap op type: ${op.type}`);
132
+ }
133
+ }
134
+
135
+ setValue(key, value) {
136
+ if (!this.handles.has(key)) {
137
+ // No fixture exercises this; treat as a value-less insert to stay total.
138
+ return this.insert(key, value, "end");
139
+ }
140
+ const changed = !deepEqual(this.values.get(key), value);
141
+ if (changed) {
142
+ this.values.set(key, value);
143
+ }
144
+ return { value: changed ? [key] : [], membership: false, order: false };
145
+ }
146
+
147
+ insert(key, value, at = "end") {
148
+ if (this.handles.has(key)) {
149
+ throw new RangeError(`CellMap key already present: ${String(key)}`);
150
+ }
151
+ let index;
152
+ if (at === "end") {
153
+ index = this.order.length;
154
+ } else if (at === "start") {
155
+ index = 0;
156
+ } else if (typeof at === "number") {
157
+ index = Math.max(0, Math.min(at, this.order.length));
158
+ } else {
159
+ index = indexOfKey(this.order, at) + 1; // anchor key → insert after it
160
+ }
161
+ this.order.splice(index, 0, key);
162
+ this.values.set(key, value);
163
+ this.handles.set(key, this.#nextHandle++);
164
+ return { value: [], membership: true, order: true };
165
+ }
166
+
167
+ remove(key) {
168
+ const index = indexOfKey(this.order, key);
169
+ this.order.splice(index, 1);
170
+ this.values.delete(key);
171
+ this.handles.delete(key);
172
+ return { value: [], membership: true, order: true };
173
+ }
174
+
175
+ moveTo(key, index) {
176
+ const from = indexOfKey(this.order, key);
177
+ const to = Math.max(0, Math.min(index, this.order.length - 1));
178
+ if (from !== to) {
179
+ this.order.splice(from, 1);
180
+ this.order.splice(to, 0, key);
181
+ }
182
+ return { value: [], membership: false, order: true };
183
+ }
184
+
185
+ moveBefore(key, beforeKey) {
186
+ return this.#reorderBefore(key, indexOfKey(this.order, beforeKey));
187
+ }
188
+
189
+ moveAfter(key, afterKey) {
190
+ return this.#reorderBefore(key, indexOfKey(this.order, afterKey) + 1);
191
+ }
192
+
193
+ #reorderBefore(key, targetSlot) {
194
+ const from = indexOfKey(this.order, key);
195
+ // Remove first, then compute the slot in the shortened order so an anchor
196
+ // before the moved key does not shift past it.
197
+ this.order.splice(from, 1);
198
+ let slot = targetSlot;
199
+ if (from < targetSlot) {
200
+ slot = targetSlot - 1;
201
+ }
202
+ slot = Math.max(0, Math.min(slot, this.order.length));
203
+ this.order.splice(slot, 0, key);
204
+ // Handle is untouched — a move is never a remove + re-mint.
205
+ return { value: [], membership: false, order: true };
206
+ }
207
+ }
208
+
209
+ // Standalone keyed reconciliation (cell-model.md § Keyed reconciliation).
210
+ // Diffs two keyed sequences BY STABLE KEY, not position, emitting the minimal
211
+ // {remove, move, insert} op set. Keys already in relative order (the longest
212
+ // increasing subsequence over their prior indices) MUST NOT move, and stable
213
+ // entries with unchanged values are not invalidated by a sibling reorder.
214
+ export function reconcileCollections(prior, target) {
215
+ const priorOrder = Array.isArray(prior.order) ? [...prior.order] : [];
216
+ const priorValues = prior.values ?? {};
217
+ const targetOrder = Array.isArray(target.order) ? [...target.order] : [];
218
+ const targetValues = target.values ?? {};
219
+
220
+ const priorIndex = new Map(priorOrder.map((key, i) => [key, i]));
221
+ const targetSet = new Set(targetOrder);
222
+
223
+ const commonInTarget = targetOrder.filter((key) => priorIndex.has(key));
224
+ const priorIndices = commonInTarget.map((key) => priorIndex.get(key));
225
+ const lisPositions = new Set(longestIncreasingSubsequence(priorIndices));
226
+ const stableKeys = new Set(
227
+ commonInTarget.filter((_, i) => lisPositions.has(i)),
228
+ );
229
+
230
+ // Stable entries whose value is also unchanged keep their value cell intact.
231
+ const stableKeysNotInvalidated = commonInTarget.filter(
232
+ (key) =>
233
+ stableKeys.has(key) && deepEqual(priorValues[key], targetValues[key]),
234
+ );
235
+
236
+ const ops = [];
237
+ // 1. removals (prior-only keys), in prior order.
238
+ for (const key of priorOrder) {
239
+ if (!targetSet.has(key)) {
240
+ ops.push({ type: "remove", key });
241
+ }
242
+ }
243
+ // 2. moves + inserts, walking the target order; each is anchored after the
244
+ // previously placed key (stable entries are already placed).
245
+ let placedAfter = null;
246
+ for (const key of targetOrder) {
247
+ if (stableKeys.has(key)) {
248
+ placedAfter = key;
249
+ continue;
250
+ }
251
+ if (priorIndex.has(key)) {
252
+ ops.push({ type: "move", key, after: placedAfter });
253
+ } else {
254
+ ops.push({ type: "insert", key, value: targetValues[key], after: placedAfter });
255
+ }
256
+ placedAfter = key;
257
+ }
258
+
259
+ return {
260
+ ops,
261
+ result_order: [...targetOrder],
262
+ stable_keys_not_invalidated: stableKeysNotInvalidated,
263
+ };
264
+ }
265
+
266
+ // Ordered keyed tree (cell-model.md § Ordered keyed tree). A `CellTree` is a
267
+ // further **composition**: each node is `(stable id, value, ordered keyed child
268
+ // collection)` — the child collection is a `CellMap` whose values are child
269
+ // tree nodes. Per-node value reactivity, per-level membership/order
270
+ // reactivity, and the atomic-move guarantee are all inherited from the per-cell
271
+ // model: editing a node touches only that node's value; adding/removing/reordering
272
+ // siblings touches only that parent's child level; a child reorder keeps the
273
+ // entry's stable handle and bumps order once (never remove + re-mint).
274
+ //
275
+ // Like `CellMap`, this is pure logic with no reactive graph. Each mutating op
276
+ // returns an invalidation report scoped to the **affected path only** — a
277
+ // reader at any other path observes no change, which is what makes the
278
+ // per-level independence invariant observable.
279
+ function buildNode(spec) {
280
+ const rawChildren = (spec && spec.children) || {};
281
+ const order = Array.isArray(rawChildren.order) ? [...rawChildren.order] : [];
282
+ const childSpecs = rawChildren.values ?? {};
283
+ const childNodes = {};
284
+ for (const key of order) {
285
+ childNodes[key] = buildNode(childSpecs[key]);
286
+ }
287
+ return new TreeNode(spec && spec.id, spec && spec.value, new CellMap({ order, values: childNodes }));
288
+ }
289
+
290
+ export class TreeNode {
291
+ constructor(id, value, children) {
292
+ this.id = id;
293
+ this.value = value;
294
+ this.children = children;
295
+ }
296
+
297
+ snapshot() {
298
+ const childOrder = this.children.order;
299
+ const childValues = {};
300
+ for (const key of childOrder) {
301
+ childValues[key] = this.children.get(key).snapshot();
302
+ }
303
+ return { id: this.id, value: this.value, children: { order: [...childOrder], values: childValues } };
304
+ }
305
+ }
306
+
307
+ export class CellTree {
308
+ constructor(rootSpec) {
309
+ this.root = rootSpec instanceof TreeNode ? rootSpec : buildNode(rootSpec);
310
+ Object.freeze(this);
311
+ }
312
+
313
+ static from(rootSpec) {
314
+ return new CellTree(rootSpec);
315
+ }
316
+
317
+ // Resolve a path (array of child keys from the root) to a node, or undefined.
318
+ nodeAt(path) {
319
+ const keys = Array.isArray(path) ? path : [path];
320
+ let node = this.root;
321
+ for (const key of keys) {
322
+ const child = node.children.get(key);
323
+ if (child === undefined) {
324
+ return undefined;
325
+ }
326
+ node = child;
327
+ }
328
+ return node;
329
+ }
330
+
331
+ #nodeAtOrThrow(path) {
332
+ const node = this.nodeAt(path);
333
+ if (node === undefined) {
334
+ throw new RangeError(`CellTree path not present: ${JSON.stringify(path)}`);
335
+ }
336
+ return node;
337
+ }
338
+
339
+ #parentAndKey(path) {
340
+ const keys = Array.isArray(path) ? path : [path];
341
+ if (keys.length === 0) {
342
+ throw new RangeError("CellTree path must have at least one key");
343
+ }
344
+ const parentPath = keys.slice(0, -1);
345
+ const key = keys[keys.length - 1];
346
+ const parent = parentPath.length === 0 ? this.root : this.#nodeAtOrThrow(parentPath);
347
+ return { parent, key, parentPath };
348
+ }
349
+
350
+ getValue(path) {
351
+ return this.#nodeAtOrThrow(path).value;
352
+ }
353
+
354
+ setValue(path, value) {
355
+ const { parent, key } = this.#parentAndKey(path);
356
+ const node = parent.children.get(key);
357
+ if (node === undefined) {
358
+ throw new RangeError(`CellTree key not present: ${String(key)}`);
359
+ }
360
+ const changed = !deepEqual(node.value, value);
361
+ if (changed) {
362
+ node.value = value;
363
+ }
364
+ return { path: Array.isArray(path) ? path : [path], value: changed ? [key] : [], membership: false, order: false };
365
+ }
366
+
367
+ hasChild(path, key) {
368
+ return this.#nodeAtOrThrow(path).children.has(key);
369
+ }
370
+
371
+ childKeys(path) {
372
+ return this.#nodeAtOrThrow(path).children.keys();
373
+ }
374
+
375
+ childHandle(path, key) {
376
+ return this.#nodeAtOrThrow(path).children.handle(key);
377
+ }
378
+
379
+ insertChild(path, key, childSpec, at = "end") {
380
+ const parent = this.#nodeAtOrThrow(path);
381
+ const node = childSpec instanceof TreeNode ? childSpec : buildNode(childSpec);
382
+ parent.children.insert(key, node, at);
383
+ return { path: Array.isArray(path) ? path : [path], value: [], membership: true, order: true };
384
+ }
385
+
386
+ removeChild(path, key) {
387
+ const parent = this.#nodeAtOrThrow(path);
388
+ parent.children.remove(key);
389
+ return { path: Array.isArray(path) ? path : [path], value: [], membership: true, order: true };
390
+ }
391
+
392
+ moveChildTo(path, key, index) {
393
+ const parent = this.#nodeAtOrThrow(path);
394
+ parent.children.moveTo(key, index);
395
+ return { path: Array.isArray(path) ? path : [path], value: [], membership: false, order: true };
396
+ }
397
+
398
+ moveChildBefore(path, key, beforeKey) {
399
+ const parent = this.#nodeAtOrThrow(path);
400
+ parent.children.moveBefore(key, beforeKey);
401
+ return { path: Array.isArray(path) ? path : [path], value: [], membership: false, order: true };
402
+ }
403
+
404
+ moveChildAfter(path, key, afterKey) {
405
+ const parent = this.#nodeAtOrThrow(path);
406
+ parent.children.moveAfter(key, afterKey);
407
+ return { path: Array.isArray(path) ? path : [path], value: [], membership: false, order: true };
408
+ }
409
+
410
+ snapshot() {
411
+ return this.root.snapshot();
412
+ }
413
+ }