@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.
Files changed (61) hide show
  1. package/README.md +289 -150
  2. package/dist/index.d.ts +1012 -310
  3. package/dist/index.js +1334 -568
  4. package/dist/index.js.map +1 -1
  5. package/package.json +4 -4
  6. package/src/change.test.ts +51 -52
  7. package/src/functional-helpers.test.ts +316 -4
  8. package/src/functional-helpers.ts +96 -6
  9. package/src/grand-unified-api.test.ts +35 -29
  10. package/src/index.ts +27 -1
  11. package/src/json-patch.test.ts +46 -27
  12. package/src/loro.test.ts +449 -0
  13. package/src/loro.ts +273 -0
  14. package/src/overlay-recursion.test.ts +1 -1
  15. package/src/overlay.ts +62 -3
  16. package/src/path-evaluator.ts +1 -1
  17. package/src/path-selector.test.ts +94 -1
  18. package/src/shape.ts +107 -14
  19. package/src/typed-doc.ts +100 -98
  20. package/src/typed-refs/base.ts +126 -46
  21. package/src/typed-refs/counter-ref-internals.ts +62 -0
  22. package/src/typed-refs/{counter.test.ts → counter-ref.test.ts} +5 -4
  23. package/src/typed-refs/counter-ref.ts +45 -0
  24. package/src/typed-refs/{doc.ts → doc-ref-internals.ts} +33 -56
  25. package/src/typed-refs/doc-ref.ts +47 -0
  26. package/src/typed-refs/encapsulation.test.ts +226 -0
  27. package/src/typed-refs/list-ref-base-internals.ts +280 -0
  28. package/src/typed-refs/list-ref-base.ts +518 -0
  29. package/src/typed-refs/list-ref-internals.ts +21 -0
  30. package/src/typed-refs/list-ref-value-updates.test.ts +213 -0
  31. package/src/typed-refs/{list.ts → list-ref.ts} +10 -11
  32. package/src/typed-refs/movable-list-ref-internals.ts +38 -0
  33. package/src/typed-refs/movable-list-ref.ts +31 -0
  34. package/src/typed-refs/proxy-handlers.ts +13 -4
  35. package/src/typed-refs/record-ref-internals.ts +216 -0
  36. package/src/typed-refs/record-ref-value-updates.test.ts +214 -0
  37. package/src/typed-refs/{record.test.ts → record-ref.test.ts} +21 -16
  38. package/src/typed-refs/record-ref.ts +80 -0
  39. package/src/typed-refs/struct-ref-internals.ts +195 -0
  40. package/src/typed-refs/struct-ref.test.ts +202 -0
  41. package/src/typed-refs/struct-ref.ts +257 -0
  42. package/src/typed-refs/text-ref-internals.ts +100 -0
  43. package/src/typed-refs/text-ref.ts +72 -0
  44. package/src/typed-refs/tree-node-ref-internals.ts +111 -0
  45. package/src/typed-refs/tree-node-ref.test.ts +234 -0
  46. package/src/typed-refs/tree-node-ref.ts +200 -0
  47. package/src/typed-refs/tree-node.test.ts +384 -0
  48. package/src/typed-refs/tree-ref-internals.ts +110 -0
  49. package/src/typed-refs/tree-ref.ts +194 -0
  50. package/src/typed-refs/utils.ts +38 -17
  51. package/src/types.ts +36 -1
  52. package/src/utils/type-guards.ts +1 -0
  53. package/src/typed-refs/counter.ts +0 -64
  54. package/src/typed-refs/list-base.ts +0 -424
  55. package/src/typed-refs/movable-list.ts +0 -34
  56. package/src/typed-refs/record.ts +0 -220
  57. package/src/typed-refs/struct.ts +0 -206
  58. package/src/typed-refs/text.ts +0 -97
  59. package/src/typed-refs/tree.ts +0 -40
  60. /package/src/typed-refs/{list.test.ts → list-ref.test.ts} +0 -0
  61. /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
- - **Empty State Overlay**: Seamlessly blend default values with CRDT state
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 `$.batch()` block are atomic
18
- - **Loro Compatible**: Works seamlessly with existing Loro code (`doc.$.loroDoc` is a familiar `LoroDoc`)
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(doc, (draft) => {
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(), // Increment-only 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
- Empty state provides default values that are merged when CRDT containers are empty, keeping the whole document typesafe:
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(doc, (draft) => {
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
- With the Grand Unified API, schema properties are accessed directly on the doc. Mutations commit immediately by default:
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
- import { change } from "@loro-extended/change";
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 (POJO values)
185
- draft.metadata.set("author", "John Doe");
186
- draft.metadata.delete("featured");
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, mergeValue } from "@loro-extended/change";
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
- // Empty states for each variant
251
- const EmptyClientPresence = {
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 key (e.g., `"type"`) determines which variant shape to use
283
- - Missing fields are filled from the empty state of the matching variant
284
- - Works seamlessly with `@loro-extended/react`'s `useEphemeral` hook
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
- // Option 1: Shape.any() directly - entire document is untyped
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, { presence: CursorPresenceSchema });
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 | Shape to Use |
341
- | ------------------------------------------- | ------------------------------------------------------------------ |
342
- | External library manages entire document | `repo.get(docId, Shape.any(), { presence: presenceSchema })` |
343
- | External library manages one container | `Shape.doc({ doc: Shape.any(), ... })` |
344
- | Flexible metadata in presence | `Shape.plain.any()` for dynamic values |
345
- | Binary cursor/selection data | `Shape.plain.bytes().nullable()` for `Uint8Array` \| `null` |
346
- | Full type safety | Use specific shapes like `Shape.struct()`, `Shape.text()` |
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(doc, (draft) => {
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(doc, (draft) => {
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
- // Note: articles[0] returns the actual CRDT containers
444
- draft.articles.get(0)?.title.insert(0, "");
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 `Handle.subscribe()` in `@loro-extended/repo` for efficient, type-safe subscriptions:
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
- #### `new TypedDoc<T>(schema, existingDoc?)` _(deprecated)_
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
- These functional helpers provide a cleaner API and are the recommended way to work with TypedDoc:
456
+ The `loro()` function provides access to CRDT internals and container-specific operations. It follows a simple design principle:
501
457
 
502
- #### `change(doc, mutator)`
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 { change } from "@loro-extended/change";
508
-
509
- change(doc, (draft) => {
510
- draft.title.insert(0, "Hello");
511
- draft.count.increment(5);
512
- });
513
-
514
- // Chainable - change returns the doc
515
- change(doc, (d) => d.count.increment(1)).count.increment(2);
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
- #### `doc.toJSON()`
519
-
520
- Returns the full plain JavaScript object representation of the document.
521
-
522
- ```typescript
523
- const snapshot = doc.toJSON();
524
- // { title: "Hello", count: 5, ... }
525
- ```
526
-
527
- #### `getLoroDoc(doc)`
528
-
529
- Access the underlying LoroDoc for advanced operations.
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 { getLoroDoc } from "@loro-extended/change";
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
- ### $ Namespace (Escape Hatch)
563
+ function TextEditor({ textRef }: { textRef: TextRef }) {
564
+ useEffect(() => {
565
+ return loro(textRef).subscribe((event) => {
566
+ // Handle text changes
567
+ });
568
+ }, [textRef]);
539
569
 
540
- The `$` namespace provides access to meta-operations. While functional helpers are recommended, the `$` namespace is available for advanced use cases:
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(shape)` - Collaborative hierarchical tree structures (Note: incomplete implementation)
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 meta-operations are accessed via the `$` namespace.
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.value;
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 `$.change()` instead.
682
+ For batched mutations, use `change(doc, fn)`.
661
683
 
662
- #### `doc.$.toJSON()`
684
+ #### `doc.toJSON()`
663
685
 
664
- Same as `doc.toJSON`. Returns the full plain JavaScript object representation.
686
+ Returns the full plain JavaScript object representation.
665
687
 
666
688
  ```typescript
667
- const snapshot = doc.$.toJSON();
689
+ const snapshot = doc.toJSON();
668
690
  ```
669
691
 
670
- #### `doc.$.rawValue`
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
- const crdtState = doc.$.rawValue;
697
+ import { loro } from "@loro-extended/change";
698
+ const crdtState = loro(doc).rawValue;
676
699
  ```
677
700
 
678
- #### `doc.$.loroDoc`
701
+ #### `loro(doc).doc`
679
702
 
680
- Same as `getLoroDoc(doc)`. Access the underlying LoroDoc.
703
+ Access the underlying LoroDoc.
681
704
 
682
705
  ```typescript
683
- const loroDoc = doc.$.loroDoc;
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
- **Important**: Methods like `find()` and `filter()` return **mutable draft objects** that you can modify directly:
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.$.toJSON();
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**: Uses Biome for formatting and linting
1076
+ - **Lint**: `pnpm check`
938
1077
 
939
1078
  ## License
940
1079