@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.
Files changed (2) hide show
  1. package/package.json +70 -70
  2. package/src/delta/README.md +319 -0
package/package.json CHANGED
@@ -1,72 +1,72 @@
1
1
  {
2
- "name": "@earendil-works/chord",
3
- "version": "0.0.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
- }
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.