@earendil-works/chord 0.85.0 → 0.86.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.
@@ -19,8 +19,10 @@ replica = apply(replica, tracker.flush());
19
19
  ```
20
20
 
21
21
  The first `flush()` returns one operation containing the complete value. Each
22
- later flush returns the operations needed to transform the previously published
23
- value into the current value. It returns `[]` when the value has not changed.
22
+ later flush returns operations whose application transforms the previously
23
+ published value into the current value. It returns `[]` when no tracked mutation
24
+ is pending, but a mutation window that restores its starting value may still
25
+ produce a redundant batch.
24
26
 
25
27
  `applyImmutable()` copies only containers along changed paths and shares
26
28
  unchanged subtrees. It does not mutate, clone, or freeze either complete input.
@@ -151,7 +153,7 @@ tracker.state.messages.push(message);
151
153
  delete tracker.state.retry;
152
154
  ```
153
155
 
154
- Only the value at flush time is published:
156
+ Operations are coalesced within a flush window when doing so is cheap and safe:
155
157
 
156
158
  ```ts
157
159
  tracker.state.status = "starting";
@@ -159,8 +161,12 @@ tracker.state.status = "running";
159
161
  tracker.flush(); // one set to "running"
160
162
  ```
161
163
 
164
+ The operation sequence is not canonical. Equivalent changes may use different
165
+ verbs, and mutations that cancel can still produce a nonempty batch. Consumers
166
+ must depend on the resulting value, not the exact tuples or their minimality.
167
+
162
168
  Replacing an object or array is valid. Delta compares its properties and elements
163
- with the previously published value:
169
+ with the outgoing value at assignment time:
164
170
 
165
171
  ```ts
166
172
  tracker.state.settings = {
@@ -180,8 +186,8 @@ Appending text produces an `a` operation:
180
186
  tracker.state.output += "next line\n";
181
187
  ```
182
188
 
183
- Moving a bounded text window forward produces `t` followed by `a` when the old
184
- suffix matches the new prefix:
189
+ Moving a bounded text window forward produces `t` followed by `a` when the
190
+ previous suffix matches the new prefix:
185
191
 
186
192
  ```ts
187
193
  tracker.state.output = tracker.state.output.slice(200) + nextChunk;
@@ -199,29 +205,77 @@ tracker.state.messages.push(second);
199
205
  tracker.state.messages.splice(3, 1, replacement);
200
206
  ```
201
207
 
202
- All `push()` calls before one flush produce one tail `p`. Changes to older
203
- elements remain separate, regardless of whether they happen before or after the
204
- pushes. Changes to newly pushed elements are included in the pushed values.
208
+ Adjacent `push()` calls are normally coalesced into one tail `p`. Intervening
209
+ operations may keep them separate to preserve ordering. Changes to older
210
+ elements remain separate, and changes to newly pushed elements may be folded
211
+ into the pushed values when no structural operation intervenes.
212
+
213
+ Front or middle insertion and removal are recorded directly. Edits before and
214
+ after an index-changing operation remain ordered against the array generation
215
+ they addressed. Sorting, reversing, `fill()`, and `copyWithin()` emit a snapshot
216
+ of the affected array; repeated whole-array mutators can therefore produce a
217
+ redundant snapshot even when their combined result restores the prior value.
218
+
219
+ Sparse arrays are unsupported. Writing beyond the next index throws. Increasing
220
+ `length` creates explicit `null` elements; decreasing it removes elements.
205
221
 
206
- Front or middle insertion, removal, sorting, reversing, `fill()`, and
207
- `copyWithin()` are supported. A structural change combined with edits to elements
208
- whose indices moved may compare and publish the retained suffix positionally:
222
+ `fill()` and `copyWithin()` keep normal JavaScript reference semantics; an object
223
+ they place at several indices is published at each.
224
+
225
+ ### Large mutation windows
226
+
227
+ #### Build once, assign once
228
+
229
+ Every object or array assignment is diffed immediately. Do not repeatedly assign
230
+ large intermediate values before one flush:
209
231
 
210
232
  ```ts
211
- tracker.state.items.shift();
212
- tracker.state.items[0].status = "changed";
213
- // A shift followed by push in the same flush has the same issue.
233
+ // Avoid: traverses every intermediate tree.
234
+ for (const frame of frames) tracker.state.view = render(frame);
235
+
236
+ // Prefer: only the final tree crosses the tracked boundary.
237
+ const nextView = frames.reduce((view, frame) => renderInto(view, frame), initialView);
238
+ tracker.state.view = nextView;
214
239
  ```
215
240
 
216
- The emitted data can then scale with the retained suffix, or with the complete
217
- array, rather than only the changed element. When batching is under your control,
218
- flush the structural change before editing elements at their new indices.
241
+ For a few changes, mutate the leaves directly:
219
242
 
220
- Sparse arrays are unsupported. Writing beyond the next index throws. Increasing
221
- `length` creates explicit `null` elements; decreasing it removes elements.
243
+ ```ts
244
+ for (const update of updates) {
245
+ tracker.state.view.rows[update.index]!.status = update.status;
246
+ }
247
+ ```
248
+
249
+ #### Do not cancel whole-array mutators
250
+
251
+ `sort()`, `reverse()`, `fill()`, and `copyWithin()` record snapshots. Cancelling
252
+ them still publishes the final snapshot:
253
+
254
+ ```ts
255
+ // Avoid: final value is unchanged, but a snapshot may still be sent.
256
+ tracker.state.items.reverse();
257
+ tracker.state.items.reverse();
222
258
 
223
- `fill()` and `copyWithin()` keep normal JavaScript reference semantics. Do not use
224
- them to place one mutable object at multiple live paths.
259
+ // Prefer: decide before mutating tracked state.
260
+ if (needsReverse) tracker.state.items.reverse();
261
+ ```
262
+
263
+ #### Publish large inserts and edit sets in chunks
264
+
265
+ Pending inserted values are cloned for replica ownership. Very large unflushed
266
+ pushes therefore temporarily retain both the live values and their operation
267
+ payloads. Long operation/path histories may also collapse to a complete base
268
+ batch, increasing snapshot and wire cost.
269
+
270
+ ```ts
271
+ for (const chunk of chunks(items, 1_000)) {
272
+ tracker.state.items.push(...chunk);
273
+ replica = apply(replica, tracker.flush()); // send each batch in a real producer
274
+ }
275
+ ```
276
+
277
+ Use the same pattern for large sets of unrelated edits: apply a bounded chunk,
278
+ publish it, then continue.
225
279
 
226
280
  ### Optional properties
227
281
 
@@ -243,42 +297,65 @@ array position or explicit empty value must remain present.
243
297
 
244
298
  ## State ownership
245
299
 
246
- The object passed to `track()` becomes tracker-owned. The same applies to objects
247
- later assigned into state or inserted into arrays.
248
-
249
- After insertion, a retained reference may be read but must not be mutated or
250
- inserted at another live location. The tracker relies on this ownership rule; it
251
- does not recursively validate values or detect aliases:
300
+ The object passed to `track()` becomes tracker-owned, as does any object later
301
+ assigned into state or inserted into an array. Mutate through `tracker.state`:
252
302
 
253
303
  ```ts
254
304
  const item = { status: "new" };
255
305
  tracker.state.item = item;
256
306
 
257
- tracker.state.item.status = "ready"; // supported: tracked mutation
258
- item.status = "broken"; // unsupported: bypasses tracking
259
- tracker.state.other = item; // unsupported: one object at two live paths
307
+ tracker.state.item.status = "ready"; // tracked
308
+ item.status = "broken"; // NOT tracked: silently diverges from the replica
260
309
  ```
261
310
 
262
- The same restriction applies across separate array calls:
311
+ A reference retained from `tracker.state` stays correct across operations that
312
+ renumber it, and across the removal of the element it points at:
263
313
 
264
314
  ```ts
265
- tracker.state.items.push(item);
266
- tracker.state.items.push(item); // unsupported alias
315
+ const held = tracker.state.items[2];
316
+ tracker.state.items.unshift(other);
317
+ held.name = "edited"; // publishes items[3].name
318
+
319
+ tracker.state.items.splice(3, 1);
320
+ held.name = "gone"; // element is no longer in the tree: mutated, nothing published
267
321
  ```
268
322
 
269
- Use distinct objects when values must appear at multiple paths. Perform
270
- mutations through `tracker.state`; do not put a proxy read from `tracker.state`
271
- back into tracked state.
323
+ One object may occupy several paths. Each live path is published:
324
+
325
+ ```ts
326
+ tracker.state.a = tracker.state.items[0];
327
+ tracker.state.items[0].k = 1; // publishes both a.k and items[0].k
328
+ ```
272
329
 
273
330
  Tracked state must be a mutable JSON tree:
274
331
 
275
332
  - strings, booleans, finite numbers, `null`, arrays, and plain objects;
276
- - no cycles or one mutable object stored at multiple locations;
333
+ - no cycles;
277
334
  - no sparse arrays, accessors, frozen objects, symbols, classes, functions,
278
335
  `Map`, or `Set`.
279
336
 
280
- Do not keep a child proxy across an array operation that changes indices. Read
281
- the child again from its new index.
337
+ ## Proxy lifetime and large reads
338
+
339
+ Proxy caches are weak. Reading a subtree does not permanently retain its proxies
340
+ just because the underlying plain objects remain in the document. A proxy still
341
+ held by application code keeps its identity; held descendants retain the ancestor
342
+ tracking metadata needed to follow array reindexing. Explicit alias locations are
343
+ remembered separately from the lifetime of their public proxies.
344
+
345
+ Collection is automatic, not a `flush()` side effect. JavaScript keeps newly
346
+ created or dereferenced `WeakRef` targets alive until the current job ends, and
347
+ finalizer cleanup can run later. A synchronous traversal can therefore still have
348
+ a substantial allocation peak. Retained-memory measurements must allow event-loop
349
+ turns as well as GC; a synchronous `gc()` immediately after the traversal is not
350
+ sufficient to measure weak-cache reclamation.
351
+
352
+ This does not eliminate proxy construction/trap costs or full comparisons on
353
+ container assignment. `tracker.target` is available for read-only bulk inspection
354
+ without creating proxies. Never mutate through it; all tracked mutations must go
355
+ through `tracker.state` or a proxy obtained from it.
356
+
357
+ See the [delta investigation findings](../../../durable/docs/chord-delta-findings.md)
358
+ for the full-traversal regression, measured trade-offs, and reproduction commands.
282
359
 
283
360
  ## Tracker lifecycle
284
361
 
@@ -292,6 +369,14 @@ tracker.state = replacement; // replace the root; next flush is complete
292
369
  `discard()` intentionally prevents current changes from reaching existing
293
370
  replicas. Use it only when those replicas do not need the discarded changes.
294
371
 
372
+ `flush()` guarantees convergence, not a minimal or canonical diff. Any nonempty
373
+ batch advances replicated-state sequence numbers and notifies subscribers, even
374
+ if applying it leaves the value deeply equal to the previous revision. To bound
375
+ pending operation and path history, a sufficiently long mutation window may
376
+ collapse to a complete base batch automatically. This bounds accumulated log
377
+ metadata, not payload bytes or peak allocation, and trades one full-value
378
+ snapshot for an additional recovery point.
379
+
295
380
  `apply()` adopts object and array payloads from its input batch. Do not freeze a
296
381
  batch before applying it, and do not apply one in-memory batch to multiple
297
382
  mutable replicas unless each replica owns that batch. A serialized and decoded
@@ -308,8 +393,8 @@ changed the replica.
308
393
  - Delta assumes one authoritative writer and ordered delivery. Sequence numbers,
309
394
  gap detection, retries, and persistence policy belong to the surrounding
310
395
  protocol or storage format.
311
- - Object identity is not replicated. Tracked mutable state must be a tree;
312
- immutable inputs may share references, but replicas need not preserve them.
396
+ - Object identity is not replicated. One object at several paths publishes each
397
+ path separately, and a replica holds a distinct value at each.
313
398
  - Object key insertion order is not replicated. Do not compare or hash replicas
314
399
  using serialized key order.
315
400
  - Array operations that change indices may publish a wider array region, as