@earendil-works/chord 0.87.1 → 0.99.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.
Files changed (99) hide show
  1. package/README.md +80 -27
  2. package/dist/api.d.ts +6 -1
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +8 -3
  5. package/dist/api.js.map +1 -1
  6. package/dist/bundler.d.ts.map +1 -1
  7. package/dist/context/index.d.ts.map +1 -1
  8. package/dist/context/index.js.map +1 -1
  9. package/dist/delta/apply-immutable-trusted.d.ts +4 -0
  10. package/dist/delta/apply-immutable-trusted.d.ts.map +1 -0
  11. package/dist/delta/apply-immutable-trusted.js +127 -0
  12. package/dist/delta/apply-immutable-trusted.js.map +1 -0
  13. package/dist/{state → delta}/diff.d.ts +1 -1
  14. package/dist/delta/diff.d.ts.map +1 -0
  15. package/dist/delta/diff.js +433 -0
  16. package/dist/delta/diff.js.map +1 -0
  17. package/dist/delta/draft.d.ts +5 -0
  18. package/dist/delta/draft.d.ts.map +1 -0
  19. package/dist/delta/draft.js +2 -0
  20. package/dist/delta/draft.js.map +1 -0
  21. package/dist/delta/index.d.ts +11 -21
  22. package/dist/delta/index.d.ts.map +1 -1
  23. package/dist/delta/index.js +43 -1341
  24. package/dist/delta/index.js.map +1 -1
  25. package/dist/delta/revision-validator.d.ts +7 -0
  26. package/dist/delta/revision-validator.d.ts.map +1 -0
  27. package/dist/delta/revision-validator.js +71 -0
  28. package/dist/delta/revision-validator.js.map +1 -0
  29. package/dist/delta/tracker.d.ts +26 -0
  30. package/dist/delta/tracker.d.ts.map +1 -0
  31. package/dist/delta/tracker.js +2069 -0
  32. package/dist/delta/tracker.js.map +1 -0
  33. package/dist/facets/host.d.ts.map +1 -1
  34. package/dist/facets/host.js.map +1 -1
  35. package/dist/facets/loader.d.ts.map +1 -1
  36. package/dist/facets/loader.js.map +1 -1
  37. package/dist/index.d.ts +3 -3
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/json.d.ts +6 -0
  42. package/dist/json.d.ts.map +1 -1
  43. package/dist/json.js +73 -6
  44. package/dist/json.js.map +1 -1
  45. package/dist/node/bundle-loader.d.ts.map +1 -1
  46. package/dist/node/bundle-loader.js.map +1 -1
  47. package/dist/node/bundle.d.ts.map +1 -1
  48. package/dist/node/bundle.js.map +1 -1
  49. package/dist/node/manifest.d.ts.map +1 -1
  50. package/dist/node/package.d.ts.map +1 -1
  51. package/dist/node/package.js.map +1 -1
  52. package/dist/node.d.ts.map +1 -1
  53. package/dist/services/consumer.d.ts.map +1 -1
  54. package/dist/services/consumer.js +49 -1
  55. package/dist/services/consumer.js.map +1 -1
  56. package/dist/services/errors.d.ts.map +1 -1
  57. package/dist/services/errors.js.map +1 -1
  58. package/dist/services/handle.d.ts.map +1 -1
  59. package/dist/services/handle.js +3 -1
  60. package/dist/services/handle.js.map +1 -1
  61. package/dist/services/instances.d.ts +1 -0
  62. package/dist/services/instances.d.ts.map +1 -1
  63. package/dist/services/instances.js +3 -0
  64. package/dist/services/instances.js.map +1 -1
  65. package/dist/services/loopback.d.ts.map +1 -1
  66. package/dist/services/loopback.js.map +1 -1
  67. package/dist/services/provider.d.ts.map +1 -1
  68. package/dist/services/provider.js +48 -29
  69. package/dist/services/provider.js.map +1 -1
  70. package/dist/services/state-codec.d.ts.map +1 -1
  71. package/dist/services/state-codec.js +18 -0
  72. package/dist/services/state-codec.js.map +1 -1
  73. package/dist/services/state-internals.d.ts +5 -2
  74. package/dist/services/state-internals.d.ts.map +1 -1
  75. package/dist/services/state-internals.js.map +1 -1
  76. package/dist/services/state.d.ts +8 -3
  77. package/dist/services/state.d.ts.map +1 -1
  78. package/dist/services/state.js +288 -88
  79. package/dist/services/state.js.map +1 -1
  80. package/dist/services/wire.d.ts +3 -0
  81. package/dist/services/wire.d.ts.map +1 -1
  82. package/dist/services/wire.js +13 -0
  83. package/dist/services/wire.js.map +1 -1
  84. package/dist/types.d.ts +74 -8
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/types.js.map +1 -1
  87. package/package.json +3 -3
  88. package/src/delta/README.md +143 -360
  89. package/dist/state/diff.d.ts.map +0 -1
  90. package/dist/state/diff.js +0 -259
  91. package/dist/state/diff.js.map +0 -1
  92. package/dist/state/draft.d.ts +0 -13
  93. package/dist/state/draft.d.ts.map +0 -1
  94. package/dist/state/draft.js +0 -540
  95. package/dist/state/draft.js.map +0 -1
  96. package/dist/state/value.d.ts +0 -9
  97. package/dist/state/value.d.ts.map +0 -1
  98. package/dist/state/value.js +0 -175
  99. package/dist/state/value.js.map +0 -1
@@ -1,408 +1,191 @@
1
1
  # Chord Delta
2
2
 
3
- Chord Delta synchronizes JSON values from an authoritative producer to an
4
- ordered replica. It is available from `@earendil-works/chord/delta`.
3
+ Chord Delta produces immutable JSON revisions and exact operation batches for
4
+ ordered replicas. Import it from `@earendil-works/chord/delta`.
5
5
 
6
- A change is represented by an `Op`: a JSON tuple for replacing, setting,
7
- deleting, updating a string, splicing an array, or permuting an array. Producers
8
- use `track()`; replicas use `apply()` or `applyImmutable()`.
6
+ Immutability is an ownership contract. Nothing is frozen or defensively copied,
7
+ so an illegal mutation is not detected. It silently corrupts state.
9
8
 
10
9
  ```ts
11
- import { apply, track } from "@earendil-works/chord/delta";
10
+ import { applyImmutable, applyImmutableBatches, track } from "@earendil-works/chord/delta";
12
11
 
13
- const tracker = track({ output: "", entries: [] as string[] });
14
- let replica = apply(undefined, tracker.flush());
12
+ const initial = { output: "", entries: [] as { id: number }[] };
13
+ const tracker = track(initial); // `initial` is transferred: never mutate it again
15
14
 
16
- tracker.state.output += "done\n";
17
- tracker.state.entries.push("result");
18
- replica = apply(replica, tracker.flush());
19
- ```
20
-
21
- The first `flush()` returns one operation containing the complete value. Each
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.
26
-
27
- `applyImmutable()` copies only containers along changed paths and shares
28
- unchanged subtrees. It does not mutate, clone, or freeze either complete input.
29
- Chord's replicated-state producers use a separate transaction-scoped
30
- copy-on-write draft and derive these operation batches from immutable revisions;
31
- consumers still observe complete immutable values.
32
-
33
- ## Sending or storing changes
15
+ const change = tracker.beginChange();
16
+ change.state.output += "done\n"; // the draft is mutable only while the change is open
17
+ change.state.entries.push({ id: 1 }); // placed values are cloned; the caller keeps its object
18
+ const prepared = change.prepare(); // draft handles are unusable from here on
34
19
 
35
- `flush()` produces decoded `Op[]` with complete paths. This is convenient for
36
- local use but repeats long paths on the wire or disk.
20
+ // tracker.value, prepared.base, prepared.value, and prepared.ops (including paths
21
+ // and payloads) are immutable by contract.
22
+ tracker.adopt(prepared); // tracker.value === prepared.value
37
23
 
38
- `encoder()` compresses those paths and returns `WireOp[]`. `decoder()` validates
39
- the encoded tuples, restores complete paths, and returns the `Op[]` required by
40
- `apply()`:
41
-
42
- ```ts
43
- import { apply, decoder, encoder, track } from "@earendil-works/chord/delta";
44
-
45
- const tracker = track({ output: "" });
46
- const enc = encoder(); // producer side
47
- const dec = decoder(); // consumer side
48
- let replica: { output: string } | undefined;
49
-
50
- const send = () => {
51
- const ops = tracker.flush();
52
- const wire = enc.encode(ops); // serialize or store WireOp[] here
53
- const received = dec.decode(wire);
54
- replica = apply(replica, received);
55
- };
24
+ // Shares containers with prepared.base and the op payloads: never mutate it either.
25
+ const replica = applyImmutable(prepared.base, prepared.ops);
56
26
  ```
57
27
 
58
- Encoding is optional for local application. Never pass `WireOp[]` directly to
59
- `apply()`.
60
-
61
- An encoder and decoder are stateful. Use one pair for each ordered stream. The
62
- encoder assigns numeric IDs to paths used across batches; the decoder remembers
63
- the corresponding definitions. A complete-value operation resets both path
64
- dictionaries, so replay can begin at that batch with a fresh decoder.
65
-
66
- Path omission is local to one batch. Numeric path IDs may span batches. Each
67
- independently hydrated replicated-state stream needs its own encoder and decoder.
68
- Do not share a pair between state members or subscriptions, even when their
69
- batches use the same ordered transport connection.
70
-
71
- ## Operation vocabulary
72
-
73
- A path is an array of object keys and array indices:
74
-
75
- ```ts
76
- ["operation", "message", "content", 0, "text"]
77
- ```
28
+ ## Mutation rights
78
29
 
79
- ### Decoded `Op`
80
-
81
- `track().flush()` returns these tuples, and `apply()` accepts them:
82
-
83
- | Tuple | Meaning |
84
- | --- | --- |
85
- | `["r", value]` | Replace the complete value. |
86
- | `["s", path, value]` | Set a property or array element. |
87
- | `["d", path]` | Delete an object property. |
88
- | `["a", path, text]` | Append to a string. |
89
- | `["t", path, count]` | Remove UTF-16 code units from a string's front. |
90
- | `["p", path, index, remove, items]` | Splice an array. |
91
- | `["m", path, permutation]` | Reorder an array so `new[i] = old[permutation[i]]`. |
92
-
93
- Except for `r`, every decoded operation carries its complete path. `s`, `d`,
94
- `a`, and `t` cannot address the root. `p` and `m` may address a root array.
95
-
96
- ### Encoded `WireOp`
97
-
98
- A `PathRef` is either an inline path or a non-negative numeric path ID.
99
- `WireOp` supports the following tuples:
100
-
101
- | Tuple | Meaning |
30
+ | Value | May it be mutated? |
102
31
  | --- | --- |
103
- | `["r", value]` | Complete replacement; identical to decoded form. |
104
- | `["#", id, path]` | Define a numeric path ID. |
105
- | `["s", pathRef, value]` | Set with an inline or interned path. |
106
- | `["s", value]` | Set using the previous path in this batch. |
107
- | `["d", pathRef]` | Delete with an inline or interned path. |
108
- | `["d"]` | Delete using the previous path. |
109
- | `["a", pathRef, text]` | Append with an inline or interned path. |
110
- | `["a", text]` | Append using the previous path. |
111
- | `["t", pathRef, count]` | Front-truncate with an inline or interned path. |
112
- | `["t", count]` | Front-truncate using the previous path. |
113
- | `["p", pathRef, index, remove, items]` | Splice with an inline or interned path. |
114
- | `["p", index, remove, items]` | Splice using the previous path. |
115
- | `["m", pathRef, permutation]` | Reorder with an inline or interned path. |
116
- | `["m", permutation]` | Reorder using the previous path. |
117
-
118
- For example, adjacent decoded operations on one path:
119
-
120
- ```ts
121
- [
122
- ["t", ["output"], 200],
123
- ["a", ["output"], "next chunk"],
124
- ]
125
- ```
126
-
127
- encode to:
128
-
129
- ```ts
130
- [
131
- ["t", ["output"], 200],
132
- ["a", "next chunk"], // reuses ["output"]
133
- ]
134
- ```
135
-
136
- When `output` is used again in a later batch, the encoder defines an ID on its
137
- second explicit use:
138
-
139
- ```ts
140
- [
141
- ["#", 0, ["output"]],
142
- ["a", 0, "more"],
143
- ]
144
- ```
145
-
146
- Later batches can use `0` directly until a complete-value operation resets the
147
- dictionary.
148
-
149
- ## Producing changes
150
-
151
- Read and mutate `tracker.state` as a normal object:
152
-
153
- ```ts
154
- tracker.state.status = "running";
155
- tracker.state.settings.theme = "dark";
156
- tracker.state.messages.push(message);
157
- delete tracker.state.retry;
158
- ```
159
-
160
- Operations are coalesced within a flush window when doing so is cheap and safe:
161
-
162
- ```ts
163
- tracker.state.status = "starting";
164
- tracker.state.status = "running";
165
- tracker.flush(); // one set to "running"
166
- ```
167
-
168
- The operation sequence is not canonical. Equivalent changes may use different
169
- verbs, and mutations that cancel can still produce a nonempty batch. Consumers
170
- must depend on the resulting value, not the exact tuples or their minimality.
171
-
172
- Replacing an object or array is valid. Delta compares its properties and elements
173
- with the outgoing value at assignment time:
174
-
175
- ```ts
176
- tracker.state.settings = {
177
- ...plainSettings,
178
- theme: "dark",
179
- };
180
- ```
32
+ | Root passed to `track()`, `prepareReplace()`, `replicatedState()`, or `replace()` | No. Ownership moved to the tracker. |
33
+ | `change.state` and handles read from it | Yes, only while that change is open. After `prepare()`, `abort()`, or another adoption, every use throws. Writes through a handle whose element was removed from the draft are ignored. |
34
+ | External value after assigning or inserting it into a draft | Yes. The draft stored a validated clone. |
35
+ | `tracker.value`, `prepared.base`, retained older revisions | No. |
36
+ | `prepared.value`, `prepared.ops`, op tuples, paths, permutations, payloads | No. Payloads may be the same objects as parts of `prepared.value`. |
37
+ | `applyImmutable()` / `applyImmutableBatches()` inputs and result | No. The result shares containers with both inputs. |
38
+ | Values from replicated state (`value`, listener values, loopback consumers) | No. In-process consumers may share the provider's containers. |
39
+ | Mutable replica passed to `apply()` | Only through `apply()`, with batches it owns exclusively. Code that edits it between batches breaks convergence. |
40
+ | Batch passed to `apply()` | Consumed. `apply()` adopts payload containers into the replica. Use a detached copy for exactly one replica and never touch it again. |
181
41
 
182
- Unchanged properties produce no operations. Changed nested strings and arrays
183
- still use string and splice operations.
42
+ ## Replaying batches
184
43
 
185
- ### Strings
186
-
187
- Appending text produces an `a` operation:
44
+ Immutable replay is the preferred in-process path. One batch can fan out to any
45
+ number of immutable replicas:
188
46
 
189
47
  ```ts
190
- tracker.state.output += "next line\n";
48
+ let replica = tracker.value; // shared with authority; never mutated
49
+ // For each adopted batch, in order:
50
+ replica = applyImmutable(replica, prepared.ops); // replica must equal prepared.base
191
51
  ```
192
52
 
193
- Moving a bounded text window forward produces `t` followed by `a` when the
194
- previous suffix matches the new prefix:
53
+ When only the final result of an ordered backlog is needed, replay its batches
54
+ without concatenating their operations. One copy-on-write scope is shared across
55
+ the complete call, so no intermediate revision is exposed or safe to retain:
195
56
 
196
57
  ```ts
197
- tracker.state.output = tracker.state.output.slice(200) + nextChunk;
58
+ replica = applyImmutableBatches(
59
+ replica,
60
+ queuedFrames.map((frame) => frame.ops),
61
+ );
198
62
  ```
199
63
 
200
- An unrelated replacement produces `s`.
201
-
202
- ### Arrays
64
+ Use separate `applyImmutable()` calls when every intermediate revision is
65
+ published or retained.
203
66
 
204
- Use normal array methods:
67
+ Mutable replay needs a detached starting root and a detached copy of every batch
68
+ for every replica. Never apply one in-memory batch to two mutable replicas:
205
69
 
206
70
  ```ts
207
- tracker.state.messages.push(first);
208
- tracker.state.messages.push(second);
209
- tracker.state.messages.splice(3, 1, replacement);
210
- ```
211
-
212
- Adjacent `push()` calls are normally coalesced into one tail `p`. Intervening
213
- operations may keep them separate to preserve ordering. Changes to older
214
- elements remain separate, and changes to newly pushed elements may be folded
215
- into the pushed values when no structural operation intervenes.
216
-
217
- Front or middle insertion and removal are recorded directly. Edits before and
218
- after an index-changing operation remain ordered against the array generation
219
- they addressed. Sorting, reversing, `fill()`, and `copyWithin()` emit a snapshot
220
- of the affected array; repeated whole-array mutators can therefore produce a
221
- redundant snapshot even when their combined result restores the prior value.
71
+ import { apply } from "@earendil-works/chord/delta";
222
72
 
223
- Sparse arrays are unsupported. Writing beyond the next index throws. Increasing
224
- `length` creates explicit `null` elements; decreasing it removes elements.
225
-
226
- `fill()` and `copyWithin()` keep normal JavaScript reference semantics; an object
227
- they place at several indices is published at each.
228
-
229
- ### Large mutation windows
230
-
231
- #### Build once, assign once
232
-
233
- Every object or array assignment is diffed immediately. Do not repeatedly assign
234
- large intermediate values before one flush:
235
-
236
- ```ts
237
- // Avoid: traverses every intermediate tree.
238
- for (const frame of frames) tracker.state.view = render(frame);
73
+ const detach = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
239
74
 
240
- // Prefer: only the final tree crosses the tracked boundary.
241
- const nextView = frames.reduce((view, frame) => renderInto(view, frame), initialView);
242
- tracker.state.view = nextView;
75
+ let replica = detach(tracker.value); // exclusively owned starting root
76
+ // For each adopted batch, in order:
77
+ replica = apply(replica, detach(prepared.ops)); // fresh copy for this replica only
78
+ // Only apply() may change `replica`; do not edit it in application code.
243
79
  ```
244
80
 
245
- For a few changes, mutate the leaves directly:
81
+ Parsing a separately serialized message per replica is also a valid detachment.
82
+ `decode()` does not copy payloads, so decoding one in-memory `WireOp[]` for two
83
+ mutable replicas makes them alias each other.
246
84
 
247
- ```ts
248
- for (const update of updates) {
249
- tracker.state.view.rows[update.index]!.status = update.status;
250
- }
251
- ```
252
-
253
- #### Do not cancel whole-array mutators
85
+ ## Do and don't
254
86
 
255
- `sort()`, `reverse()`, `fill()`, and `copyWithin()` record snapshots. Cancelling
256
- them still publishes the final snapshot:
87
+ - Do build a fresh root and then transfer it; validate untrusted roots at your ingestion boundary.
88
+ - Do keep structural sharing between revisions, for example `replace(ctx, { ...state.value, changed })`.
89
+ - Do clone or serialize before handing any published value to code that may mutate it.
90
+ - Do settle every change with `prepare()` or `abort()`.
91
+ - Don't reuse one container at two places in a transferred root.
92
+ - Don't mutate anything the tracker owns, published, or received from a batch.
93
+ - Don't keep draft handles past their change or place a settled handle.
94
+ - Don't infer meaning from op shape. Only the resulting value is contractual.
257
95
 
258
- ```ts
259
- // Avoid: final value is unchanged, but a snapshot may still be sent.
260
- tracker.state.items.reverse();
261
- tracker.state.items.reverse();
262
-
263
- // Prefer: decide before mutating tracked state.
264
- if (needsReverse) tracker.state.items.reverse();
265
- ```
96
+ ## Roots and placements
266
97
 
267
- #### Publish large inserts and edit sets in chunks
98
+ Full roots are trusted. `track()` and `prepareReplace()` take ownership in O(1)
99
+ and never walk the tree. Roots must be alias-free, acyclic strict JSON: dense
100
+ plain arrays, plain or null-prototype objects with enumerable own data
101
+ properties, strings, booleans, finite numbers, and `null`. Violations have
102
+ unspecified behavior. For example, a container shared by two keys can make
103
+ `prepared.value` diverge from what replicas compute from `prepared.ops`.
268
104
 
269
- Pending inserted values are cloned for replica ownership. Very large unflushed
270
- pushes therefore temporarily retain both the live values and their operation
271
- payloads. Long operation/path histories may also collapse to a complete base
272
- batch, increasing snapshot and wire cost.
105
+ Draft placements are copied, and that copy walk also validates. Placements come
106
+ from property writes, index writes, `push`, `unshift`, `splice`, `fill`, and
107
+ `copyWithin`. A non-strict value throws `TypeError` before the draft changes;
108
+ for example, `push(valid, invalid)` inserts nothing. Rejected values include:
109
+ cycles, accessors (never invoked), symbol keys, class instances, sparse or
110
+ non-plain arrays, functions, bigint, `NaN`, infinities, and `undefined`.
111
+ Null-prototype objects are accepted and preserved. Placing a draft handle clones
112
+ its current content. Placing one value at several paths yields independent
113
+ containers.
273
114
 
274
- ```ts
275
- for (const chunk of chunks(items, 1_000)) {
276
- tracker.state.items.push(...chunk);
277
- replica = apply(replica, tracker.flush()); // send each batch in a real producer
278
- }
279
- ```
115
+ `undefined` rules:
280
116
 
281
- Use the same pattern for large sets of unrelated edits: apply a bounded chunk,
282
- publish it, then continue.
117
+ - `draft.obj.key = undefined` deletes `key`.
118
+ - `arr[i] = undefined`, `push(undefined)`, `unshift`, `splice`, and `fill` with `undefined` throw.
119
+ - `undefined` nested anywhere inside a placed object or array throws. It is not dropped like `JSON.stringify` does.
283
120
 
284
- ### Optional properties
121
+ Arrays stay dense. Writing past the next index and deleting an element throw.
122
+ Growing `length` inserts `null`; shrinking it removes elements.
285
123
 
286
- Optional object properties use absence. They do not require `null`:
124
+ ## Lifecycle
287
125
 
288
- ```ts
289
- type Settings = { label?: string; count: number };
290
- const tracker = track<Settings>({ count: 0 });
126
+ `tracker.value` is the latest adopted revision. `beginChange()` opens an overlay
127
+ draft over it and never modifies it. A draft may stay open across `await`.
128
+ `prepare()` materializes the candidate and ops and does not change authority.
129
+ `adopt()` checks the preparation and swaps the root pointer.
291
130
 
292
- tracker.state.label = "active";
293
- tracker.state.label = undefined; // produces d
294
- // `delete tracker.state.label` is equivalent
295
- ```
131
+ Several changes may be open or prepared from one revision. Adopting one makes all
132
+ others stale: open drafts become unusable and their `prepare()` throws, and
133
+ prepared competitors are rejected by `adopt()`. `adopt()` also rejects foreign,
134
+ aborted, and already consumed preparations. `Change.abort()` or
135
+ `Prepared.abort()` after `prepare()` prevents adoption. The candidate stays
136
+ readable.
296
137
 
297
- `undefined` is accepted only as assignment syntax for deleting an object
298
- property. It is not a JSON value. Initial and assigned objects cannot contain own
299
- `undefined` values, and array elements cannot be `undefined`. Use `null` when an
300
- array position or explicit empty value must remain present.
138
+ No-op batches:
301
139
 
302
- ## State ownership
140
+ - Empty `ops` means `prepared.value === prepared.base`. Writes restored to their original value and deeply equal container assignments usually normalize to empty.
141
+ - Equality ignores key order and prototype. A deeply equal assignment keeps the previous revision's order and prototype, even if the draft showed the new ones.
142
+ - Structural array edits may emit a nonempty exact batch even when the result is deeply equal.
143
+ - Adopting a no-op still advances `tracker.revision` and stales competitors. Replicated state does not publish no-ops.
303
144
 
304
- The object passed to `track()` becomes tracker-owned, as does any object later
305
- assigned into state or inserted into an array. Mutate through `tracker.state`:
145
+ `prepareReplace(value)` is a whole-root operation, not a diff. If `value` is
146
+ deeply equal to the current root, `ops` is empty and the current root is kept
147
+ (the comparison may traverse both trees). Otherwise `ops` is `[["r", value]]`
148
+ and `prepared.value === value`, so replicas receive the complete root.
306
149
 
307
- ```ts
308
- const item = { status: "new" };
309
- tracker.state.item = item;
150
+ Array mutators: `push`, `pop`, `shift`, `unshift`, `splice`, `reverse`, `sort`,
151
+ `fill`, and `copyWithin`. Held handles follow elements through reindexing.
310
152
 
311
- tracker.state.item.status = "ready"; // tracked
312
- item.status = "broken"; // NOT tracked: silently diverges from the replica
313
- ```
153
+ ## Operations
314
154
 
315
- A reference retained from `tracker.state` stays correct across operations that
316
- renumber it, and across the removal of the element it points at:
155
+ Paths contain object keys and non-negative integer array indices.
317
156
 
318
- ```ts
319
- const held = tracker.state.items[2];
320
- tracker.state.items.unshift(other);
321
- held.name = "edited"; // publishes items[3].name
322
-
323
- tracker.state.items.splice(3, 1);
324
- held.name = "gone"; // element is no longer in the tree: mutated, nothing published
325
- ```
326
-
327
- One object may occupy several paths. Each live path is published:
328
-
329
- ```ts
330
- tracker.state.a = tracker.state.items[0];
331
- tracker.state.items[0].k = 1; // publishes both a.k and items[0].k
332
- ```
333
-
334
- Tracked state must be a mutable JSON tree:
335
-
336
- - strings, booleans, finite numbers, `null`, arrays, and plain objects;
337
- - no cycles;
338
- - no sparse arrays, accessors, frozen objects, symbols, classes, functions,
339
- `Map`, or `Set`.
340
-
341
- ## Proxy lifetime and large reads
342
-
343
- Proxy caches are weak. Reading a subtree does not permanently retain its proxies
344
- just because the underlying plain objects remain in the document. A proxy still
345
- held by application code keeps its identity; held descendants retain the ancestor
346
- tracking metadata needed to follow array reindexing. Explicit alias locations are
347
- remembered separately from the lifetime of their public proxies.
348
-
349
- Collection is automatic, not a `flush()` side effect. JavaScript keeps newly
350
- created or dereferenced `WeakRef` targets alive until the current job ends, and
351
- finalizer cleanup can run later. A synchronous traversal can therefore still have
352
- a substantial allocation peak. Retained-memory measurements must allow event-loop
353
- turns as well as GC; a synchronous `gc()` immediately after the traversal is not
354
- sufficient to measure weak-cache reclamation.
355
-
356
- This does not eliminate proxy construction/trap costs or full comparisons on
357
- container assignment. `tracker.target` is available for read-only bulk inspection
358
- without creating proxies. Never mutate through it; all tracked mutations must go
359
- through `tracker.state` or a proxy obtained from it.
360
-
361
- See the [delta investigation findings](../../../durable/docs/chord-delta-findings.md)
362
- for the full-traversal regression, measured trade-offs, and reproduction commands.
363
-
364
- ## Tracker lifecycle
365
-
366
- ```ts
367
- tracker.flush(); // publish changes since the previous flush
368
- tracker.rebase(); // make the next flush a complete replacement
369
- tracker.discard(); // accept current changes without publishing them
370
- tracker.state = replacement; // replace the root; next flush is complete
371
- ```
372
-
373
- `discard()` intentionally prevents current changes from reaching existing
374
- replicas. Use it only when those replicas do not need the discarded changes.
375
-
376
- `flush()` guarantees convergence, not a minimal or canonical diff. Any nonempty
377
- batch advances replicated-state sequence numbers and notifies subscribers, even
378
- if applying it leaves the value deeply equal to the previous revision. To bound
379
- pending operation and path history, a sufficiently long mutation window may
380
- collapse to a complete base batch automatically. This bounds accumulated log
381
- metadata, not payload bytes or peak allocation, and trades one full-value
382
- snapshot for an additional recovery point.
383
-
384
- `apply()` adopts object and array payloads from its input batch. Do not freeze a
385
- batch before applying it, and do not apply one in-memory batch to multiple
386
- mutable replicas unless each replica owns that batch. A serialized and decoded
387
- batch is already detached. `applyImmutable()` instead treats its previous value
388
- and operation payloads as immutable, so one batch can safely fan out in-process.
389
-
390
- A `decode()`, `apply()`, or `applyImmutable()` error terminates that stream.
391
- Discard its decoder and replica, then recover from a later base batch. `apply()`
392
- is not transactional; operations before the failing operation may already have
393
- changed the replica.
394
-
395
- ## Limits
396
-
397
- - Delta assumes one authoritative writer and ordered delivery. Sequence numbers,
398
- gap detection, retries, and persistence policy belong to the surrounding
399
- protocol or storage format.
400
- - Object identity is not replicated. One object at several paths publishes each
401
- path separately, and a replica holds a distinct value at each.
402
- - Object key insertion order is not replicated. Do not compare or hash replicas
403
- using serialized key order.
404
- - Array operations that change indices may publish a wider array region, as
405
- described under Arrays.
406
- - Object-valued keys named `__proto__`, `constructor`, or `prototype` can be read
407
- and serialized, but cannot be mutated through that key. Replace the nearest
408
- ordinarily named parent instead.
157
+ | Tuple | Meaning |
158
+ | --- | --- |
159
+ | `["r", value]` | Replace the complete value. |
160
+ | `["s", path, value]` | Set an object property or array element. |
161
+ | `["d", path]` | Delete an object property or remove an array element. |
162
+ | `["a", path, text]` | Append to a string. |
163
+ | `["t", path, count]` | Remove `count` UTF-16 code units from a string's front. |
164
+ | `["p", path, index, remove, items]` | Splice an array. |
165
+ | `["m", path, permutation]` | Reorder an array: `new[i] = old[permutation[i]]`. |
166
+
167
+ Batches are exact but not canonical. The same change may use different tuples,
168
+ and large edit sets may fold into a region splice, an ancestor `s`, or `r`.
169
+
170
+ Reserved keys: the tracker never emits `__proto__`, `constructor`, or
171
+ `prototype` as a path segment. A mutation at or below such a key is folded into a
172
+ set of the nearest safe ancestor, or `r` at the root. `apply()`, `applyImmutable()`, `applyImmutableBatches()`, and `decoder()` reject
173
+ those segments with `UnsafePathError`.
174
+ The appliers write values as own data properties, never through a prototype setter.
175
+ Appliers check op shape and path safety but not payload strictness. Chord's
176
+ replicated-state replicas validate each resulting revision.
177
+
178
+ `encoder()` interns repeated paths into `WireOp` tuples; `decoder()` validates
179
+ and restores `Op` tuples. Use one encoder/decoder pair per ordered state stream.
180
+ Path IDs span batches and an `r` resets both dictionaries. After a decode or
181
+ apply error, discard the decoder and replica and recover from a later `r`.
182
+
183
+ ## Limits and footguns
184
+
185
+ - No freezing. Mutating any immutable value corrupts state silently.
186
+ - Loopback sharing: in-process service consumers may receive the provider's containers. A consumer mutation corrupts authority and makes remote replicas diverge.
187
+ - A change inside a large flat array copies that array's pointer storage for the new revision.
188
+ - Placement validation costs time and memory on bulk inserts. In a benchmark unshifting 100k three-field objects, it added about 19 ms and 20 MiB of transient heap.
189
+ - External proxies as placements, argument coercion callbacks that mutate the draft, non-primitive array indices, and non-numeric comparator results are out of contract. So are sort comparators that mutate, prepare, abort, or adopt. Behavior is unspecified.
190
+ - Object identity is not replicated. Each path is an independent value placement.
191
+ - One tracker is one revision sequence. Delivery order and persistence belong to the surrounding protocol.
@@ -1 +0,0 @@
1
- {"version":3,"file":"diff.d.ts","sourceRoot":"","sources":["../../src/state/diff.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,EAAE,EAAmD,MAAM,mBAAmB,CAAC;AAChH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AA4O7C,2EAA2E;AAC3E,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,GAAG,EAAE,EAAE,CAUvE","sourcesContent":["import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS, type Seg } from \"../delta/index.ts\";\nimport type { JsonValue } from \"../types.ts\";\n\nconst DEFAULT_OVERLAP_SCAN = 65_536;\nconst MAX_DELTA_OPERATIONS = 4_096;\n\nconst isContainer = (value: JsonValue): value is JsonValue[] | Record<string, JsonValue> =>\n\tvalue !== null && typeof value === \"object\";\n\nconst emitSet = (path: Path, value: JsonValue, operations: Op[]): void => {\n\tif (path.length === 0) operations.push([\"r\", value]);\n\telse operations.push([\"s\", path as NonEmptyPath, value]);\n};\n\nconst equalJson = (left: JsonValue, right: JsonValue): boolean => {\n\tif (left === right) return true;\n\tif (!isContainer(left) || !isContainer(right) || Array.isArray(left) !== Array.isArray(right)) return false;\n\tif (Array.isArray(left)) {\n\t\tconst other = right as JsonValue[];\n\t\tif (left.length !== other.length) return false;\n\t\tfor (let index = 0; index < left.length; index++) {\n\t\t\tif (!equalJson(left[index]!, other[index]!)) return false;\n\t\t}\n\t\treturn true;\n\t}\n\tconst other = right as Record<string, JsonValue>;\n\tconst keys = Object.keys(left);\n\tif (keys.length !== Object.keys(other).length) return false;\n\tfor (const key of keys) {\n\t\tif (!Object.hasOwn(other, key) || !equalJson(left[key]!, other[key]!)) return false;\n\t}\n\treturn true;\n};\n\nconst permutation = (before: readonly JsonValue[], after: readonly JsonValue[]): number[] | undefined => {\n\tif (before.length !== after.length) return undefined;\n\tconst positions = new Map<JsonValue, { indices: number[]; used: number }>();\n\tfor (let index = 0; index < before.length; index++) {\n\t\tconst value = before[index]!;\n\t\tconst entry = positions.get(value);\n\t\tif (entry === undefined) positions.set(value, { indices: [index], used: 0 });\n\t\telse entry.indices.push(index);\n\t}\n\tconst result = new Array<number>(after.length);\n\tfor (let index = 0; index < after.length; index++) {\n\t\tconst entry = positions.get(after[index]!);\n\t\tif (entry === undefined || entry.used === entry.indices.length) return undefined;\n\t\tresult[index] = entry.indices[entry.used++]!;\n\t}\n\treturn result;\n};\n\nconst emitString = (before: string, after: string, path: NonEmptyPath, operations: Op[]): void => {\n\tif (before === after) return;\n\tif (after.length > before.length && after.slice(0, before.length) === before) {\n\t\toperations.push([\"a\", path, after.slice(before.length)]);\n\t\treturn;\n\t}\n\tconst shared = overlap(before, after, DEFAULT_OVERLAP_SCAN);\n\tif (shared === 0) {\n\t\toperations.push([\"s\", path, after]);\n\t\treturn;\n\t}\n\toperations.push([\"t\", path, before.length - shared]);\n\tif (after.length > shared) operations.push([\"a\", path, after.slice(shared)]);\n};\n\nconst emitSameLengthShift = (\n\tbefore: readonly JsonValue[],\n\tafter: readonly JsonValue[],\n\tpath: Path,\n\toperations: Op[],\n): boolean => {\n\tconst length = before.length;\n\tif (length < 2) return false;\n\tlet candidates = 0;\n\tfor (let removed = 1; removed < length && candidates < 16; removed++) {\n\t\tif (before[removed] !== after[0]) continue;\n\t\tcandidates += 1;\n\t\tlet matches = true;\n\t\tfor (let index = removed; index < length; index++) {\n\t\t\tif (before[index] !== after[index - removed]) {\n\t\t\t\tmatches = false;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tif (!matches) continue;\n\t\toperations.push([\"p\", path, 0, removed, []]);\n\t\toperations.push([\"p\", path, length - removed, 0, after.slice(length - removed)]);\n\t\treturn true;\n\t}\n\tcandidates = 0;\n\tfor (let inserted = 1; inserted < length && candidates < 16; inserted++) {\n\t\tif (after[inserted] !== before[0]) continue;\n\t\tcandidates += 1;\n\t\tlet matches = true;\n\t\tfor (let index = inserted; index < length; index++) {\n\t\t\tif (after[index] !== before[index - inserted]) {\n\t\t\t\tmatches = false;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tif (!matches) continue;\n\t\toperations.push([\"p\", path, 0, 0, after.slice(0, inserted)]);\n\t\toperations.push([\"p\", path, length, inserted, []]);\n\t\treturn true;\n\t}\n\treturn false;\n};\n\nconst diffArray = (before: JsonValue[], after: JsonValue[], path: Path, operations: Op[]): void => {\n\tif (before.length === after.length) {\n\t\tlet mismatches = 0;\n\t\tfor (let index = 0; index < after.length; index++) {\n\t\t\tif (before[index] !== after[index]) mismatches += 1;\n\t\t}\n\t\tif (mismatches === 0) return;\n\t\tif (mismatches > 1) {\n\t\t\tconst order = permutation(before, after);\n\t\t\tif (order !== undefined) {\n\t\t\t\toperations.push([\"m\", path, order]);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (emitSameLengthShift(before, after, path, operations)) return;\n\t\t}\n\t\tfor (let index = 0; index < after.length; index++) {\n\t\t\tif (operations.length > MAX_DELTA_OPERATIONS) return;\n\t\t\tdiffValue(before[index]!, after[index]!, [...path, index], operations);\n\t\t}\n\t\treturn;\n\t}\n\n\tlet prefix = 0;\n\tconst shortest = Math.min(before.length, after.length);\n\twhile (prefix < shortest && equalJson(before[prefix]!, after[prefix]!)) prefix += 1;\n\tlet suffix = 0;\n\twhile (\n\t\tsuffix < shortest - prefix &&\n\t\tequalJson(before[before.length - 1 - suffix]!, after[after.length - 1 - suffix]!)\n\t) {\n\t\tsuffix += 1;\n\t}\n\toperations.push([\"p\", path, prefix, before.length - prefix - suffix, after.slice(prefix, after.length - suffix)]);\n};\n\nconst diffObject = (\n\tbefore: Record<string, JsonValue>,\n\tafter: Record<string, JsonValue>,\n\tpath: Path,\n\toperations: Op[],\n): void => {\n\tconst beforeKeys = Object.keys(before);\n\tconst afterKeys = Object.keys(after);\n\tif ([...beforeKeys, ...afterKeys].some((key) => RESERVED_SEGMENTS.has(key))) {\n\t\tif (!equalJson(before, after)) emitSet(path, after, operations);\n\t\treturn;\n\t}\n\tfor (const key of afterKeys) {\n\t\tif (operations.length > MAX_DELTA_OPERATIONS) return;\n\t\tif (Object.hasOwn(before, key)) diffValue(before[key]!, after[key]!, [...path, key], operations);\n\t\telse emitSet([...path, key], after[key]!, operations);\n\t}\n\tfor (const key of beforeKeys) {\n\t\tif (operations.length > MAX_DELTA_OPERATIONS) return;\n\t\tif (!Object.hasOwn(after, key)) operations.push([\"d\", [...path, key] as unknown as NonEmptyPath]);\n\t}\n};\n\nconst diffValue = (before: JsonValue, after: JsonValue, path: Path, operations: Op[]): void => {\n\tif (before === after || operations.length > MAX_DELTA_OPERATIONS) return;\n\tif (typeof before === \"string\" && typeof after === \"string\" && path.length > 0) {\n\t\temitString(before, after, path as NonEmptyPath, operations);\n\t\treturn;\n\t}\n\tif (Array.isArray(before) && Array.isArray(after)) {\n\t\tdiffArray(before, after, path, operations);\n\t\treturn;\n\t}\n\tif (isContainer(before) && isContainer(after) && !Array.isArray(before) && !Array.isArray(after)) {\n\t\tdiffObject(before, after, path, operations);\n\t\treturn;\n\t}\n\temitSet(path, after, operations);\n};\n\nconst jsonCost = (value: JsonValue): number => {\n\tif (value === null) return 4;\n\tif (typeof value === \"string\") return value.length + 2;\n\tif (typeof value === \"number\") return String(value).length;\n\tif (typeof value === \"boolean\") return value ? 4 : 5;\n\tif (Array.isArray(value)) {\n\t\tlet cost = 2;\n\t\tfor (let index = 0; index < value.length; index++) cost += jsonCost(value[index]!) + (index === 0 ? 0 : 1);\n\t\treturn cost;\n\t}\n\tlet cost = 2;\n\tlet index = 0;\n\tfor (const key of Object.keys(value)) {\n\t\tcost += key.length + 3 + jsonCost(value[key]!) + (index++ === 0 ? 0 : 1);\n\t}\n\treturn cost;\n};\n\nconst pathCost = (path: Path): number => {\n\tlet cost = 2;\n\tfor (let index = 0; index < path.length; index++) {\n\t\tconst segment: Seg = path[index]!;\n\t\tcost += (typeof segment === \"string\" ? segment.length + 2 : String(segment).length) + (index === 0 ? 0 : 1);\n\t}\n\treturn cost;\n};\n\nconst operationCost = (operation: Op): number => {\n\tswitch (operation[0]) {\n\t\tcase \"r\":\n\t\t\treturn 6 + jsonCost(operation[1]);\n\t\tcase \"s\":\n\t\t\treturn 7 + pathCost(operation[1]) + jsonCost(operation[2]);\n\t\tcase \"d\":\n\t\t\treturn 6 + pathCost(operation[1]);\n\t\tcase \"a\":\n\t\t\treturn 7 + pathCost(operation[1]) + operation[2].length + 2;\n\t\tcase \"t\":\n\t\t\treturn 7 + pathCost(operation[1]) + String(operation[2]).length;\n\t\tcase \"p\":\n\t\t\treturn (\n\t\t\t\t10 +\n\t\t\t\tpathCost(operation[1]) +\n\t\t\t\tString(operation[2]).length +\n\t\t\t\tString(operation[3]).length +\n\t\t\t\tjsonCost(operation[4])\n\t\t\t);\n\t\tcase \"m\":\n\t\t\treturn 7 + pathCost(operation[1]) + jsonCost(operation[2]);\n\t}\n};\n\n/** Compute a compact operation batch from two immutable JSON revisions. */\nexport function diffRevisions(before: JsonValue, after: JsonValue): Op[] {\n\tconst operations: Op[] = [];\n\tdiffValue(before, after, [], operations);\n\tif (operations.length > MAX_DELTA_OPERATIONS) return [[\"r\", after]];\n\tif (operations.length === 0 || operations[0]?.[0] === \"r\") return operations;\n\tlet deltaCost = 2;\n\tfor (const operation of operations) deltaCost += operationCost(operation) + 1;\n\tif (deltaCost < 65_536) return operations;\n\tconst snapshotCost = jsonCost(after) + 6;\n\treturn deltaCost >= snapshotCost ? [[\"r\", after]] : operations;\n}\n"]}