@loro-extended/change 5.3.0 → 5.4.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 (39) hide show
  1. package/README.md +85 -28
  2. package/dist/index.d.ts +291 -107
  3. package/dist/index.js +587 -36
  4. package/dist/index.js.map +1 -1
  5. package/package.json +3 -2
  6. package/src/change.test.ts +1 -1
  7. package/src/conversion.ts +40 -4
  8. package/src/diff-overlay.test.ts +95 -0
  9. package/src/diff-overlay.ts +10 -0
  10. package/src/discriminated-union-tojson.test.ts +2 -2
  11. package/src/fork-at.test.ts +1 -1
  12. package/src/functional-helpers.test.ts +50 -1
  13. package/src/functional-helpers.ts +152 -8
  14. package/src/index.ts +46 -18
  15. package/src/loro.ts +2 -1
  16. package/src/nested-container-materialization.test.ts +336 -0
  17. package/src/overlay-recursion.test.ts +8 -8
  18. package/src/replay-diff.test.ts +389 -0
  19. package/src/replay-diff.ts +229 -0
  20. package/src/shallow-fork.test.ts +302 -0
  21. package/src/shape.ts +7 -7
  22. package/src/typed-doc-ownkeys.test.ts +116 -0
  23. package/src/typed-doc.ts +33 -10
  24. package/src/typed-refs/base.ts +40 -4
  25. package/src/typed-refs/counter-ref-internals.ts +16 -2
  26. package/src/typed-refs/doc-ref-internals.ts +1 -0
  27. package/src/typed-refs/doc-ref-ownkeys.test.ts +78 -0
  28. package/src/typed-refs/index.ts +17 -0
  29. package/src/typed-refs/json-compatibility.test.ts +1 -1
  30. package/src/typed-refs/list-ref-base-internals.ts +2 -1
  31. package/src/typed-refs/list-ref-base.ts +79 -3
  32. package/src/typed-refs/record-ref-internals.ts +116 -2
  33. package/src/typed-refs/record-ref.test.ts +522 -1
  34. package/src/typed-refs/record-ref.ts +72 -3
  35. package/src/typed-refs/struct-ref-internals.ts +40 -3
  36. package/src/typed-refs/text-ref-internals.ts +70 -4
  37. package/src/typed-refs/tree-node-ref-internals.ts +14 -2
  38. package/src/typed-refs/tree-ref-internals.ts +2 -1
  39. package/src/typed-refs/utils.ts +65 -8
package/README.md CHANGED
@@ -37,7 +37,7 @@ const schema = Shape.doc({
37
37
  users: Shape.record(
38
38
  Shape.plain.struct({
39
39
  name: Shape.plain.string(),
40
- })
40
+ }),
41
41
  ),
42
42
  });
43
43
 
@@ -103,7 +103,7 @@ const blogSchema = Shape.doc({
103
103
  heading: Shape.text(), // Collaborative headings
104
104
  content: Shape.text(), // Collaborative content
105
105
  order: Shape.plain.number(), // Plain metadata
106
- })
106
+ }),
107
107
  ),
108
108
  });
109
109
  ```
@@ -141,7 +141,7 @@ const blogSchemaWithDefaults = Shape.doc({
141
141
  heading: Shape.text(),
142
142
  content: Shape.text(),
143
143
  order: Shape.plain.number(),
144
- })
144
+ }),
145
145
  ),
146
146
  });
147
147
 
@@ -228,7 +228,7 @@ import { change } from "@loro-extended/change";
228
228
  // Library code - expose only the ref, not the doc
229
229
  class StateMachine {
230
230
  private doc: TypedDoc<...>;
231
-
231
+
232
232
  get states(): TreeRef<StateNodeShape> {
233
233
  return this.doc.states;
234
234
  }
@@ -239,7 +239,7 @@ function addStates(states: TreeRef<StateNodeShape>) {
239
239
  change(states, draft => {
240
240
  const idle = draft.createNode();
241
241
  idle.data.name.insert(0, "idle");
242
-
242
+
243
243
  const running = draft.createNode();
244
244
  running.data.name.insert(0, "running");
245
245
  });
@@ -251,6 +251,7 @@ addStates(machine.states); // No access to the underlying doc needed!
251
251
  ```
252
252
 
253
253
  This pattern is useful for:
254
+
254
255
  - **Library APIs**: Expose typed refs without leaking document structure
255
256
  - **Component isolation**: Pass refs to components that only need partial access
256
257
  - **Testing**: Mock or stub individual refs without full document setup
@@ -259,37 +260,37 @@ All ref types support `change()`:
259
260
 
260
261
  ```typescript
261
262
  // ListRef
262
- change(doc.items, draft => {
263
+ change(doc.items, (draft) => {
263
264
  draft.push("item1");
264
265
  draft.push("item2");
265
266
  });
266
267
 
267
268
  // TextRef
268
- change(doc.title, draft => {
269
+ change(doc.title, (draft) => {
269
270
  draft.insert(0, "Hello ");
270
271
  draft.insert(6, "World");
271
272
  });
272
273
 
273
274
  // CounterRef
274
- change(doc.count, draft => {
275
+ change(doc.count, (draft) => {
275
276
  draft.increment(5);
276
277
  draft.decrement(2);
277
278
  });
278
279
 
279
280
  // StructRef
280
- change(doc.profile, draft => {
281
+ change(doc.profile, (draft) => {
281
282
  draft.bio.insert(0, "Hello");
282
283
  draft.age.increment(1);
283
284
  });
284
285
 
285
286
  // RecordRef
286
- change(doc.users, draft => {
287
+ change(doc.users, (draft) => {
287
288
  draft.set("alice", { name: "Alice" });
288
289
  draft.set("bob", { name: "Bob" });
289
290
  });
290
291
 
291
292
  // TreeRef
292
- change(doc.tree, draft => {
293
+ change(doc.tree, (draft) => {
293
294
  const node = draft.createNode();
294
295
  node.data.name.insert(0, "root");
295
296
  });
@@ -298,14 +299,14 @@ change(doc.tree, draft => {
298
299
  Nested `change()` calls are safe - Loro's commit is idempotent:
299
300
 
300
301
  ```typescript
301
- change(doc.items, outer => {
302
+ change(doc.items, (outer) => {
302
303
  outer.push("from outer");
303
-
304
+
304
305
  // Nested change on a different ref - works correctly
305
- change(doc.count, inner => {
306
+ change(doc.count, (inner) => {
306
307
  inner.increment(10);
307
308
  });
308
-
309
+
309
310
  outer.push("still in outer");
310
311
  });
311
312
  // All mutations are committed
@@ -336,7 +337,7 @@ const ServerPresenceShape = Shape.plain.struct({
336
337
  Shape.plain.struct({
337
338
  x: Shape.plain.number(),
338
339
  y: Shape.plain.number(),
339
- })
340
+ }),
340
341
  ),
341
342
  tick: Shape.plain.number(),
342
343
  });
@@ -516,7 +517,7 @@ handle.subscribe(
516
517
  (titles, prev) => {
517
518
  // titles: string[], prev: string[] | undefined
518
519
  console.log("Titles changed:", titles);
519
- }
520
+ },
520
521
  );
521
522
 
522
523
  // DSL constructs:
@@ -605,15 +606,18 @@ loro(doc).rawValue; // Unmerged CRDT value
605
606
 
606
607
  **RecordRef** (Map-like interface)
607
608
 
608
- | Direct Access | Only via `loro()` |
609
- | -------------------- | ------------------------------ |
610
- | `get(key)` | `setContainer(key, container)` |
611
- | `set(key, value)` | `subscribe(callback)` |
612
- | `delete(key)` | `doc` |
613
- | `has(key)` | `container` |
614
- | `keys()`, `values()` | |
615
- | `size` | |
616
- | `toJSON()` | |
609
+ | Direct Access | Only via `loro()` |
610
+ | --------------------------------- | ------------------------------ |
611
+ | `get(key)` | `setContainer(key, container)` |
612
+ | `set(key, value)` | `subscribe(callback)` |
613
+ | `delete(key)` | `doc` |
614
+ | `has(key)` | `container` |
615
+ | `keys()`, `values()`, `entries()` | |
616
+ | `size` | |
617
+ | `replace(values)` | |
618
+ | `merge(values)` | |
619
+ | `clear()` | |
620
+ | `toJSON()` | |
617
621
 
618
622
  **TextRef**
619
623
 
@@ -665,6 +669,25 @@ function TextEditor({ textRef }: { textRef: TextRef }) {
665
669
  }
666
670
  ```
667
671
 
672
+ ### Subscribing to Document Transitions
673
+
674
+ Use `getTransition()` to build `{ before, after }` TypedDocs from a
675
+ subscription event using the diff overlay (no checkout or fork required):
676
+
677
+ ```typescript
678
+ import { getTransition, loro } from "@loro-extended/change";
679
+
680
+ const unsubscribe = loro(doc).subscribe((event) => {
681
+ if (event.by === "checkout") return;
682
+
683
+ const { before, after } = getTransition(doc, event);
684
+
685
+ if (!before.users.has("alice") && after.users.has("alice")) {
686
+ console.log("Alice just joined");
687
+ }
688
+ });
689
+ ```
690
+
668
691
  ### Schema Builders
669
692
 
670
693
  #### `Shape.doc(shape)`
@@ -906,6 +929,40 @@ draft.metadata.values();
906
929
  const value = draft.metadata.get("key");
907
930
  ```
908
931
 
932
+ ### Record Bulk Update Operations
933
+
934
+ Records support bulk update methods for efficient batch operations:
935
+
936
+ ```typescript
937
+ // Replace entire contents - keys not in the new object are removed
938
+ draft.players.replace({
939
+ alice: { name: "Alice", score: 100 },
940
+ bob: { name: "Bob", score: 50 },
941
+ });
942
+ // Result: only alice and bob exist, any previous entries are removed
943
+
944
+ // Merge values - existing keys not in the new object are kept
945
+ draft.scores.merge({
946
+ alice: 150, // updates alice
947
+ charlie: 25, // adds charlie
948
+ });
949
+ // Result: alice=150, bob=50 (unchanged), charlie=25
950
+
951
+ // Clear all entries
952
+ draft.history.clear();
953
+ // Result: empty record
954
+ ```
955
+
956
+ **Method semantics:**
957
+
958
+ | Method | Adds new | Updates existing | Removes absent |
959
+ | ----------------- | -------- | ---------------- | -------------- |
960
+ | `replace(values)` | ✅ | ✅ | ✅ |
961
+ | `merge(values)` | ✅ | ✅ | ❌ |
962
+ | `clear()` | ❌ | ❌ | ✅ (all) |
963
+
964
+ These methods batch all operations into a single commit, avoiding multiple subscription notifications.
965
+
909
966
  ### Tree Operations
910
967
 
911
968
  Trees are hierarchical structures where each node has typed metadata. Perfect for state machines, file systems, org charts, and nested data.
@@ -920,7 +977,7 @@ const StateNodeDataShape = Shape.struct({
920
977
  name: Shape.plain.string(),
921
978
  rego: Shape.plain.string(),
922
979
  description: Shape.plain.string().nullable(),
923
- })
980
+ }),
924
981
  ),
925
982
  });
926
983
 
@@ -1062,7 +1119,7 @@ const todoSchema = Shape.doc({
1062
1119
  id: Shape.plain.string(),
1063
1120
  text: Shape.plain.string(),
1064
1121
  done: Shape.plain.boolean(),
1065
- })
1122
+ }),
1066
1123
  ),
1067
1124
  });
1068
1125