@loro-extended/change 3.0.0 → 5.0.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 +289 -150
- package/dist/index.d.ts +1012 -310
- package/dist/index.js +1334 -568
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/change.test.ts +51 -52
- package/src/functional-helpers.test.ts +316 -4
- package/src/functional-helpers.ts +96 -6
- package/src/grand-unified-api.test.ts +35 -29
- package/src/index.ts +27 -1
- package/src/json-patch.test.ts +46 -27
- package/src/loro.test.ts +449 -0
- package/src/loro.ts +273 -0
- package/src/overlay-recursion.test.ts +1 -1
- package/src/overlay.ts +62 -3
- package/src/path-evaluator.ts +1 -1
- package/src/path-selector.test.ts +94 -1
- package/src/shape.ts +107 -14
- package/src/typed-doc.ts +100 -98
- package/src/typed-refs/base.ts +126 -46
- package/src/typed-refs/counter-ref-internals.ts +62 -0
- package/src/typed-refs/{counter.test.ts → counter-ref.test.ts} +5 -4
- package/src/typed-refs/counter-ref.ts +45 -0
- package/src/typed-refs/{doc.ts → doc-ref-internals.ts} +33 -56
- package/src/typed-refs/doc-ref.ts +47 -0
- package/src/typed-refs/encapsulation.test.ts +226 -0
- package/src/typed-refs/list-ref-base-internals.ts +280 -0
- package/src/typed-refs/list-ref-base.ts +518 -0
- package/src/typed-refs/list-ref-internals.ts +21 -0
- package/src/typed-refs/list-ref-value-updates.test.ts +213 -0
- package/src/typed-refs/{list.ts → list-ref.ts} +10 -11
- package/src/typed-refs/movable-list-ref-internals.ts +38 -0
- package/src/typed-refs/movable-list-ref.ts +31 -0
- package/src/typed-refs/proxy-handlers.ts +13 -4
- package/src/typed-refs/record-ref-internals.ts +216 -0
- package/src/typed-refs/record-ref-value-updates.test.ts +214 -0
- package/src/typed-refs/{record.test.ts → record-ref.test.ts} +21 -16
- package/src/typed-refs/record-ref.ts +80 -0
- package/src/typed-refs/struct-ref-internals.ts +195 -0
- package/src/typed-refs/struct-ref.test.ts +202 -0
- package/src/typed-refs/struct-ref.ts +257 -0
- package/src/typed-refs/text-ref-internals.ts +100 -0
- package/src/typed-refs/text-ref.ts +72 -0
- package/src/typed-refs/tree-node-ref-internals.ts +111 -0
- package/src/typed-refs/tree-node-ref.test.ts +234 -0
- package/src/typed-refs/tree-node-ref.ts +200 -0
- package/src/typed-refs/tree-node.test.ts +384 -0
- package/src/typed-refs/tree-ref-internals.ts +110 -0
- package/src/typed-refs/tree-ref.ts +194 -0
- package/src/typed-refs/utils.ts +38 -17
- package/src/types.ts +36 -1
- package/src/utils/type-guards.ts +1 -0
- package/src/typed-refs/counter.ts +0 -64
- package/src/typed-refs/list-base.ts +0 -424
- package/src/typed-refs/movable-list.ts +0 -34
- package/src/typed-refs/record.ts +0 -220
- package/src/typed-refs/struct.ts +0 -206
- package/src/typed-refs/text.ts +0 -97
- package/src/typed-refs/tree.ts +0 -40
- /package/src/typed-refs/{list.test.ts → list-ref.test.ts} +0 -0
- /package/src/typed-refs/{movable-list.test.ts → movable-list-ref.test.ts} +0 -0
package/README.md
CHANGED
|
@@ -12,10 +12,10 @@ Working with Loro directly involves somewhat verbose container operations and co
|
|
|
12
12
|
|
|
13
13
|
- **Schema-First Design**: Define your document structure with type-safe schemas
|
|
14
14
|
- **Natural Syntax**: Write `doc.title.insert(0, "Hello")` instead of verbose CRDT operations
|
|
15
|
-
- **
|
|
15
|
+
- **Placeholders**: Seamlessly blend default values with CRDT state
|
|
16
16
|
- **Full Type Safety**: Complete TypeScript support with compile-time validation
|
|
17
|
-
- **Transactional Changes**: All mutations within a
|
|
18
|
-
- **Loro Compatible**: Works seamlessly with existing Loro code (`doc
|
|
17
|
+
- **Transactional Changes**: All mutations within a `change()` block are atomic
|
|
18
|
+
- **Loro Compatible**: Works seamlessly with existing Loro code (`loro(doc).doc` is a familiar `LoroDoc`)
|
|
19
19
|
|
|
20
20
|
## Installation
|
|
21
21
|
|
|
@@ -59,7 +59,7 @@ if ("alice" in doc.users) {
|
|
|
59
59
|
|
|
60
60
|
// Batched mutations - commit together (optional, for performance)
|
|
61
61
|
// Using functional helper (recommended)
|
|
62
|
-
change(
|
|
62
|
+
doc.change((draft) => {
|
|
63
63
|
draft.title.insert(0, "Change: ");
|
|
64
64
|
draft.count.increment(10);
|
|
65
65
|
draft.users.set("bob", { name: "Bob" });
|
|
@@ -85,7 +85,7 @@ import { Shape } from "@loro-extended/change";
|
|
|
85
85
|
const blogSchema = Shape.doc({
|
|
86
86
|
// CRDT containers for collaborative editing
|
|
87
87
|
title: Shape.text(), // Collaborative text
|
|
88
|
-
viewCount: Shape.counter(), //
|
|
88
|
+
viewCount: Shape.counter(), // Collaborative increment/decrement counter
|
|
89
89
|
|
|
90
90
|
// Lists for ordered data
|
|
91
91
|
tags: Shape.list(Shape.plain.string()), // List of strings
|
|
@@ -110,9 +110,20 @@ const blogSchema = Shape.doc({
|
|
|
110
110
|
|
|
111
111
|
**NOTE:** Use `Shape.*` for collaborative containers and `Shape.plain.*` for plain values. Only put plain values inside Loro containers - a Loro container inside a plain JS struct or array won't work.
|
|
112
112
|
|
|
113
|
-
### Empty State Overlay
|
|
113
|
+
### Placeholders (Empty State Overlay)
|
|
114
|
+
|
|
115
|
+
Placeholders provide default values that are merged when CRDT containers are empty, ensuring the entire document remains type-safe even before any data has been written.
|
|
116
|
+
|
|
117
|
+
#### Why placeholders matter in distributed systems:
|
|
114
118
|
|
|
115
|
-
|
|
119
|
+
In traditional client-server architectures, you typically have a single source of truth that initializes default values. But in CRDTs, multiple peers can start working independently without coordination. This creates a challenge: who initializes the defaults?
|
|
120
|
+
|
|
121
|
+
Placeholders solve this elegantly:
|
|
122
|
+
|
|
123
|
+
- No initialization race conditions - Every peer sees the same defaults without needing to coordinate who writes them first
|
|
124
|
+
- Zero-cost defaults - Placeholders aren't stored in the CRDT; they're computed on read. This means no wasted storage or sync bandwidth for default values
|
|
125
|
+
- Conflict-free - Since placeholders aren't written to the CRDT, there's no possibility of conflicts between peers trying to initialize the same field
|
|
126
|
+
- Lazy materialization - Defaults only become "real" CRDT data when a peer explicitly modifies them
|
|
116
127
|
|
|
117
128
|
```typescript
|
|
118
129
|
// Use .placeholder() to set default values
|
|
@@ -141,7 +152,7 @@ console.log(doc.toJSON());
|
|
|
141
152
|
// { title: "Untitled Document", viewCount: 0, ... }
|
|
142
153
|
|
|
143
154
|
// After changes, CRDT values take priority over empty state
|
|
144
|
-
change(
|
|
155
|
+
doc.change((draft) => {
|
|
145
156
|
draft.title.insert(0, "My Blog Post");
|
|
146
157
|
draft.viewCount.increment(10);
|
|
147
158
|
});
|
|
@@ -153,7 +164,7 @@ console.log(doc.toJSON());
|
|
|
153
164
|
|
|
154
165
|
### Direct Mutations vs Batched Mutations
|
|
155
166
|
|
|
156
|
-
|
|
167
|
+
You can access and write schema properties directly on a TypedDoc. Mutations commit immediately by default:
|
|
157
168
|
|
|
158
169
|
```typescript
|
|
159
170
|
// Direct mutations - each commits immediately
|
|
@@ -165,9 +176,7 @@ doc.tags.push("typescript");
|
|
|
165
176
|
For batched operations (better performance, atomic undo), use `change()`:
|
|
166
177
|
|
|
167
178
|
```typescript
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
change(doc, (draft) => {
|
|
179
|
+
doc.change((draft) => {
|
|
171
180
|
// Text operations
|
|
172
181
|
draft.title.insert(0, "📝");
|
|
173
182
|
draft.title.delete(5, 3);
|
|
@@ -181,9 +190,9 @@ change(doc, (draft) => {
|
|
|
181
190
|
draft.tags.insert(0, "loro");
|
|
182
191
|
draft.tags.delete(1, 1);
|
|
183
192
|
|
|
184
|
-
// Struct operations (
|
|
185
|
-
draft.metadata.
|
|
186
|
-
draft.metadata.
|
|
193
|
+
// Struct operations (property assignment)
|
|
194
|
+
draft.metadata.author = "John Doe";
|
|
195
|
+
delete draft.metadata.featured;
|
|
187
196
|
|
|
188
197
|
// Movable list operations
|
|
189
198
|
draft.sections.push({
|
|
@@ -195,7 +204,6 @@ change(doc, (draft) => {
|
|
|
195
204
|
});
|
|
196
205
|
|
|
197
206
|
// All changes are committed atomically as one transaction
|
|
198
|
-
// change() returns the doc for chaining
|
|
199
207
|
console.log(doc.toJSON()); // Updated document state
|
|
200
208
|
```
|
|
201
209
|
|
|
@@ -209,8 +217,6 @@ console.log(doc.toJSON()); // Updated document state
|
|
|
209
217
|
| Performance-critical bulk updates | Batched: `change(doc, d => { ... })` |
|
|
210
218
|
| Simple reads + writes | Direct: `doc.users.set(...)` |
|
|
211
219
|
|
|
212
|
-
> **Note:** The `$.change()` method is available as an escape hatch, but the functional `change()` helper is recommended for cleaner code.
|
|
213
|
-
|
|
214
220
|
## Advanced Usage
|
|
215
221
|
|
|
216
222
|
### Discriminated Unions
|
|
@@ -218,12 +224,12 @@ console.log(doc.toJSON()); // Updated document state
|
|
|
218
224
|
For type-safe tagged unions (like different message types or presence states), use `Shape.plain.discriminatedUnion()`:
|
|
219
225
|
|
|
220
226
|
```typescript
|
|
221
|
-
import { Shape
|
|
227
|
+
import { Shape } from "@loro-extended/change";
|
|
222
228
|
|
|
223
229
|
// Define variant shapes - each must have the discriminant key
|
|
224
230
|
const ClientPresenceShape = Shape.plain.struct({
|
|
225
231
|
type: Shape.plain.string("client"), // Literal type for discrimination
|
|
226
|
-
name: Shape.plain.string(),
|
|
232
|
+
name: Shape.plain.string().placeholder("Anonymous"),
|
|
227
233
|
input: Shape.plain.struct({
|
|
228
234
|
force: Shape.plain.number(),
|
|
229
235
|
angle: Shape.plain.number(),
|
|
@@ -247,26 +253,8 @@ const GamePresenceSchema = Shape.plain.discriminatedUnion("type", {
|
|
|
247
253
|
server: ServerPresenceShape,
|
|
248
254
|
});
|
|
249
255
|
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
type: "client" as const,
|
|
253
|
-
name: "",
|
|
254
|
-
input: { force: 0, angle: 0 },
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
const EmptyServerPresence = {
|
|
258
|
-
type: "server" as const,
|
|
259
|
-
cars: {},
|
|
260
|
-
tick: 0,
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
// Use with mergeValue for presence data
|
|
264
|
-
const crdtValue = { type: "client", name: "Alice" };
|
|
265
|
-
const result = mergeValue(GamePresenceSchema, crdtValue, EmptyClientPresence);
|
|
266
|
-
// Result: { type: "client", name: "Alice", input: { force: 0, angle: 0 } }
|
|
267
|
-
|
|
268
|
-
// Type-safe filtering
|
|
269
|
-
function handlePresence(presence: typeof result) {
|
|
256
|
+
// Type-safe handling based on discriminant
|
|
257
|
+
function handlePresence(presence: Infer<typeof GamePresenceSchema>) {
|
|
270
258
|
if (presence.type === "server") {
|
|
271
259
|
// TypeScript knows this is ServerPresence
|
|
272
260
|
console.log(presence.cars, presence.tick);
|
|
@@ -279,9 +267,9 @@ function handlePresence(presence: typeof result) {
|
|
|
279
267
|
|
|
280
268
|
**Key features:**
|
|
281
269
|
|
|
282
|
-
- The discriminant
|
|
283
|
-
-
|
|
284
|
-
- Works seamlessly with `@loro-extended/
|
|
270
|
+
- The discriminant (e.g., `"type"`) determines which variant shape to use
|
|
271
|
+
- Use `.placeholder()` on fields to provide defaults (placeholders are applied automatically)
|
|
272
|
+
- Works seamlessly with `@loro-extended/repo`'s presence system
|
|
285
273
|
- Full TypeScript support for discriminated union types
|
|
286
274
|
|
|
287
275
|
### Untyped Integration with External Libraries
|
|
@@ -305,18 +293,7 @@ const CursorPresenceSchema = Shape.plain.struct({
|
|
|
305
293
|
|
|
306
294
|
// With @loro-extended/repo:
|
|
307
295
|
|
|
308
|
-
//
|
|
309
|
-
const handle = repo.get(docId, Shape.any(), { presence: CursorPresenceSchema });
|
|
310
|
-
handle.loroDoc; // Raw LoroDoc - use Loro API directly
|
|
311
|
-
handle.loroDoc.getMap("anything").set("key", "value");
|
|
312
|
-
handle.presence.setSelf({
|
|
313
|
-
// Typed presence
|
|
314
|
-
anchor: cursor.encode(), // Uint8Array directly
|
|
315
|
-
focus: null,
|
|
316
|
-
user: { name: "Alice", color: "#ff0000" },
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
// Option 2: Shape.any() in a container - one container is untyped
|
|
296
|
+
// Shape.any() in a container - one container is untyped
|
|
320
297
|
const ProseMirrorDocShape = Shape.doc({
|
|
321
298
|
doc: Shape.any(), // loro-prosemirror manages this
|
|
322
299
|
metadata: Shape.struct({
|
|
@@ -324,7 +301,9 @@ const ProseMirrorDocShape = Shape.doc({
|
|
|
324
301
|
title: Shape.text(),
|
|
325
302
|
}),
|
|
326
303
|
});
|
|
327
|
-
const handle2 = repo.get(docId, ProseMirrorDocShape, {
|
|
304
|
+
const handle2 = repo.get(docId, ProseMirrorDocShape, {
|
|
305
|
+
presence: CursorPresenceSchema,
|
|
306
|
+
});
|
|
328
307
|
handle2.doc.toJSON(); // { doc: unknown, metadata: { title: string } }
|
|
329
308
|
```
|
|
330
309
|
|
|
@@ -337,13 +316,13 @@ handle2.doc.toJSON(); // { doc: unknown, metadata: { title: string } }
|
|
|
337
316
|
|
|
338
317
|
**When to use:**
|
|
339
318
|
|
|
340
|
-
| Scenario
|
|
341
|
-
|
|
|
342
|
-
| External library manages entire document
|
|
343
|
-
| External library manages one container
|
|
344
|
-
| Flexible metadata in presence
|
|
345
|
-
| Binary cursor/selection data
|
|
346
|
-
| Full type safety
|
|
319
|
+
| Scenario | Shape to Use |
|
|
320
|
+
| ---------------------------------------- | ------------------------------------------------------------ |
|
|
321
|
+
| External library manages entire document | `repo.get(docId, Shape.any(), { presence: presenceSchema })` |
|
|
322
|
+
| External library manages one container | `Shape.doc({ doc: Shape.any(), ... })` |
|
|
323
|
+
| Flexible metadata in presence | `Shape.plain.any()` for dynamic values |
|
|
324
|
+
| Binary cursor/selection data | `Shape.plain.bytes().nullable()` for `Uint8Array` \| `null` |
|
|
325
|
+
| Full type safety | Use specific shapes like `Shape.struct()`, `Shape.text()` |
|
|
347
326
|
|
|
348
327
|
### Nested Structures
|
|
349
328
|
|
|
@@ -356,29 +335,16 @@ const complexSchema = Shape.doc({
|
|
|
356
335
|
metadata: Shape.struct({
|
|
357
336
|
views: Shape.counter(),
|
|
358
337
|
author: Shape.struct({
|
|
359
|
-
name: Shape.plain.string(),
|
|
338
|
+
name: Shape.plain.string().placeholder("Anonymous),
|
|
360
339
|
email: Shape.plain.string(),
|
|
361
340
|
}),
|
|
362
341
|
}),
|
|
363
342
|
}),
|
|
364
343
|
});
|
|
365
344
|
|
|
366
|
-
const emptyState = {
|
|
367
|
-
article: {
|
|
368
|
-
title: "",
|
|
369
|
-
metadata: {
|
|
370
|
-
views: 0,
|
|
371
|
-
author: {
|
|
372
|
-
name: "Anonymous",
|
|
373
|
-
email: "",
|
|
374
|
-
},
|
|
375
|
-
},
|
|
376
|
-
},
|
|
377
|
-
};
|
|
378
|
-
|
|
379
345
|
const doc = createTypedDoc(complexSchema);
|
|
380
346
|
|
|
381
|
-
change(
|
|
347
|
+
doc.change((draft) => {
|
|
382
348
|
draft.article.title.insert(0, "Deep Nesting Example");
|
|
383
349
|
draft.article.metadata.views.increment(5);
|
|
384
350
|
draft.article.metadata.author.name = "Alice"; // plain string update is captured and applied after closure
|
|
@@ -427,7 +393,7 @@ const collaborativeSchema = Shape.doc({
|
|
|
427
393
|
),
|
|
428
394
|
});
|
|
429
395
|
|
|
430
|
-
change(
|
|
396
|
+
doc.change((draft) => {
|
|
431
397
|
// Push creates and configures nested containers automatically
|
|
432
398
|
draft.articles.push({
|
|
433
399
|
title: "Collaborative Article",
|
|
@@ -440,15 +406,14 @@ change(doc, (draft) => {
|
|
|
440
406
|
});
|
|
441
407
|
|
|
442
408
|
// Later, edit the collaborative parts
|
|
443
|
-
|
|
444
|
-
draft.articles.
|
|
445
|
-
draft.articles.get(0)?.tags.push("real-time");
|
|
409
|
+
draft.articles.[0]?.title.insert(0, "✨ ");
|
|
410
|
+
draft.articles.[0]?.tags.push("real-time");
|
|
446
411
|
});
|
|
447
412
|
```
|
|
448
413
|
|
|
449
414
|
## Path Selector DSL
|
|
450
415
|
|
|
451
|
-
The `@loro-extended/change` package exports a type-safe path selector DSL for building (a subset of) JSONPath expressions with full TypeScript type inference. This is primarily used by `
|
|
416
|
+
The `@loro-extended/change` package exports a type-safe path selector DSL for building (a subset of) JSONPath expressions with full TypeScript type inference. This is primarily used by `handle.subscribe()` in `@loro-extended/repo` for efficient, type-safe subscriptions:
|
|
452
417
|
|
|
453
418
|
```typescript
|
|
454
419
|
// In @loro-extended/repo, use with Handle.subscribe():
|
|
@@ -486,67 +451,124 @@ const doc = createTypedDoc(schema);
|
|
|
486
451
|
const docFromExisting = createTypedDoc(schema, existingLoroDoc);
|
|
487
452
|
```
|
|
488
453
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
Constructor-style API. Use `createTypedDoc()` instead for cleaner code.
|
|
492
|
-
|
|
493
|
-
```typescript
|
|
494
|
-
// Deprecated - use createTypedDoc() instead
|
|
495
|
-
const doc = new TypedDoc(schema);
|
|
496
|
-
```
|
|
497
|
-
|
|
498
|
-
### Functional Helpers (Recommended)
|
|
454
|
+
### The `loro()` Escape Hatch
|
|
499
455
|
|
|
500
|
-
|
|
456
|
+
The `loro()` function provides access to CRDT internals and container-specific operations. It follows a simple design principle:
|
|
501
457
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
Batches multiple mutations into a single transaction. Returns the doc for chaining.
|
|
458
|
+
> **If it takes a plain JavaScript value, keep it on the ref.** > **If it takes a Loro container or exposes CRDT internals, use `loro()`.**
|
|
505
459
|
|
|
506
460
|
```typescript
|
|
507
|
-
import {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
//
|
|
515
|
-
|
|
461
|
+
import { loro } from "@loro-extended/change";
|
|
462
|
+
|
|
463
|
+
// Access underlying Loro primitives
|
|
464
|
+
loro(ref).doc; // LoroDoc
|
|
465
|
+
loro(ref).container; // LoroList, LoroMap, etc. (correctly typed)
|
|
466
|
+
loro(ref).subscribe(cb); // Subscribe to changes
|
|
467
|
+
|
|
468
|
+
// Container operations (take Loro containers, not plain values)
|
|
469
|
+
loro(list).pushContainer(loroMap);
|
|
470
|
+
loro(list).insertContainer(0, loroMap);
|
|
471
|
+
loro(struct).setContainer("key", loroMap);
|
|
472
|
+
loro(record).setContainer("key", loroMap);
|
|
473
|
+
|
|
474
|
+
// TypedDoc operations
|
|
475
|
+
loro(doc).doc; // Raw LoroDoc access
|
|
476
|
+
loro(doc).applyPatch(patch); // JSON Patch operations
|
|
477
|
+
loro(doc).docShape; // Schema access
|
|
478
|
+
loro(doc).rawValue; // Unmerged CRDT value
|
|
516
479
|
```
|
|
517
480
|
|
|
518
|
-
####
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
481
|
+
#### API Surface by Ref Type
|
|
482
|
+
|
|
483
|
+
**ListRef / MovableListRef**
|
|
484
|
+
|
|
485
|
+
| Direct Access | Only via `loro()` |
|
|
486
|
+
| ---------------------- | ----------------------------------- |
|
|
487
|
+
| `push(item)` | `pushContainer(container)` |
|
|
488
|
+
| `insert(index, item)` | `insertContainer(index, container)` |
|
|
489
|
+
| `delete(index, len)` | `subscribe(callback)` |
|
|
490
|
+
| `find(predicate)` | `doc` |
|
|
491
|
+
| `filter(predicate)` | `container` |
|
|
492
|
+
| `map(callback)` | |
|
|
493
|
+
| `forEach(callback)` | |
|
|
494
|
+
| `some(predicate)` | |
|
|
495
|
+
| `every(predicate)` | |
|
|
496
|
+
| `slice(start, end)` | |
|
|
497
|
+
| `findIndex(predicate)` | |
|
|
498
|
+
| `length`, `[index]` | |
|
|
499
|
+
| `toJSON()` | |
|
|
500
|
+
|
|
501
|
+
**StructRef**
|
|
502
|
+
|
|
503
|
+
| Direct Access | Only via `loro()` |
|
|
504
|
+
| ---------------------------- | ------------------------------ |
|
|
505
|
+
| `obj.property` (get) | `setContainer(key, container)` |
|
|
506
|
+
| `obj.property = value` (set) | `subscribe(callback)` |
|
|
507
|
+
| `Object.keys(obj)` | `doc` |
|
|
508
|
+
| `'key' in obj` | `container` |
|
|
509
|
+
| `delete obj.key` | |
|
|
510
|
+
| `toJSON()` | |
|
|
511
|
+
|
|
512
|
+
**RecordRef** (Map-like interface)
|
|
513
|
+
|
|
514
|
+
| Direct Access | Only via `loro()` |
|
|
515
|
+
| -------------------- | ------------------------------ |
|
|
516
|
+
| `get(key)` | `setContainer(key, container)` |
|
|
517
|
+
| `set(key, value)` | `subscribe(callback)` |
|
|
518
|
+
| `delete(key)` | `doc` |
|
|
519
|
+
| `has(key)` | `container` |
|
|
520
|
+
| `keys()`, `values()` | |
|
|
521
|
+
| `size` | |
|
|
522
|
+
| `toJSON()` | |
|
|
523
|
+
|
|
524
|
+
**TextRef**
|
|
525
|
+
|
|
526
|
+
| Direct Access | Only via `loro()` |
|
|
527
|
+
| -------------------------------- | --------------------- |
|
|
528
|
+
| `insert(index, content)` | `subscribe(callback)` |
|
|
529
|
+
| `delete(index, len)` | `doc` |
|
|
530
|
+
| `update(text)` | `container` |
|
|
531
|
+
| `mark(range, key, value)` | |
|
|
532
|
+
| `unmark(range, key)` | |
|
|
533
|
+
| `toDelta()`, `applyDelta(delta)` | |
|
|
534
|
+
| `toString()`, `valueOf()` | |
|
|
535
|
+
| `length`, `toJSON()` | |
|
|
536
|
+
|
|
537
|
+
**CounterRef**
|
|
538
|
+
|
|
539
|
+
| Direct Access | Only via `loro()` |
|
|
540
|
+
| -------------------- | --------------------- |
|
|
541
|
+
| `increment(value)` | `subscribe(callback)` |
|
|
542
|
+
| `decrement(value)` | `doc` |
|
|
543
|
+
| `value`, `valueOf()` | `container` |
|
|
544
|
+
| `toJSON()` | |
|
|
545
|
+
|
|
546
|
+
**TypedDoc**
|
|
547
|
+
|
|
548
|
+
| Direct Access | Only via `loro()` |
|
|
549
|
+
| ------------------------------ | --------------------- |
|
|
550
|
+
| `doc.property` (schema access) | `doc` (raw LoroDoc) |
|
|
551
|
+
| `toJSON()` | `subscribe(callback)` |
|
|
552
|
+
| `change(fn)` | `applyPatch(patch)` |
|
|
553
|
+
| | `docShape` |
|
|
554
|
+
| | `rawValue` |
|
|
555
|
+
|
|
556
|
+
### Subscribing to Ref Changes
|
|
557
|
+
|
|
558
|
+
The `loro()` function enables the "pass around a ref" pattern where components can receive a ref and subscribe to its changes without needing the full document:
|
|
530
559
|
|
|
531
560
|
```typescript
|
|
532
|
-
import {
|
|
533
|
-
|
|
534
|
-
const loroDoc = getLoroDoc(doc);
|
|
535
|
-
loroDoc.subscribe((event) => console.log("Changed:", event));
|
|
536
|
-
```
|
|
561
|
+
import { loro } from "@loro-extended/change";
|
|
537
562
|
|
|
538
|
-
|
|
563
|
+
function TextEditor({ textRef }: { textRef: TextRef }) {
|
|
564
|
+
useEffect(() => {
|
|
565
|
+
return loro(textRef).subscribe((event) => {
|
|
566
|
+
// Handle text changes
|
|
567
|
+
});
|
|
568
|
+
}, [textRef]);
|
|
539
569
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
#### `doc.$.change(mutator)`
|
|
543
|
-
|
|
544
|
-
Same as `change(doc, mutator)`.
|
|
545
|
-
|
|
546
|
-
```typescript
|
|
547
|
-
doc.$.change((draft) => {
|
|
548
|
-
// Make changes to draft - all commit together
|
|
549
|
-
});
|
|
570
|
+
return <div>...</div>;
|
|
571
|
+
}
|
|
550
572
|
```
|
|
551
573
|
|
|
552
574
|
### Schema Builders
|
|
@@ -570,7 +592,7 @@ const schema = Shape.doc({
|
|
|
570
592
|
- `Shape.movableList(itemSchema)` - Collaborative reorderable lists
|
|
571
593
|
- `Shape.struct(shape)` - Collaborative structs with fixed keys (uses LoroMap internally)
|
|
572
594
|
- `Shape.record(valueSchema)` - Collaborative key-value maps with dynamic string keys
|
|
573
|
-
- `Shape.tree(
|
|
595
|
+
- `Shape.tree(dataShape)` - Collaborative hierarchical tree structures with typed node metadata
|
|
574
596
|
- `Shape.any()` - Escape hatch for untyped containers (see [Untyped Integration](#untyped-integration-with-external-libraries))
|
|
575
597
|
|
|
576
598
|
#### Value Types
|
|
@@ -636,7 +658,7 @@ email: Shape.plain
|
|
|
636
658
|
|
|
637
659
|
### TypedDoc API
|
|
638
660
|
|
|
639
|
-
With the proxy-based API, schema properties are accessed directly on the doc object, and
|
|
661
|
+
With the proxy-based API, schema properties are accessed directly on the doc object, and CRDT internals are accessed via the `loro()` function.
|
|
640
662
|
|
|
641
663
|
#### Direct Schema Access
|
|
642
664
|
|
|
@@ -645,7 +667,7 @@ Access schema properties directly on the doc. Mutations commit immediately (auto
|
|
|
645
667
|
```typescript
|
|
646
668
|
// Read values
|
|
647
669
|
const title = doc.title.toString();
|
|
648
|
-
const count = doc.count
|
|
670
|
+
const count = doc.count;
|
|
649
671
|
|
|
650
672
|
// Mutate directly - commits immediately
|
|
651
673
|
doc.title.insert(0, "Hello");
|
|
@@ -657,30 +679,32 @@ doc.users.has("alice"); // true
|
|
|
657
679
|
"alice" in doc.users; // true
|
|
658
680
|
```
|
|
659
681
|
|
|
660
|
-
For batched mutations, use
|
|
682
|
+
For batched mutations, use `change(doc, fn)`.
|
|
661
683
|
|
|
662
|
-
#### `doc
|
|
684
|
+
#### `doc.toJSON()`
|
|
663
685
|
|
|
664
|
-
|
|
686
|
+
Returns the full plain JavaScript object representation.
|
|
665
687
|
|
|
666
688
|
```typescript
|
|
667
|
-
const snapshot = doc
|
|
689
|
+
const snapshot = doc.toJSON();
|
|
668
690
|
```
|
|
669
691
|
|
|
670
|
-
#### `doc
|
|
692
|
+
#### `loro(doc).rawValue`
|
|
671
693
|
|
|
672
|
-
Returns raw CRDT state without empty state overlay.
|
|
694
|
+
Returns raw CRDT state without placeholders (empty state overlay).
|
|
673
695
|
|
|
674
696
|
```typescript
|
|
675
|
-
|
|
697
|
+
import { loro } from "@loro-extended/change";
|
|
698
|
+
const crdtState = loro(doc).rawValue;
|
|
676
699
|
```
|
|
677
700
|
|
|
678
|
-
#### `doc
|
|
701
|
+
#### `loro(doc).doc`
|
|
679
702
|
|
|
680
|
-
|
|
703
|
+
Access the underlying LoroDoc.
|
|
681
704
|
|
|
682
705
|
```typescript
|
|
683
|
-
|
|
706
|
+
import { loro } from "@loro-extended/change";
|
|
707
|
+
const loroDoc = loro(doc).doc;
|
|
684
708
|
```
|
|
685
709
|
|
|
686
710
|
## CRDT Container Operations
|
|
@@ -743,7 +767,7 @@ draft.todos.forEach((todo, index) => {
|
|
|
743
767
|
});
|
|
744
768
|
```
|
|
745
769
|
|
|
746
|
-
|
|
770
|
+
Methods like `find()` and `filter()` return **mutable draft objects** that you can modify directly:
|
|
747
771
|
|
|
748
772
|
```typescript
|
|
749
773
|
change(doc, (draft) => {
|
|
@@ -788,13 +812,128 @@ draft.metadata.values();
|
|
|
788
812
|
const value = draft.metadata.get("key");
|
|
789
813
|
```
|
|
790
814
|
|
|
815
|
+
### Tree Operations
|
|
816
|
+
|
|
817
|
+
Trees are hierarchical structures where each node has typed metadata. Perfect for state machines, file systems, org charts, and nested data.
|
|
818
|
+
|
|
819
|
+
```typescript
|
|
820
|
+
// Define node data shape
|
|
821
|
+
const StateNodeDataShape = Shape.struct({
|
|
822
|
+
name: Shape.text(),
|
|
823
|
+
facts: Shape.record(Shape.plain.any()),
|
|
824
|
+
rules: Shape.list(
|
|
825
|
+
Shape.plain.struct({
|
|
826
|
+
name: Shape.plain.string(),
|
|
827
|
+
rego: Shape.plain.string(),
|
|
828
|
+
description: Shape.plain.string().nullable(),
|
|
829
|
+
})
|
|
830
|
+
),
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
const schema = Shape.doc({
|
|
834
|
+
states: Shape.tree(StateNodeDataShape),
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
const doc = createTypedDoc(schema);
|
|
838
|
+
|
|
839
|
+
change(doc, (draft) => {
|
|
840
|
+
// Create root nodes
|
|
841
|
+
const idle = draft.states.createNode();
|
|
842
|
+
idle.data.name.insert(0, "idle");
|
|
843
|
+
|
|
844
|
+
const running = draft.states.createNode();
|
|
845
|
+
running.data.name.insert(0, "running");
|
|
846
|
+
|
|
847
|
+
// Create child nodes
|
|
848
|
+
const processing = idle.createNode();
|
|
849
|
+
processing.data.name.insert(0, "processing");
|
|
850
|
+
|
|
851
|
+
// Access typed node data
|
|
852
|
+
processing.data.rules.push({
|
|
853
|
+
name: "validate",
|
|
854
|
+
rego: "package validate",
|
|
855
|
+
description: null,
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
// Navigate the tree
|
|
859
|
+
const parent = processing.parent(); // Returns idle node
|
|
860
|
+
const children = idle.children(); // Returns [processing]
|
|
861
|
+
|
|
862
|
+
// Move nodes between parents
|
|
863
|
+
processing.move(running); // Move to different parent
|
|
864
|
+
processing.move(); // Move to root (no parent)
|
|
865
|
+
|
|
866
|
+
// Query the tree
|
|
867
|
+
const roots = draft.states.roots(); // All root nodes
|
|
868
|
+
const allNodes = draft.states.nodes(); // All nodes (flat)
|
|
869
|
+
const node = draft.states.getNodeByID(idle.id); // Find by ID
|
|
870
|
+
const exists = draft.states.has(idle.id); // Check existence
|
|
871
|
+
|
|
872
|
+
// Delete nodes (and all descendants)
|
|
873
|
+
draft.states.delete(running);
|
|
874
|
+
|
|
875
|
+
// Enable fractional indexing for ordering
|
|
876
|
+
draft.states.enableFractionalIndex(8);
|
|
877
|
+
const index = idle.index(); // Position among siblings
|
|
878
|
+
const fractionalIndex = idle.fractionalIndex(); // Fractional index string
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
// Serialize to JSON (nested structure)
|
|
882
|
+
const json = doc.toJSON();
|
|
883
|
+
// {
|
|
884
|
+
// states: [{
|
|
885
|
+
// id: "0@123",
|
|
886
|
+
// parent: null,
|
|
887
|
+
// index: 0,
|
|
888
|
+
// fractionalIndex: "80",
|
|
889
|
+
// data: { name: "idle", facts: {}, rules: [] },
|
|
890
|
+
// children: [...]
|
|
891
|
+
// }]
|
|
892
|
+
// }
|
|
893
|
+
|
|
894
|
+
// Get flat array representation
|
|
895
|
+
change(doc, (draft) => {
|
|
896
|
+
const flatArray = draft.states.toArray();
|
|
897
|
+
// [{ id, parent, index, fractionalIndex, data }, ...]
|
|
898
|
+
});
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
**Tree Node Properties:**
|
|
902
|
+
|
|
903
|
+
- `node.id` - Unique TreeID for the node
|
|
904
|
+
- `node.data` - Typed StructRef for node metadata (access like `node.data.name`)
|
|
905
|
+
- `node.parent()` - Get parent node (or undefined for roots)
|
|
906
|
+
- `node.children()` - Get child nodes in order
|
|
907
|
+
- `node.index()` - Position among siblings
|
|
908
|
+
- `node.fractionalIndex()` - Fractional index string for ordering
|
|
909
|
+
- `node.isDeleted()` - Check if node has been deleted
|
|
910
|
+
|
|
911
|
+
**Tree Node Methods:**
|
|
912
|
+
|
|
913
|
+
- `node.createNode(initialData?, index?)` - Create child node
|
|
914
|
+
- `node.move(newParent?, index?)` - Move to new parent (undefined = root)
|
|
915
|
+
- `node.moveAfter(sibling)` - Move after sibling
|
|
916
|
+
- `node.moveBefore(sibling)` - Move before sibling
|
|
917
|
+
|
|
918
|
+
**TreeRef Methods:**
|
|
919
|
+
|
|
920
|
+
- `tree.createNode(initialData?)` - Create root node
|
|
921
|
+
- `tree.roots()` - Get all root nodes
|
|
922
|
+
- `tree.nodes()` - Get all nodes (flat)
|
|
923
|
+
- `tree.getNodeByID(id)` - Find node by TreeID
|
|
924
|
+
- `tree.has(id)` - Check if node exists
|
|
925
|
+
- `tree.delete(target)` - Delete node and descendants
|
|
926
|
+
- `tree.enableFractionalIndex(jitter?)` - Enable ordering
|
|
927
|
+
- `tree.toJSON()` - Nested JSON structure
|
|
928
|
+
- `tree.toArray()` - Flat array representation
|
|
929
|
+
|
|
791
930
|
### JSON Serialization and Snapshots
|
|
792
931
|
|
|
793
932
|
You can easily get a plain JavaScript object snapshot of any part of the document using `JSON.stringify()` or `.toJSON()`. This works for the entire document, nested containers, and even during loading states (placeholders).
|
|
794
933
|
|
|
795
934
|
```typescript
|
|
796
935
|
// Get full document snapshot
|
|
797
|
-
const snapshot = doc
|
|
936
|
+
const snapshot = doc.toJSON();
|
|
798
937
|
|
|
799
938
|
// Get snapshot of a specific list
|
|
800
939
|
const todos = doc.todos.toJSON(); // returns plain array of todos
|
|
@@ -934,7 +1073,7 @@ This package is part of the loro-extended ecosystem. Contributions welcome!
|
|
|
934
1073
|
|
|
935
1074
|
- **Build**: `pnpm build`
|
|
936
1075
|
- **Test**: `pnpm test`
|
|
937
|
-
- **Lint**:
|
|
1076
|
+
- **Lint**: `pnpm check`
|
|
938
1077
|
|
|
939
1078
|
## License
|
|
940
1079
|
|