@earendil-works/chord 0.87.0 → 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.
- package/README.md +80 -27
- package/dist/api.d.ts +6 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +8 -3
- package/dist/api.js.map +1 -1
- package/dist/bundler.d.ts.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js.map +1 -1
- package/dist/delta/apply-immutable-trusted.d.ts +4 -0
- package/dist/delta/apply-immutable-trusted.d.ts.map +1 -0
- package/dist/delta/apply-immutable-trusted.js +127 -0
- package/dist/delta/apply-immutable-trusted.js.map +1 -0
- package/dist/{state → delta}/diff.d.ts +1 -1
- package/dist/delta/diff.d.ts.map +1 -0
- package/dist/delta/diff.js +433 -0
- package/dist/delta/diff.js.map +1 -0
- package/dist/delta/draft.d.ts +5 -0
- package/dist/delta/draft.d.ts.map +1 -0
- package/dist/delta/draft.js +2 -0
- package/dist/delta/draft.js.map +1 -0
- package/dist/delta/index.d.ts +11 -21
- package/dist/delta/index.d.ts.map +1 -1
- package/dist/delta/index.js +43 -1341
- package/dist/delta/index.js.map +1 -1
- package/dist/delta/revision-validator.d.ts +7 -0
- package/dist/delta/revision-validator.d.ts.map +1 -0
- package/dist/delta/revision-validator.js +71 -0
- package/dist/delta/revision-validator.js.map +1 -0
- package/dist/delta/tracker.d.ts +26 -0
- package/dist/delta/tracker.d.ts.map +1 -0
- package/dist/delta/tracker.js +2069 -0
- package/dist/delta/tracker.js.map +1 -0
- package/dist/facets/host.d.ts.map +1 -1
- package/dist/facets/host.js.map +1 -1
- package/dist/facets/loader.d.ts.map +1 -1
- package/dist/facets/loader.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/json.d.ts +6 -0
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +73 -6
- package/dist/json.js.map +1 -1
- package/dist/node/bundle-loader.d.ts.map +1 -1
- package/dist/node/bundle-loader.js.map +1 -1
- package/dist/node/bundle.d.ts.map +1 -1
- package/dist/node/bundle.js.map +1 -1
- package/dist/node/manifest.d.ts.map +1 -1
- package/dist/node/package.d.ts.map +1 -1
- package/dist/node/package.js.map +1 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/services/consumer.d.ts.map +1 -1
- package/dist/services/consumer.js +49 -1
- package/dist/services/consumer.js.map +1 -1
- package/dist/services/errors.d.ts.map +1 -1
- package/dist/services/errors.js.map +1 -1
- package/dist/services/handle.d.ts.map +1 -1
- package/dist/services/handle.js +3 -1
- package/dist/services/handle.js.map +1 -1
- package/dist/services/instances.d.ts +1 -0
- package/dist/services/instances.d.ts.map +1 -1
- package/dist/services/instances.js +3 -0
- package/dist/services/instances.js.map +1 -1
- package/dist/services/loopback.d.ts.map +1 -1
- package/dist/services/loopback.js.map +1 -1
- package/dist/services/provider.d.ts.map +1 -1
- package/dist/services/provider.js +48 -29
- package/dist/services/provider.js.map +1 -1
- package/dist/services/state-codec.d.ts.map +1 -1
- package/dist/services/state-codec.js +18 -0
- package/dist/services/state-codec.js.map +1 -1
- package/dist/services/state-internals.d.ts +5 -2
- package/dist/services/state-internals.d.ts.map +1 -1
- package/dist/services/state-internals.js.map +1 -1
- package/dist/services/state.d.ts +8 -3
- package/dist/services/state.d.ts.map +1 -1
- package/dist/services/state.js +288 -88
- package/dist/services/state.js.map +1 -1
- package/dist/services/wire.d.ts +3 -0
- package/dist/services/wire.d.ts.map +1 -1
- package/dist/services/wire.js +13 -0
- package/dist/services/wire.js.map +1 -1
- package/dist/types.d.ts +74 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +3 -3
- package/src/delta/README.md +143 -360
- package/dist/state/diff.d.ts.map +0 -1
- package/dist/state/diff.js +0 -259
- package/dist/state/diff.js.map +0 -1
- package/dist/state/draft.d.ts +0 -13
- package/dist/state/draft.d.ts.map +0 -1
- package/dist/state/draft.js +0 -540
- package/dist/state/draft.js.map +0 -1
- package/dist/state/value.d.ts +0 -9
- package/dist/state/value.d.ts.map +0 -1
- package/dist/state/value.js +0 -175
- package/dist/state/value.js.map +0 -1
package/src/delta/README.md
CHANGED
|
@@ -1,408 +1,191 @@
|
|
|
1
1
|
# Chord Delta
|
|
2
2
|
|
|
3
|
-
Chord Delta
|
|
4
|
-
ordered
|
|
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
|
-
|
|
7
|
-
|
|
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 {
|
|
10
|
+
import { applyImmutable, applyImmutableBatches, track } from "@earendil-works/chord/delta";
|
|
12
11
|
|
|
13
|
-
const
|
|
14
|
-
|
|
12
|
+
const initial = { output: "", entries: [] as { id: number }[] };
|
|
13
|
+
const tracker = track(initial); // `initial` is transferred: never mutate it again
|
|
15
14
|
|
|
16
|
-
tracker.
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
|
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
|
|
|
111
|
-
|
|
|
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
|
-
|
|
183
|
-
still use string and splice operations.
|
|
42
|
+
## Replaying batches
|
|
184
43
|
|
|
185
|
-
|
|
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.
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
58
|
+
replica = applyImmutableBatches(
|
|
59
|
+
replica,
|
|
60
|
+
queuedFrames.map((frame) => frame.ops),
|
|
61
|
+
);
|
|
198
62
|
```
|
|
199
63
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
### Arrays
|
|
64
|
+
Use separate `applyImmutable()` calls when every intermediate revision is
|
|
65
|
+
published or retained.
|
|
203
66
|
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
+
## Lifecycle
|
|
287
125
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
|
|
312
|
-
item.status = "broken"; // NOT tracked: silently diverges from the replica
|
|
313
|
-
```
|
|
153
|
+
## Operations
|
|
314
154
|
|
|
315
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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.
|
package/dist/state/diff.d.ts.map
DELETED
|
@@ -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"]}
|