@loro-extended/change 0.9.1 → 1.0.1

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 (43) hide show
  1. package/README.md +201 -93
  2. package/dist/index.d.ts +361 -169
  3. package/dist/index.js +516 -235
  4. package/dist/index.js.map +1 -1
  5. package/package.json +1 -1
  6. package/src/change.test.ts +180 -175
  7. package/src/conversion.test.ts +19 -19
  8. package/src/conversion.ts +7 -7
  9. package/src/derive-placeholder.test.ts +14 -14
  10. package/src/derive-placeholder.ts +3 -3
  11. package/src/discriminated-union-assignability.test.ts +7 -7
  12. package/src/discriminated-union-tojson.test.ts +13 -24
  13. package/src/discriminated-union.test.ts +9 -8
  14. package/src/equality.test.ts +10 -2
  15. package/src/functional-helpers.test.ts +149 -0
  16. package/src/functional-helpers.ts +61 -0
  17. package/src/grand-unified-api.test.ts +423 -0
  18. package/src/index.ts +8 -6
  19. package/src/json-patch.test.ts +64 -56
  20. package/src/overlay-recursion.test.ts +23 -22
  21. package/src/overlay.ts +9 -9
  22. package/src/readonly.test.ts +27 -26
  23. package/src/shape.ts +103 -21
  24. package/src/typed-doc.ts +227 -58
  25. package/src/typed-refs/base.ts +23 -1
  26. package/src/typed-refs/counter.test.ts +44 -13
  27. package/src/typed-refs/counter.ts +40 -3
  28. package/src/typed-refs/doc.ts +12 -6
  29. package/src/typed-refs/json-compatibility.test.ts +37 -32
  30. package/src/typed-refs/list-base.ts +26 -22
  31. package/src/typed-refs/list.test.ts +4 -3
  32. package/src/typed-refs/movable-list.test.ts +3 -2
  33. package/src/typed-refs/movable-list.ts +4 -1
  34. package/src/typed-refs/proxy-handlers.ts +14 -1
  35. package/src/typed-refs/record.test.ts +107 -42
  36. package/src/typed-refs/record.ts +37 -19
  37. package/src/typed-refs/{map.ts → struct.ts} +31 -16
  38. package/src/typed-refs/text.ts +42 -1
  39. package/src/typed-refs/utils.ts +28 -6
  40. package/src/types.test.ts +34 -39
  41. package/src/types.ts +5 -40
  42. package/src/utils/type-guards.ts +11 -6
  43. package/src/validation.ts +10 -10
package/README.md CHANGED
@@ -11,11 +11,11 @@ A schema-driven, type-safe wrapper for [Loro CRDT](https://github.com/loro-dev/l
11
11
  Working with Loro directly involves somewhat verbose container operations and complex type management. The `change` package provides:
12
12
 
13
13
  - **Schema-First Design**: Define your document structure with type-safe schemas
14
- - **Natural Syntax**: Write `draft.title.insert(0, "Hello")` instead of verbose CRDT operations
14
+ - **Natural Syntax**: Write `doc.title.insert(0, "Hello")` instead of verbose CRDT operations
15
15
  - **Empty State Overlay**: 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 `change()` block are atomic
18
- - **Loro Compatible**: Works seamlessly with existing Loro code (`typedDoc.loroDoc` is a familiar `LoroDoc`)
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`)
19
19
 
20
20
  ## Installation
21
21
 
@@ -28,35 +28,45 @@ pnpm add @loro-extended/change loro-crdt
28
28
  ## Quick Start
29
29
 
30
30
  ```typescript
31
- import { TypedDoc, Shape } from "@loro-extended/change";
31
+ import { createTypedDoc, Shape, change } from "@loro-extended/change";
32
32
 
33
33
  // Define your document schema
34
34
  const schema = Shape.doc({
35
35
  title: Shape.text().placeholder("My Todo List"),
36
- todos: Shape.list(
37
- Shape.plain.object({
38
- id: Shape.plain.string(),
39
- text: Shape.plain.string(),
40
- completed: Shape.plain.boolean(),
41
- })
42
- ),
36
+ count: Shape.counter(),
37
+ users: Shape.record(Shape.plain.struct({
38
+ name: Shape.plain.string(),
39
+ })),
43
40
  });
44
41
 
45
42
  // Create a typed document
46
- const doc = new TypedDoc(schema);
43
+ const doc = createTypedDoc(schema);
47
44
 
48
- // Make changes with natural syntax
49
- const result = doc.change((draft) => {
50
- draft.title.insert(0, "📝 Todo");
51
- draft.todos.push({
52
- id: "1",
53
- text: "Learn Loro",
54
- completed: false,
55
- });
45
+ // Direct mutations - commit immediately (auto-commit mode)
46
+ doc.title.insert(0, "📝 Todo");
47
+ doc.count.increment(5);
48
+ doc.users.set("alice", { name: "Alice" });
49
+
50
+ // Check existence
51
+ if (doc.users.has("alice")) {
52
+ console.log("Alice exists!");
53
+ }
54
+ if ("alice" in doc.users) {
55
+ console.log("Also works with 'in' operator!");
56
+ }
57
+
58
+ // Batched mutations - commit together (optional, for performance)
59
+ // Using functional helper (recommended)
60
+ change(doc, draft => {
61
+ draft.title.insert(0, "Change: ");
62
+ draft.count.increment(10);
63
+ draft.users.set("bob", { name: "Bob" });
56
64
  });
65
+ // All changes commit as one transaction
57
66
 
58
- console.log(result);
59
- // { title: "📝 Todo", todos: [{ id: "1", text: "Learn Loro", completed: false }] }
67
+ // Get JSON snapshot using functional helper
68
+ console.log(doc.toJSON());
69
+ // { title: "Change: 📝 Todo", count: 15, users: { alice: { name: "Alice" }, bob: { name: "Bob" } } }
60
70
  ```
61
71
 
62
72
  Note that this is even more useful in combination with `@loro-extended/react` (if your app uses React) and `@loro-extended/repo` for syncing between client/server or among peers.
@@ -78,8 +88,8 @@ const blogSchema = Shape.doc({
78
88
  // Lists for ordered data
79
89
  tags: Shape.list(Shape.plain.string()), // List of strings
80
90
 
81
- // Maps for structured data
82
- metadata: Shape.map({
91
+ // Structs for structured data with fixed keys
92
+ metadata: Shape.struct({
83
93
  author: Shape.plain.string(), // Plain values (POJOs)
84
94
  publishedAt: Shape.plain.string(), // ISO date string
85
95
  featured: Shape.plain.boolean(),
@@ -87,7 +97,7 @@ const blogSchema = Shape.doc({
87
97
 
88
98
  // Movable lists for reorderable content
89
99
  sections: Shape.movableList(
90
- Shape.map({
100
+ Shape.struct({
91
101
  heading: Shape.text(), // Collaborative headings
92
102
  content: Shape.text(), // Collaborative content
93
103
  order: Shape.plain.number(), // Plain metadata
@@ -96,7 +106,7 @@ const blogSchema = Shape.doc({
96
106
  });
97
107
  ```
98
108
 
99
- **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 object or array won't work.
109
+ **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.
100
110
 
101
111
  ### Empty State Overlay
102
112
 
@@ -108,13 +118,13 @@ const blogSchemaWithDefaults = Shape.doc({
108
118
  title: Shape.text().placeholder("Untitled Document"),
109
119
  viewCount: Shape.counter(), // defaults to 0
110
120
  tags: Shape.list(Shape.plain.string()), // defaults to []
111
- metadata: Shape.map({
121
+ metadata: Shape.struct({
112
122
  author: Shape.plain.string().placeholder("Anonymous"),
113
123
  publishedAt: Shape.plain.string(), // defaults to ""
114
124
  featured: Shape.plain.boolean(), // defaults to false
115
125
  }),
116
126
  sections: Shape.movableList(
117
- Shape.map({
127
+ Shape.struct({
118
128
  heading: Shape.text(),
119
129
  content: Shape.text(),
120
130
  order: Shape.plain.number(),
@@ -122,29 +132,40 @@ const blogSchemaWithDefaults = Shape.doc({
122
132
  ),
123
133
  });
124
134
 
125
- const doc = new TypedDoc(blogSchemaWithDefaults);
135
+ const doc = createTypedDoc(blogSchemaWithDefaults);
126
136
 
127
137
  // Initially returns empty state
128
- console.log(doc.value);
138
+ console.log(doc.toJSON());
129
139
  // { title: "Untitled Document", viewCount: 0, ... }
130
140
 
131
141
  // After changes, CRDT values take priority over empty state
132
- doc.change((draft) => {
142
+ change(doc, (draft) => {
133
143
  draft.title.insert(0, "My Blog Post");
134
144
  draft.viewCount.increment(10);
135
145
  });
136
146
 
137
- console.log(doc.value);
147
+ console.log(doc.toJSON());
138
148
  // { title: "My Blog Post", viewCount: 10, tags: [], ... }
139
149
  // ↑ CRDT value ↑ CRDT value ↑ empty state preserved
140
150
  ```
141
151
 
142
- ### The `change()` Function
152
+ ### Direct Mutations vs Batched Mutations
143
153
 
144
- All mutations happen within transactional `change()` blocks:
154
+ With the Grand Unified API, schema properties are accessed directly on the doc. Mutations commit immediately by default:
145
155
 
146
156
  ```typescript
147
- const result = doc.change((draft) => {
157
+ // Direct mutations - each commits immediately
158
+ doc.title.insert(0, "📝");
159
+ doc.viewCount.increment(1);
160
+ doc.tags.push("typescript");
161
+ ```
162
+
163
+ For batched operations (better performance, atomic undo), use `change()`:
164
+
165
+ ```typescript
166
+ import { change } from "@loro-extended/change";
167
+
168
+ change(doc, (draft) => {
148
169
  // Text operations
149
170
  draft.title.insert(0, "📝");
150
171
  draft.title.delete(5, 3);
@@ -158,7 +179,7 @@ const result = doc.change((draft) => {
158
179
  draft.tags.insert(0, "loro");
159
180
  draft.tags.delete(1, 1);
160
181
 
161
- // Map operations (POJO values)
182
+ // Struct operations (POJO values)
162
183
  draft.metadata.set("author", "John Doe");
163
184
  draft.metadata.delete("featured");
164
185
 
@@ -171,10 +192,23 @@ const result = doc.change((draft) => {
171
192
  draft.sections.move(0, 1); // Reorder sections
172
193
  });
173
194
 
174
- // All changes are committed atomically
175
- console.log(result); // Updated document state
195
+ // All changes are committed atomically as one transaction
196
+ // change() returns the doc for chaining
197
+ console.log(doc.toJSON()); // Updated document state
176
198
  ```
177
199
 
200
+ ### When to Use `change()` vs Direct Mutations
201
+
202
+ | Use Case | Approach |
203
+ |----------|----------|
204
+ | Single mutation | Direct: `doc.count.increment(1)` |
205
+ | Multiple related mutations | Batched: `change(doc, d => { ... })` |
206
+ | Atomic undo/redo | Batched: `change(doc, d => { ... })` |
207
+ | Performance-critical bulk updates | Batched: `change(doc, d => { ... })` |
208
+ | Simple reads + writes | Direct: `doc.users.set(...)` |
209
+
210
+ > **Note:** The `$.change()` method is available as an escape hatch, but the functional `change()` helper is recommended for cleaner code.
211
+
178
212
  ## Advanced Usage
179
213
 
180
214
  ### Discriminated Unions
@@ -185,19 +219,19 @@ For type-safe tagged unions (like different message types or presence states), u
185
219
  import { Shape, mergeValue } from "@loro-extended/change";
186
220
 
187
221
  // Define variant shapes - each must have the discriminant key
188
- const ClientPresenceShape = Shape.plain.object({
222
+ const ClientPresenceShape = Shape.plain.struct({
189
223
  type: Shape.plain.string("client"), // Literal type for discrimination
190
224
  name: Shape.plain.string(),
191
- input: Shape.plain.object({
225
+ input: Shape.plain.struct({
192
226
  force: Shape.plain.number(),
193
227
  angle: Shape.plain.number(),
194
228
  }),
195
229
  });
196
230
 
197
- const ServerPresenceShape = Shape.plain.object({
231
+ const ServerPresenceShape = Shape.plain.struct({
198
232
  type: Shape.plain.string("server"), // Literal type for discrimination
199
233
  cars: Shape.plain.record(
200
- Shape.plain.object({
234
+ Shape.plain.struct({
201
235
  x: Shape.plain.number(),
202
236
  y: Shape.plain.number(),
203
237
  })
@@ -253,11 +287,11 @@ Handle complex nested documents with ease:
253
287
 
254
288
  ```typescript
255
289
  const complexSchema = Shape.doc({
256
- article: Shape.map({
290
+ article: Shape.struct({
257
291
  title: Shape.text(),
258
- metadata: Shape.map({
292
+ metadata: Shape.struct({
259
293
  views: Shape.counter(),
260
- author: Shape.map({
294
+ author: Shape.struct({
261
295
  name: Shape.plain.string(),
262
296
  email: Shape.plain.string(),
263
297
  }),
@@ -278,9 +312,9 @@ const emptyState = {
278
312
  },
279
313
  };
280
314
 
281
- const doc = new TypedDoc(complexSchema, emptyState);
315
+ const doc = createTypedDoc(complexSchema);
282
316
 
283
- doc.change((draft) => {
317
+ change(doc, (draft) => {
284
318
  draft.article.title.insert(0, "Deep Nesting Example");
285
319
  draft.article.metadata.views.increment(5);
286
320
  draft.article.metadata.author.name = "Alice"; // plain string update is captured and applied after closure
@@ -288,20 +322,20 @@ doc.change((draft) => {
288
322
  });
289
323
  ```
290
324
 
291
- ### Map Operations
325
+ ### Struct Operations
292
326
 
293
- For map containers, use the standard map methods:
327
+ For struct containers (fixed-key objects), use direct property access:
294
328
 
295
329
  ```typescript
296
330
  const schema = Shape.doc({
297
- settings: Shape.map({
331
+ settings: Shape.struct({
298
332
  theme: Shape.plain.string(),
299
333
  collapsed: Shape.plain.boolean(),
300
334
  width: Shape.plain.number(),
301
335
  }),
302
336
  });
303
337
 
304
- doc.change((draft) => {
338
+ change(doc, (draft) => {
305
339
  // Set individual values
306
340
  draft.settings.theme = "dark";
307
341
  draft.settings.collapsed = true;
@@ -316,11 +350,11 @@ Create lists containing CRDT containers for collaborative nested structures:
316
350
  ```typescript
317
351
  const collaborativeSchema = Shape.doc({
318
352
  articles: Shape.list(
319
- Shape.map({
353
+ Shape.struct({
320
354
  title: Shape.text(), // Collaborative title
321
355
  content: Shape.text(), // Collaborative content
322
356
  tags: Shape.list(Shape.plain.string()), // Collaborative tag list
323
- metadata: Shape.plain.object({
357
+ metadata: Shape.plain.struct({
324
358
  // Static metadata
325
359
  authorId: Shape.plain.string(),
326
360
  publishedAt: Shape.plain.string(),
@@ -329,7 +363,7 @@ const collaborativeSchema = Shape.doc({
329
363
  ),
330
364
  });
331
365
 
332
- doc.change((draft) => {
366
+ change(doc, (draft) => {
333
367
  // Push creates and configures nested containers automatically
334
368
  draft.articles.push({
335
369
  title: "Collaborative Article",
@@ -352,22 +386,77 @@ doc.change((draft) => {
352
386
 
353
387
  ### Core Functions
354
388
 
355
- #### `new TypedDoc<T>(schema, existingDoc?)`
389
+ #### `createTypedDoc<T>(schema, existingDoc?)`
390
+
391
+ Creates a new typed Loro document. This is the recommended way to create documents.
392
+
393
+ ```typescript
394
+ import { createTypedDoc, Shape } from "@loro-extended/change";
395
+
396
+ const doc = createTypedDoc(schema);
397
+ const docFromExisting = createTypedDoc(schema, existingLoroDoc);
398
+ ```
399
+
400
+ #### `new TypedDoc<T>(schema, existingDoc?)` *(deprecated)*
356
401
 
357
- Creates a new typed Loro document.
402
+ Constructor-style API. Use `createTypedDoc()` instead for cleaner code.
358
403
 
359
404
  ```typescript
405
+ // Deprecated - use createTypedDoc() instead
360
406
  const doc = new TypedDoc(schema);
361
- const docFromExisting = new TypedDoc(schema, existingLoroDoc);
362
407
  ```
363
408
 
364
- #### `doc.change(mutator)`
409
+ ### Functional Helpers (Recommended)
365
410
 
366
- Applies transactional changes to a document.
411
+ These functional helpers provide a cleaner API and are the recommended way to work with TypedDoc:
412
+
413
+ #### `change(doc, mutator)`
414
+
415
+ Batches multiple mutations into a single transaction. Returns the doc for chaining.
367
416
 
368
417
  ```typescript
369
- const result = doc.change((draft) => {
370
- // Make changes to draft
418
+ import { change } from "@loro-extended/change";
419
+
420
+ change(doc, (draft) => {
421
+ draft.title.insert(0, "Hello");
422
+ draft.count.increment(5);
423
+ });
424
+
425
+ // Chainable - change returns the doc
426
+ change(doc, d => d.count.increment(1)).count.increment(2);
427
+ ```
428
+
429
+ #### `doc.toJSON()`
430
+
431
+ Returns the full plain JavaScript object representation of the document.
432
+
433
+ ```typescript
434
+ const snapshot = doc.toJSON();
435
+ // { title: "Hello", count: 5, ... }
436
+ ```
437
+
438
+ #### `getLoroDoc(doc)`
439
+
440
+ Access the underlying LoroDoc for advanced operations.
441
+
442
+ ```typescript
443
+ import { getLoroDoc } from "@loro-extended/change";
444
+
445
+ const loroDoc = getLoroDoc(doc);
446
+ loroDoc.subscribe((event) => console.log("Changed:", event));
447
+ ```
448
+
449
+ ### $ Namespace (Escape Hatch)
450
+
451
+ The `$` namespace provides access to meta-operations. While functional helpers are recommended, the `$` namespace is available for advanced use cases:
452
+
453
+ #### `doc.$.change(mutator)`
454
+
455
+ Same as `change(doc, mutator)`.
456
+
457
+ ```typescript
458
+ doc.$.change((draft) => {
459
+ // Make changes to draft - all commit together
371
460
  });
372
461
  ```
373
462
 
@@ -390,7 +479,7 @@ const schema = Shape.doc({
390
479
  - `Shape.counter()` - Collaborative increment/decrement counters
391
480
  - `Shape.list(itemSchema)` - Collaborative ordered lists
392
481
  - `Shape.movableList(itemSchema)` - Collaborative reorderable lists
393
- - `Shape.map(shape)` - Collaborative key-value maps with fixed keys
482
+ - `Shape.struct(shape)` - Collaborative structs with fixed keys (uses LoroMap internally)
394
483
  - `Shape.record(valueSchema)` - Collaborative key-value maps with dynamic string keys
395
484
  - `Shape.tree(shape)` - Collaborative hierarchical tree structures (Note: incomplete implementation)
396
485
 
@@ -402,45 +491,60 @@ const schema = Shape.doc({
402
491
  - `Shape.plain.null()` - Null values
403
492
  - `Shape.plain.undefined()` - Undefined values
404
493
  - `Shape.plain.uint8Array()` - Binary data values
405
- - `Shape.plain.object(shape)` - Object values with fixed keys
494
+ - `Shape.plain.struct(shape)` - Struct values with fixed keys
406
495
  - `Shape.plain.record(valueShape)` - Object values with dynamic string keys
407
496
  - `Shape.plain.array(itemShape)` - Array values
408
497
  - `Shape.plain.union(shapes)` - Union of value types (e.g., `string | null`)
409
498
  - `Shape.plain.discriminatedUnion(key, variants)` - Tagged union types with a discriminant key
410
499
 
411
- ### TypedDoc Methods
500
+ ### TypedDoc API
501
+
502
+ With the proxy-based API, schema properties are accessed directly on the doc object, and meta-operations are accessed via the `$` namespace.
412
503
 
413
- #### `.value`
504
+ #### Direct Schema Access
414
505
 
415
- Returns the current document state with empty state overlay.
506
+ Access schema properties directly on the doc. Mutations commit immediately (auto-commit mode).
416
507
 
417
508
  ```typescript
418
- const currentState = doc.value;
509
+ // Read values
510
+ const title = doc.title.toString();
511
+ const count = doc.count.value;
512
+
513
+ // Mutate directly - commits immediately
514
+ doc.title.insert(0, "Hello");
515
+ doc.count.increment(5);
516
+ doc.users.set("alice", { name: "Alice" });
517
+
518
+ // Check existence
519
+ doc.users.has("alice"); // true
520
+ "alice" in doc.users; // true
419
521
  ```
420
522
 
421
- This overlays "empty state" defaults with CRDT values, returning a JSON object with full type information (from your schema).
523
+ For batched mutations, use `$.change()` instead.
422
524
 
423
- #### `.rawValue`
525
+ #### `doc.$.toJSON()`
424
526
 
425
- Returns raw CRDT state without empty state overlay.
527
+ Same as `doc.toJSON`. Returns the full plain JavaScript object representation.
426
528
 
427
529
  ```typescript
428
- const crdtState = doc.rawValue;
530
+ const snapshot = doc.$.toJSON();
429
531
  ```
430
532
 
431
- #### `.loroDoc`
533
+ #### `doc.$.rawValue`
432
534
 
433
- Access the underlying LoroDoc for advanced operations.
535
+ Returns raw CRDT state without empty state overlay.
434
536
 
435
537
  ```typescript
436
- const loroDoc = doc.loroDoc;
437
-
438
- const foods = loroDoc.getMap("foods");
439
- const drinks = loroDoc.getOrCreateContainer("drinks", new LoroMap());
440
- // etc.
538
+ const crdtState = doc.$.rawValue;
441
539
  ```
442
540
 
443
- You may need this when interfacing with other libraries, such as `loro-dev/loro-prosemirror`.
541
+ #### `doc.$.loroDoc`
542
+
543
+ Same as `getLoroDoc(doc)`. Access the underlying LoroDoc.
544
+
545
+ ```typescript
546
+ const loroDoc = doc.$.loroDoc;
547
+ ```
444
548
 
445
549
  ## CRDT Container Operations
446
550
 
@@ -505,7 +609,7 @@ draft.todos.forEach((todo, index) => {
505
609
  **Important**: Methods like `find()` and `filter()` return **mutable draft objects** that you can modify directly:
506
610
 
507
611
  ```typescript
508
- doc.change((draft) => {
612
+ change(doc, (draft) => {
509
613
  // Find and mutate pattern - very common!
510
614
  const todo = draft.todos.find((t) => t.id === "123");
511
615
  if (todo) {
@@ -553,16 +657,16 @@ You can easily get a plain JavaScript object snapshot of any part of the documen
553
657
 
554
658
  ```typescript
555
659
  // Get full document snapshot
556
- const snapshot = doc.toJSON();
660
+ const snapshot = doc.$.toJSON();
557
661
 
558
662
  // Get snapshot of a specific list
559
- const todos = doc.value.todos.toJSON(); // returns plain array of todos
663
+ const todos = doc.todos.toJSON(); // returns plain array of todos
560
664
 
561
665
  // Works with nested structures
562
- const metadata = doc.value.metadata.toJSON(); // returns plain object
666
+ const metadata = doc.metadata.toJSON(); // returns plain object
563
667
 
564
668
  // Serialize as JSON
565
- const serializedMetadata = JSON.stringify(doc.value.metadata); // returns string
669
+ const serializedMetadata = JSON.stringify(doc.metadata); // returns string
566
670
  ```
567
671
 
568
672
  **Note:** `JSON.stringify()` is recommended for serialization as it handles all data types correctly. `.toJSON()` is available on all `TypedRef` objects and proxied placeholders for convenience when you need a direct object snapshot.
@@ -584,7 +688,7 @@ interface TodoDoc {
584
688
  const todoSchema = Shape.doc({
585
689
  title: Shape.text(),
586
690
  todos: Shape.list(
587
- Shape.plain.object({
691
+ Shape.plain.struct({
588
692
  id: Shape.plain.string(),
589
693
  text: Shape.plain.string(),
590
694
  done: Shape.plain.boolean(),
@@ -593,10 +697,10 @@ const todoSchema = Shape.doc({
593
697
  });
594
698
 
595
699
  // TypeScript will ensure the schema produces the correct type
596
- const doc = new TypedDoc(todoSchema);
700
+ const doc = createTypedDoc(todoSchema);
597
701
 
598
- // The result will be properly typed as TodoDoc
599
- const result: TodoDoc = doc.change((draft) => {
702
+ // Mutations are type-safe
703
+ change(doc, (draft) => {
600
704
  draft.title.insert(0, "Hello"); // ✅ Valid - TypeScript knows this is LoroText
601
705
  draft.todos.push({
602
706
  // ✅ Valid - TypeScript knows the expected shape
@@ -609,6 +713,9 @@ const result: TodoDoc = doc.change((draft) => {
609
713
  // draft.todos.push({ invalid: true }); // ❌ TypeScript error
610
714
  });
611
715
 
716
+ // The result is properly typed as TodoDoc
717
+ const result: TodoDoc = doc.toJSON();
718
+
612
719
  // You can also use type assertion to ensure schema compatibility
613
720
  type SchemaType = InferPlainType<typeof todoSchema>;
614
721
  const _typeCheck: TodoDoc = {} as SchemaType; // ✅ Will error if types don't match
@@ -622,13 +729,14 @@ const _typeCheck: TodoDoc = {} as SchemaType; // ✅ Will error if types don't m
622
729
 
623
730
  ```typescript
624
731
  import { LoroDoc } from "loro-crdt";
732
+ import { createTypedDoc, getLoroDoc } from "@loro-extended/change";
625
733
 
626
734
  // Wrap existing LoroDoc
627
735
  const existingDoc = new LoroDoc();
628
- const typedDoc = new TypedDoc(schema, existingDoc);
736
+ const typedDoc = createTypedDoc(schema, existingDoc);
629
737
 
630
738
  // Access underlying LoroDoc
631
- const loroDoc = typedDoc.loroDoc;
739
+ const loroDoc = getLoroDoc(typedDoc);
632
740
 
633
741
  // Use with existing Loro APIs
634
742
  loroDoc.subscribe((event) => {
@@ -644,8 +752,8 @@ The `TypedPresence` class provides type-safe access to ephemeral presence data w
644
752
  import { TypedPresence, Shape } from "@loro-extended/change";
645
753
 
646
754
  // Define a presence schema with placeholders
647
- const PresenceSchema = Shape.plain.object({
648
- cursor: Shape.plain.object({
755
+ const PresenceSchema = Shape.plain.struct({
756
+ cursor: Shape.plain.struct({
649
757
  x: Shape.plain.number(),
650
758
  y: Shape.plain.number(),
651
759
  }),
@@ -696,7 +804,7 @@ This is typically provided by `UntypedDocHandle.presence` in `@loro-extended/rep
696
804
 
697
805
  ## Performance Considerations
698
806
 
699
- - All changes within a `change()` block are batched into a single transaction
807
+ - All changes within a `change()` call are batched into a single transaction
700
808
  - Empty state overlay is computed on-demand, not stored
701
809
  - Container creation is lazy - containers are only created when accessed
702
810
  - Type validation occurs at development time, not runtime