@earendil-works/chord 0.0.0 → 0.85.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/package.json +70 -70
- package/src/delta/README.md +319 -0
package/package.json
CHANGED
|
@@ -1,72 +1,72 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
2
|
+
"name": "@earendil-works/chord",
|
|
3
|
+
"version": "0.85.0",
|
|
4
|
+
"description": "Application composition runtime for services, replicated state, RPC, and plugins",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"source": "./src/index.ts",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./context": {
|
|
15
|
+
"source": "./src/context/index.ts",
|
|
16
|
+
"types": "./dist/context/index.d.ts",
|
|
17
|
+
"import": "./dist/context/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./delta": {
|
|
20
|
+
"source": "./src/delta/index.ts",
|
|
21
|
+
"types": "./dist/delta/index.d.ts",
|
|
22
|
+
"import": "./dist/delta/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./bundler": {
|
|
25
|
+
"source": "./src/bundler.ts",
|
|
26
|
+
"types": "./dist/bundler.d.ts",
|
|
27
|
+
"import": "./dist/bundler.js"
|
|
28
|
+
},
|
|
29
|
+
"./node": {
|
|
30
|
+
"source": "./src/node.ts",
|
|
31
|
+
"types": "./dist/node.d.ts",
|
|
32
|
+
"import": "./dist/node.js"
|
|
33
|
+
},
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"sideEffects": false,
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"README.md",
|
|
40
|
+
"src/delta/README.md"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"clean": "shx rm -rf dist",
|
|
44
|
+
"build": "tsgo -p tsconfig.build.json",
|
|
45
|
+
"test": "vitest --run",
|
|
46
|
+
"prepublishOnly": "npm run clean && npm run build"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"application",
|
|
50
|
+
"plugins",
|
|
51
|
+
"rpc",
|
|
52
|
+
"services",
|
|
53
|
+
"state"
|
|
54
|
+
],
|
|
55
|
+
"author": "Earendil Works",
|
|
56
|
+
"license": "MIT",
|
|
57
|
+
"repository": {
|
|
58
|
+
"type": "git",
|
|
59
|
+
"url": "git+https://github.com/earendil-works/pi.git",
|
|
60
|
+
"directory": "packages/chord"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=22.19.0"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"esbuild": "0.28.1"
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"shx": "0.4.0",
|
|
70
|
+
"vitest": "4.1.9"
|
|
71
|
+
}
|
|
72
72
|
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# Chord Delta
|
|
2
|
+
|
|
3
|
+
Chord Delta synchronizes JSON values from an authoritative producer to an
|
|
4
|
+
ordered replica. It is available from `@earendil-works/chord/delta`.
|
|
5
|
+
|
|
6
|
+
A change is represented by an `Op`: a JSON tuple for replacing, setting,
|
|
7
|
+
deleting, updating a string, or splicing an array. Producers use `track()`;
|
|
8
|
+
replicas use `apply()` or `applyImmutable()`.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { apply, track } from "@earendil-works/chord/delta";
|
|
12
|
+
|
|
13
|
+
const tracker = track({ output: "", entries: [] as string[] });
|
|
14
|
+
let replica = apply(undefined, tracker.flush());
|
|
15
|
+
|
|
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 the operations needed to transform the previously published
|
|
23
|
+
value into the current value. It returns `[]` when the value has not changed.
|
|
24
|
+
|
|
25
|
+
`applyImmutable()` copies only containers along changed paths and shares
|
|
26
|
+
unchanged subtrees. It does not mutate, clone, or freeze either complete input.
|
|
27
|
+
Chord's replicated-state producers mutate a tracked proxy and publish operation
|
|
28
|
+
batches; consumers still observe complete immutable values.
|
|
29
|
+
|
|
30
|
+
## Sending or storing changes
|
|
31
|
+
|
|
32
|
+
`flush()` produces decoded `Op[]` with complete paths. This is convenient for
|
|
33
|
+
local use but repeats long paths on the wire or disk.
|
|
34
|
+
|
|
35
|
+
`encoder()` compresses those paths and returns `WireOp[]`. `decoder()` validates
|
|
36
|
+
the encoded tuples, restores complete paths, and returns the `Op[]` required by
|
|
37
|
+
`apply()`:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { apply, decoder, encoder, track } from "@earendil-works/chord/delta";
|
|
41
|
+
|
|
42
|
+
const tracker = track({ output: "" });
|
|
43
|
+
const enc = encoder(); // producer side
|
|
44
|
+
const dec = decoder(); // consumer side
|
|
45
|
+
let replica: { output: string } | undefined;
|
|
46
|
+
|
|
47
|
+
const send = () => {
|
|
48
|
+
const ops = tracker.flush();
|
|
49
|
+
const wire = enc.encode(ops); // serialize or store WireOp[] here
|
|
50
|
+
const received = dec.decode(wire);
|
|
51
|
+
replica = apply(replica, received);
|
|
52
|
+
};
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Encoding is optional for local application. Never pass `WireOp[]` directly to
|
|
56
|
+
`apply()`.
|
|
57
|
+
|
|
58
|
+
An encoder and decoder are stateful. Use one pair for each ordered stream. The
|
|
59
|
+
encoder assigns numeric IDs to paths used across batches; the decoder remembers
|
|
60
|
+
the corresponding definitions. A complete-value operation resets both path
|
|
61
|
+
dictionaries, so replay can begin at that batch with a fresh decoder.
|
|
62
|
+
|
|
63
|
+
Path omission is local to one batch. Numeric path IDs may span batches. Each
|
|
64
|
+
independently hydrated replicated-state stream needs its own encoder and decoder.
|
|
65
|
+
Do not share a pair between state members or subscriptions, even when their
|
|
66
|
+
batches use the same ordered transport connection.
|
|
67
|
+
|
|
68
|
+
## Operation vocabulary
|
|
69
|
+
|
|
70
|
+
A path is an array of object keys and array indices:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
["operation", "message", "content", 0, "text"]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Decoded `Op`
|
|
77
|
+
|
|
78
|
+
`track().flush()` returns these tuples, and `apply()` accepts them:
|
|
79
|
+
|
|
80
|
+
| Tuple | Meaning |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| `["r", value]` | Replace the complete value. |
|
|
83
|
+
| `["s", path, value]` | Set a property or array element. |
|
|
84
|
+
| `["d", path]` | Delete an object property. |
|
|
85
|
+
| `["a", path, text]` | Append to a string. |
|
|
86
|
+
| `["t", path, count]` | Remove UTF-16 code units from a string's front. |
|
|
87
|
+
| `["p", path, index, remove, items]` | Splice an array. |
|
|
88
|
+
|
|
89
|
+
Except for `r`, every decoded operation carries its complete path. `s`, `d`,
|
|
90
|
+
`a`, and `t` cannot address the root. `p` may address a root array.
|
|
91
|
+
|
|
92
|
+
### Encoded `WireOp`
|
|
93
|
+
|
|
94
|
+
A `PathRef` is either an inline path or a non-negative numeric path ID.
|
|
95
|
+
`WireOp` supports the following tuples:
|
|
96
|
+
|
|
97
|
+
| Tuple | Meaning |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `["r", value]` | Complete replacement; identical to decoded form. |
|
|
100
|
+
| `["#", id, path]` | Define a numeric path ID. |
|
|
101
|
+
| `["s", pathRef, value]` | Set with an inline or interned path. |
|
|
102
|
+
| `["s", value]` | Set using the previous path in this batch. |
|
|
103
|
+
| `["d", pathRef]` | Delete with an inline or interned path. |
|
|
104
|
+
| `["d"]` | Delete using the previous path. |
|
|
105
|
+
| `["a", pathRef, text]` | Append with an inline or interned path. |
|
|
106
|
+
| `["a", text]` | Append using the previous path. |
|
|
107
|
+
| `["t", pathRef, count]` | Front-truncate with an inline or interned path. |
|
|
108
|
+
| `["t", count]` | Front-truncate using the previous path. |
|
|
109
|
+
| `["p", pathRef, index, remove, items]` | Splice with an inline or interned path. |
|
|
110
|
+
| `["p", index, remove, items]` | Splice using the previous path. |
|
|
111
|
+
|
|
112
|
+
For example, adjacent decoded operations on one path:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
[
|
|
116
|
+
["t", ["output"], 200],
|
|
117
|
+
["a", ["output"], "next chunk"],
|
|
118
|
+
]
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
encode to:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
[
|
|
125
|
+
["t", ["output"], 200],
|
|
126
|
+
["a", "next chunk"], // reuses ["output"]
|
|
127
|
+
]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
When `output` is used again in a later batch, the encoder defines an ID on its
|
|
131
|
+
second explicit use:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
[
|
|
135
|
+
["#", 0, ["output"]],
|
|
136
|
+
["a", 0, "more"],
|
|
137
|
+
]
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Later batches can use `0` directly until a complete-value operation resets the
|
|
141
|
+
dictionary.
|
|
142
|
+
|
|
143
|
+
## Producing changes
|
|
144
|
+
|
|
145
|
+
Read and mutate `tracker.state` as a normal object:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
tracker.state.status = "running";
|
|
149
|
+
tracker.state.settings.theme = "dark";
|
|
150
|
+
tracker.state.messages.push(message);
|
|
151
|
+
delete tracker.state.retry;
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Only the value at flush time is published:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
tracker.state.status = "starting";
|
|
158
|
+
tracker.state.status = "running";
|
|
159
|
+
tracker.flush(); // one set to "running"
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Replacing an object or array is valid. Delta compares its properties and elements
|
|
163
|
+
with the previously published value:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
tracker.state.settings = {
|
|
167
|
+
...plainSettings,
|
|
168
|
+
theme: "dark",
|
|
169
|
+
};
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Unchanged properties produce no operations. Changed nested strings and arrays
|
|
173
|
+
still use string and splice operations.
|
|
174
|
+
|
|
175
|
+
### Strings
|
|
176
|
+
|
|
177
|
+
Appending text produces an `a` operation:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
tracker.state.output += "next line\n";
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Moving a bounded text window forward produces `t` followed by `a` when the old
|
|
184
|
+
suffix matches the new prefix:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
tracker.state.output = tracker.state.output.slice(200) + nextChunk;
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
An unrelated replacement produces `s`.
|
|
191
|
+
|
|
192
|
+
### Arrays
|
|
193
|
+
|
|
194
|
+
Use normal array methods:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
tracker.state.messages.push(first);
|
|
198
|
+
tracker.state.messages.push(second);
|
|
199
|
+
tracker.state.messages.splice(3, 1, replacement);
|
|
200
|
+
```
|
|
201
|
+
|
|
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.
|
|
205
|
+
|
|
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:
|
|
209
|
+
|
|
210
|
+
```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.
|
|
214
|
+
```
|
|
215
|
+
|
|
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.
|
|
219
|
+
|
|
220
|
+
Sparse arrays are unsupported. Writing beyond the next index throws. Increasing
|
|
221
|
+
`length` creates explicit `null` elements; decreasing it removes elements.
|
|
222
|
+
|
|
223
|
+
`fill()` and `copyWithin()` keep normal JavaScript reference semantics. Do not use
|
|
224
|
+
them to place one mutable object at multiple live paths.
|
|
225
|
+
|
|
226
|
+
### Optional properties
|
|
227
|
+
|
|
228
|
+
Optional object properties use absence. They do not require `null`:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
type Settings = { label?: string; count: number };
|
|
232
|
+
const tracker = track<Settings>({ count: 0 });
|
|
233
|
+
|
|
234
|
+
tracker.state.label = "active";
|
|
235
|
+
tracker.state.label = undefined; // produces d
|
|
236
|
+
// `delete tracker.state.label` is equivalent
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`undefined` is accepted only as assignment syntax for deleting an object
|
|
240
|
+
property. It is not a JSON value. Initial and assigned objects cannot contain own
|
|
241
|
+
`undefined` values, and array elements cannot be `undefined`. Use `null` when an
|
|
242
|
+
array position or explicit empty value must remain present.
|
|
243
|
+
|
|
244
|
+
## State ownership
|
|
245
|
+
|
|
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:
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
const item = { status: "new" };
|
|
255
|
+
tracker.state.item = item;
|
|
256
|
+
|
|
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
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
The same restriction applies across separate array calls:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
tracker.state.items.push(item);
|
|
266
|
+
tracker.state.items.push(item); // unsupported alias
|
|
267
|
+
```
|
|
268
|
+
|
|
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.
|
|
272
|
+
|
|
273
|
+
Tracked state must be a mutable JSON tree:
|
|
274
|
+
|
|
275
|
+
- strings, booleans, finite numbers, `null`, arrays, and plain objects;
|
|
276
|
+
- no cycles or one mutable object stored at multiple locations;
|
|
277
|
+
- no sparse arrays, accessors, frozen objects, symbols, classes, functions,
|
|
278
|
+
`Map`, or `Set`.
|
|
279
|
+
|
|
280
|
+
Do not keep a child proxy across an array operation that changes indices. Read
|
|
281
|
+
the child again from its new index.
|
|
282
|
+
|
|
283
|
+
## Tracker lifecycle
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
tracker.flush(); // publish changes since the previous flush
|
|
287
|
+
tracker.rebase(); // make the next flush a complete replacement
|
|
288
|
+
tracker.discard(); // accept current changes without publishing them
|
|
289
|
+
tracker.state = replacement; // replace the root; next flush is complete
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
`discard()` intentionally prevents current changes from reaching existing
|
|
293
|
+
replicas. Use it only when those replicas do not need the discarded changes.
|
|
294
|
+
|
|
295
|
+
`apply()` adopts object and array payloads from its input batch. Do not freeze a
|
|
296
|
+
batch before applying it, and do not apply one in-memory batch to multiple
|
|
297
|
+
mutable replicas unless each replica owns that batch. A serialized and decoded
|
|
298
|
+
batch is already detached. `applyImmutable()` instead treats its previous value
|
|
299
|
+
and operation payloads as immutable, so one batch can safely fan out in-process.
|
|
300
|
+
|
|
301
|
+
A `decode()`, `apply()`, or `applyImmutable()` error terminates that stream.
|
|
302
|
+
Discard its decoder and replica, then recover from a later base batch. `apply()`
|
|
303
|
+
is not transactional; operations before the failing operation may already have
|
|
304
|
+
changed the replica.
|
|
305
|
+
|
|
306
|
+
## Limits
|
|
307
|
+
|
|
308
|
+
- Delta assumes one authoritative writer and ordered delivery. Sequence numbers,
|
|
309
|
+
gap detection, retries, and persistence policy belong to the surrounding
|
|
310
|
+
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.
|
|
313
|
+
- Object key insertion order is not replicated. Do not compare or hash replicas
|
|
314
|
+
using serialized key order.
|
|
315
|
+
- Array operations that change indices may publish a wider array region, as
|
|
316
|
+
described under Arrays.
|
|
317
|
+
- Object-valued keys named `__proto__`, `constructor`, or `prototype` can be read
|
|
318
|
+
and serialized, but cannot be mutated through that key. Replace the nearest
|
|
319
|
+
ordinarily named parent instead.
|