@loro-extended/change 0.9.0 → 1.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 (44) hide show
  1. package/README.md +179 -69
  2. package/dist/index.d.ts +369 -172
  3. package/dist/index.js +691 -382
  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 +91 -91
  8. package/src/conversion.ts +12 -12
  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 +326 -0
  21. package/src/overlay.ts +54 -17
  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 +33 -1
  26. package/src/typed-refs/counter.test.ts +44 -13
  27. package/src/typed-refs/counter.ts +42 -5
  28. package/src/typed-refs/doc.ts +29 -30
  29. package/src/typed-refs/json-compatibility.test.ts +37 -32
  30. package/src/typed-refs/list-base.ts +49 -21
  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 +6 -3
  34. package/src/typed-refs/proxy-handlers.ts +14 -1
  35. package/src/typed-refs/record.test.ts +116 -51
  36. package/src/typed-refs/record.ts +86 -81
  37. package/src/typed-refs/{map.ts → struct.ts} +66 -78
  38. package/src/typed-refs/text.ts +48 -7
  39. package/src/typed-refs/tree.ts +3 -3
  40. package/src/typed-refs/utils.ts +120 -13
  41. package/src/types.test.ts +34 -39
  42. package/src/types.ts +5 -40
  43. package/src/utils/type-guards.ts +11 -6
  44. 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, batch, toJSON } 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.object({
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
+ batch(doc, draft => {
61
+ draft.title.insert(0, "Batch: ");
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: "Batch: 📝 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.
@@ -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
+ batch(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
153
+
154
+ With the Grand Unified API, schema properties are accessed directly on the doc. Mutations commit immediately by default:
143
155
 
144
- All mutations happen within transactional `change()` blocks:
156
+ ```typescript
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 `batch()`:
145
164
 
146
165
  ```typescript
147
- const result = doc.change((draft) => {
166
+ import { batch, toJSON } from "@loro-extended/change";
167
+
168
+ batch(doc, (draft) => {
148
169
  // Text operations
149
170
  draft.title.insert(0, "📝");
150
171
  draft.title.delete(5, 3);
@@ -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
+ // batch() returns the doc for chaining
197
+ console.log(doc.toJSON()); // Updated document state
176
198
  ```
177
199
 
200
+ ### When to Use `batch()` vs Direct Mutations
201
+
202
+ | Use Case | Approach |
203
+ |----------|----------|
204
+ | Single mutation | Direct: `doc.count.increment(1)` |
205
+ | Multiple related mutations | Batched: `batch(doc, d => { ... })` |
206
+ | Atomic undo/redo | Batched: `batch(doc, d => { ... })` |
207
+ | Performance-critical bulk updates | Batched: `batch(doc, d => { ... })` |
208
+ | Simple reads + writes | Direct: `doc.users.set(...)` |
209
+
210
+ > **Note:** The `$.change()` and `$.batch()` methods are available as an escape hatch, but the functional `batch()` helper is recommended for cleaner code.
211
+
178
212
  ## Advanced Usage
179
213
 
180
214
  ### Discriminated Unions
@@ -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
+ batch(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
@@ -301,7 +335,7 @@ const schema = Shape.doc({
301
335
  }),
302
336
  });
303
337
 
304
- doc.change((draft) => {
338
+ batch(doc, (draft) => {
305
339
  // Set individual values
306
340
  draft.settings.theme = "dark";
307
341
  draft.settings.collapsed = true;
@@ -329,7 +363,7 @@ const collaborativeSchema = Shape.doc({
329
363
  ),
330
364
  });
331
365
 
332
- doc.change((draft) => {
366
+ batch(doc, (draft) => {
333
367
  // Push creates and configures nested containers automatically
334
368
  draft.articles.push({
335
369
  title: "Collaborative Article",
@@ -352,22 +386,79 @@ 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.
356
392
 
357
- Creates a new typed Loro document.
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)*
401
+
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)
410
+
411
+ These functional helpers provide a cleaner API and are the recommended way to work with TypedDoc:
412
+
413
+ #### `batch(doc, mutator)`
414
+
415
+ Batches multiple mutations into a single transaction. Returns the doc for chaining.
416
+
417
+ ```typescript
418
+ import { batch } from "@loro-extended/change";
419
+
420
+ batch(doc, (draft) => {
421
+ draft.title.insert(0, "Hello");
422
+ draft.count.increment(5);
423
+ });
424
+
425
+ // Chainable - batch returns the doc
426
+ batch(doc, d => d.count.increment(1)).count.increment(2);
427
+ ```
428
+
429
+ #### `toJSON(doc)`
430
+
431
+ Returns the full plain JavaScript object representation of the document.
432
+
433
+ ```typescript
434
+ import { toJSON } from "@loro-extended/change";
435
+
436
+ const snapshot = toJSON(doc);
437
+ // { title: "Hello", count: 5, ... }
438
+ ```
439
+
440
+ #### `getLoroDoc(doc)`
441
+
442
+ Access the underlying LoroDoc for advanced operations.
443
+
444
+ ```typescript
445
+ import { getLoroDoc } from "@loro-extended/change";
446
+
447
+ const loroDoc = getLoroDoc(doc);
448
+ loroDoc.subscribe((event) => console.log("Changed:", event));
449
+ ```
450
+
451
+ ### $ Namespace (Escape Hatch)
365
452
 
366
- Applies transactional changes to a document.
453
+ The `$` namespace provides access to meta-operations. While functional helpers are recommended, the `$` namespace is available for advanced use cases:
454
+
455
+ #### `doc.$.batch(mutator)`
456
+
457
+ Same as `batch(doc, mutator)`.
367
458
 
368
459
  ```typescript
369
- const result = doc.change((draft) => {
370
- // Make changes to draft
460
+ doc.$.batch((draft) => {
461
+ // Make changes to draft - all commit together
371
462
  });
372
463
  ```
373
464
 
@@ -408,39 +499,54 @@ const schema = Shape.doc({
408
499
  - `Shape.plain.union(shapes)` - Union of value types (e.g., `string | null`)
409
500
  - `Shape.plain.discriminatedUnion(key, variants)` - Tagged union types with a discriminant key
410
501
 
411
- ### TypedDoc Methods
502
+ ### TypedDoc API
412
503
 
413
- #### `.value`
504
+ With the proxy-based API, schema properties are accessed directly on the doc object, and meta-operations are accessed via the `$` namespace.
414
505
 
415
- Returns the current document state with empty state overlay.
506
+ #### Direct Schema Access
507
+
508
+ Access schema properties directly on the doc. Mutations commit immediately (auto-commit mode).
416
509
 
417
510
  ```typescript
418
- const currentState = doc.value;
511
+ // Read values
512
+ const title = doc.title.toString();
513
+ const count = doc.count.value;
514
+
515
+ // Mutate directly - commits immediately
516
+ doc.title.insert(0, "Hello");
517
+ doc.count.increment(5);
518
+ doc.users.set("alice", { name: "Alice" });
519
+
520
+ // Check existence
521
+ doc.users.has("alice"); // true
522
+ "alice" in doc.users; // true
419
523
  ```
420
524
 
421
- This overlays "empty state" defaults with CRDT values, returning a JSON object with full type information (from your schema).
525
+ For batched mutations, use `$.batch()` instead.
422
526
 
423
- #### `.rawValue`
527
+ #### `doc.$.toJSON()`
424
528
 
425
- Returns raw CRDT state without empty state overlay.
529
+ Same as `doc.toJSON`. Returns the full plain JavaScript object representation.
426
530
 
427
531
  ```typescript
428
- const crdtState = doc.rawValue;
532
+ const snapshot = doc.$.toJSON();
429
533
  ```
430
534
 
431
- #### `.loroDoc`
535
+ #### `doc.$.rawValue`
432
536
 
433
- Access the underlying LoroDoc for advanced operations.
537
+ Returns raw CRDT state without empty state overlay.
434
538
 
435
539
  ```typescript
436
- const loroDoc = doc.loroDoc;
437
-
438
- const foods = loroDoc.getMap("foods");
439
- const drinks = loroDoc.getOrCreateContainer("drinks", new LoroMap());
440
- // etc.
540
+ const crdtState = doc.$.rawValue;
441
541
  ```
442
542
 
443
- You may need this when interfacing with other libraries, such as `loro-dev/loro-prosemirror`.
543
+ #### `doc.$.loroDoc`
544
+
545
+ Same as `getLoroDoc(doc)`. Access the underlying LoroDoc.
546
+
547
+ ```typescript
548
+ const loroDoc = doc.$.loroDoc;
549
+ ```
444
550
 
445
551
  ## CRDT Container Operations
446
552
 
@@ -505,7 +611,7 @@ draft.todos.forEach((todo, index) => {
505
611
  **Important**: Methods like `find()` and `filter()` return **mutable draft objects** that you can modify directly:
506
612
 
507
613
  ```typescript
508
- doc.change((draft) => {
614
+ batch(doc, (draft) => {
509
615
  // Find and mutate pattern - very common!
510
616
  const todo = draft.todos.find((t) => t.id === "123");
511
617
  if (todo) {
@@ -521,7 +627,7 @@ doc.change((draft) => {
521
627
  });
522
628
  ```
523
629
 
524
- This dual interface ensures predicates work with current data (including previous mutations in the same `change()` block) while returned objects remain mutable.
630
+ This dual interface ensures predicates work with current data (including previous mutations in the same `batch()` block) while returned objects remain mutable.
525
631
 
526
632
  ### Movable List Operations
527
633
 
@@ -553,16 +659,16 @@ You can easily get a plain JavaScript object snapshot of any part of the documen
553
659
 
554
660
  ```typescript
555
661
  // Get full document snapshot
556
- const snapshot = doc.toJSON();
662
+ const snapshot = doc.$.toJSON();
557
663
 
558
664
  // Get snapshot of a specific list
559
- const todos = doc.value.todos.toJSON(); // returns plain array of todos
665
+ const todos = doc.todos.toJSON(); // returns plain array of todos
560
666
 
561
667
  // Works with nested structures
562
- const metadata = doc.value.metadata.toJSON(); // returns plain object
668
+ const metadata = doc.metadata.toJSON(); // returns plain object
563
669
 
564
670
  // Serialize as JSON
565
- const serializedMetadata = JSON.stringify(doc.value.metadata); // returns string
671
+ const serializedMetadata = JSON.stringify(doc.metadata); // returns string
566
672
  ```
567
673
 
568
674
  **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.
@@ -595,8 +701,8 @@ const todoSchema = Shape.doc({
595
701
  // TypeScript will ensure the schema produces the correct type
596
702
  const doc = new TypedDoc(todoSchema);
597
703
 
598
- // The result will be properly typed as TodoDoc
599
- const result: TodoDoc = doc.change((draft) => {
704
+ // Mutations are type-safe
705
+ batch(doc, (draft) => {
600
706
  draft.title.insert(0, "Hello"); // ✅ Valid - TypeScript knows this is LoroText
601
707
  draft.todos.push({
602
708
  // ✅ Valid - TypeScript knows the expected shape
@@ -609,6 +715,9 @@ const result: TodoDoc = doc.change((draft) => {
609
715
  // draft.todos.push({ invalid: true }); // ❌ TypeScript error
610
716
  });
611
717
 
718
+ // The result is properly typed as TodoDoc
719
+ const result: TodoDoc = doc.toJSON();
720
+
612
721
  // You can also use type assertion to ensure schema compatibility
613
722
  type SchemaType = InferPlainType<typeof todoSchema>;
614
723
  const _typeCheck: TodoDoc = {} as SchemaType; // ✅ Will error if types don't match
@@ -622,13 +731,14 @@ const _typeCheck: TodoDoc = {} as SchemaType; // ✅ Will error if types don't m
622
731
 
623
732
  ```typescript
624
733
  import { LoroDoc } from "loro-crdt";
734
+ import { createTypedDoc, getLoroDoc } from "@loro-extended/change";
625
735
 
626
736
  // Wrap existing LoroDoc
627
737
  const existingDoc = new LoroDoc();
628
- const typedDoc = new TypedDoc(schema, existingDoc);
738
+ const typedDoc = createTypedDoc(schema, existingDoc);
629
739
 
630
740
  // Access underlying LoroDoc
631
- const loroDoc = typedDoc.loroDoc;
741
+ const loroDoc = getLoroDoc(typedDoc);
632
742
 
633
743
  // Use with existing Loro APIs
634
744
  loroDoc.subscribe((event) => {
@@ -696,7 +806,7 @@ This is typically provided by `UntypedDocHandle.presence` in `@loro-extended/rep
696
806
 
697
807
  ## Performance Considerations
698
808
 
699
- - All changes within a `change()` block are batched into a single transaction
809
+ - All changes within a `batch()` call are batched into a single transaction
700
810
  - Empty state overlay is computed on-demand, not stored
701
811
  - Container creation is lazy - containers are only created when accessed
702
812
  - Type validation occurs at development time, not runtime