@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/src/conversion.ts CHANGED
@@ -11,11 +11,11 @@ import type {
11
11
  ArrayValueShape,
12
12
  ContainerOrValueShape,
13
13
  ListContainerShape,
14
- MapContainerShape,
15
14
  MovableListContainerShape,
16
- ObjectValueShape,
17
15
  RecordContainerShape,
18
16
  RecordValueShape,
17
+ StructContainerShape,
18
+ StructValueShape,
19
19
  } from "./shape.js"
20
20
  import {
21
21
  isContainer,
@@ -59,7 +59,7 @@ function convertListInput(
59
59
  const list = new LoroList()
60
60
 
61
61
  for (const item of value) {
62
- const convertedItem = convertInputToNode(item, shape.shape)
62
+ const convertedItem = convertInputToRef(item, shape.shape)
63
63
  if (isContainer(convertedItem)) {
64
64
  list.pushContainer(convertedItem)
65
65
  } else {
@@ -85,7 +85,7 @@ function convertMovableListInput(
85
85
  const list = new LoroMovableList()
86
86
 
87
87
  for (const item of value) {
88
- const convertedItem = convertInputToNode(item, shape.shape)
88
+ const convertedItem = convertInputToRef(item, shape.shape)
89
89
  if (isContainer(convertedItem)) {
90
90
  list.pushContainer(convertedItem)
91
91
  } else {
@@ -97,11 +97,11 @@ function convertMovableListInput(
97
97
  }
98
98
 
99
99
  /**
100
- * Converts object input to LoroMap container
100
+ * Converts object input to LoroMap container (Struct)
101
101
  */
102
- function convertMapInput(
102
+ function convertStructInput(
103
103
  value: { [key: string]: Value },
104
- shape: MapContainerShape | ObjectValueShape,
104
+ shape: StructContainerShape | StructValueShape,
105
105
  ): LoroMap | { [key: string]: Value } {
106
106
  if (!isContainerShape(shape)) {
107
107
  return value
@@ -111,7 +111,7 @@ function convertMapInput(
111
111
  for (const [k, v] of Object.entries(value)) {
112
112
  const nestedSchema = shape.shapes[k]
113
113
  if (nestedSchema) {
114
- const convertedValue = convertInputToNode(v, nestedSchema)
114
+ const convertedValue = convertInputToRef(v, nestedSchema)
115
115
  if (isContainer(convertedValue)) {
116
116
  map.setContainer(k, convertedValue)
117
117
  } else {
@@ -138,7 +138,7 @@ function convertRecordInput(
138
138
 
139
139
  const map = new LoroMap()
140
140
  for (const [k, v] of Object.entries(value)) {
141
- const convertedValue = convertInputToNode(v, shape.shape)
141
+ const convertedValue = convertInputToRef(v, shape.shape)
142
142
  if (isContainer(convertedValue)) {
143
143
  map.setContainer(k, convertedValue)
144
144
  } else {
@@ -153,7 +153,7 @@ function convertRecordInput(
153
153
  * Main conversion function that transforms input values to appropriate CRDT containers
154
154
  * based on schema definitions
155
155
  */
156
- export function convertInputToNode<Shape extends ContainerOrValueShape>(
156
+ export function convertInputToRef<Shape extends ContainerOrValueShape>(
157
157
  value: Value,
158
158
  shape: Shape,
159
159
  ): Container | Value {
@@ -186,12 +186,12 @@ export function convertInputToNode<Shape extends ContainerOrValueShape>(
186
186
 
187
187
  return convertMovableListInput(value, shape)
188
188
  }
189
- case "map": {
189
+ case "struct": {
190
190
  if (!isObjectValue(value)) {
191
191
  throw new Error("object expected")
192
192
  }
193
193
 
194
- return convertMapInput(value, shape)
194
+ return convertStructInput(value, shape)
195
195
  }
196
196
  case "record": {
197
197
  if (!isObjectValue(value)) {
@@ -29,7 +29,7 @@ describe("derivePlaceholder", () => {
29
29
 
30
30
  it("composes nested map placeholders", () => {
31
31
  const schema = Shape.doc({
32
- settings: Shape.map({
32
+ settings: Shape.struct({
33
33
  theme: Shape.plain.string().placeholder("dark"),
34
34
  fontSize: Shape.plain.number().placeholder(14),
35
35
  }),
@@ -65,7 +65,7 @@ describe("derivePlaceholder", () => {
65
65
 
66
66
  it("handles plain value shapes with defaults", () => {
67
67
  const schema = Shape.doc({
68
- config: Shape.map({
68
+ config: Shape.struct({
69
69
  name: Shape.plain.string(),
70
70
  count: Shape.plain.number(),
71
71
  enabled: Shape.plain.boolean(),
@@ -85,7 +85,7 @@ describe("derivePlaceholder", () => {
85
85
 
86
86
  it("handles plain value shapes with custom placeholders", () => {
87
87
  const schema = Shape.doc({
88
- config: Shape.map({
88
+ config: Shape.struct({
89
89
  name: Shape.plain.string().placeholder("default-name"),
90
90
  count: Shape.plain.number().placeholder(42),
91
91
  enabled: Shape.plain.boolean().placeholder(true),
@@ -103,8 +103,8 @@ describe("derivePlaceholder", () => {
103
103
 
104
104
  it("handles nested plain objects", () => {
105
105
  const schema = Shape.doc({
106
- user: Shape.map({
107
- profile: Shape.plain.object({
106
+ user: Shape.struct({
107
+ profile: Shape.plain.struct({
108
108
  name: Shape.plain.string().placeholder("Anonymous"),
109
109
  age: Shape.plain.number().placeholder(0),
110
110
  }),
@@ -123,7 +123,7 @@ describe("derivePlaceholder", () => {
123
123
 
124
124
  it("handles plain arrays as empty", () => {
125
125
  const schema = Shape.doc({
126
- tags: Shape.map({
126
+ tags: Shape.struct({
127
127
  items: Shape.plain.array(Shape.plain.string()),
128
128
  }),
129
129
  })
@@ -137,7 +137,7 @@ describe("derivePlaceholder", () => {
137
137
 
138
138
  it("handles plain records as empty", () => {
139
139
  const schema = Shape.doc({
140
- metadata: Shape.map({
140
+ metadata: Shape.struct({
141
141
  values: Shape.plain.record(Shape.plain.number()),
142
142
  }),
143
143
  })
@@ -151,7 +151,7 @@ describe("derivePlaceholder", () => {
151
151
 
152
152
  it("handles union types by deriving from first variant", () => {
153
153
  const schema = Shape.doc({
154
- value: Shape.map({
154
+ value: Shape.struct({
155
155
  data: Shape.plain.union([Shape.plain.string(), Shape.plain.null()]),
156
156
  }),
157
157
  })
@@ -165,7 +165,7 @@ describe("derivePlaceholder", () => {
165
165
 
166
166
  it("handles union types with explicit placeholder", () => {
167
167
  const schema = Shape.doc({
168
- value: Shape.map({
168
+ value: Shape.struct({
169
169
  data: Shape.plain
170
170
  .union([Shape.plain.string(), Shape.plain.null()])
171
171
  .placeholder(null),
@@ -191,7 +191,7 @@ describe("derivePlaceholder", () => {
191
191
 
192
192
  it("handles tree containers as empty arrays", () => {
193
193
  const schema = Shape.doc({
194
- hierarchy: Shape.tree(Shape.map({ name: Shape.text() })),
194
+ hierarchy: Shape.tree(Shape.struct({ name: Shape.text() })),
195
195
  })
196
196
 
197
197
  expect(derivePlaceholder(schema)).toEqual({
@@ -201,11 +201,11 @@ describe("derivePlaceholder", () => {
201
201
 
202
202
  it("handles complex nested structures", () => {
203
203
  const schema = Shape.doc({
204
- article: Shape.map({
204
+ article: Shape.struct({
205
205
  title: Shape.text().placeholder("Untitled Article"),
206
- metadata: Shape.map({
206
+ metadata: Shape.struct({
207
207
  views: Shape.counter().placeholder(0),
208
- author: Shape.plain.object({
208
+ author: Shape.plain.struct({
209
209
  name: Shape.plain.string().placeholder("Anonymous"),
210
210
  email: Shape.plain.string(),
211
211
  }),
@@ -231,7 +231,7 @@ describe("derivePlaceholder", () => {
231
231
 
232
232
  it("handles string literal options", () => {
233
233
  const schema = Shape.doc({
234
- status: Shape.map({
234
+ status: Shape.struct({
235
235
  value: Shape.plain.string("active", "inactive", "pending"),
236
236
  }),
237
237
  })
@@ -42,7 +42,7 @@ export function deriveShapePlaceholder(shape: ContainerOrValueShape): unknown {
42
42
  return {}
43
43
 
44
44
  // Structured container - recurse into nested shapes
45
- case "map": {
45
+ case "struct": {
46
46
  const result: Record<string, unknown> = {}
47
47
  for (const [key, nestedShape] of Object.entries(shape.shapes)) {
48
48
  result[key] = deriveShapePlaceholder(nestedShape)
@@ -74,8 +74,8 @@ function deriveValueShapePlaceholder(shape: ValueShape): unknown {
74
74
  case "uint8array":
75
75
  return shape._placeholder
76
76
 
77
- // Structured value - recurse into nested shapes (like map)
78
- case "object": {
77
+ // Structured value - recurse into nested shapes (like struct)
78
+ case "struct": {
79
79
  const result: Record<string, unknown> = {}
80
80
  for (const [key, nestedShape] of Object.entries(shape.shape)) {
81
81
  result[key] = deriveValueShapePlaceholder(nestedShape)
@@ -8,35 +8,35 @@ describe("Discriminated Union Placeholder Issue", () => {
8
8
 
9
9
  const SessionPhaseSchema = Shape.plain
10
10
  .discriminatedUnion("phase", {
11
- "not-started": Shape.plain.object({
11
+ "not-started": Shape.plain.struct({
12
12
  phase: Shape.plain.string("not-started"),
13
13
  }),
14
14
 
15
- lobby: Shape.plain.object({
15
+ lobby: Shape.plain.struct({
16
16
  phase: Shape.plain.string("lobby"),
17
17
  }),
18
- "lobby-paused": Shape.plain.object({
18
+ "lobby-paused": Shape.plain.struct({
19
19
  phase: Shape.plain.string("lobby-paused"),
20
20
  reason: PauseReasonSchema,
21
21
  }),
22
22
 
23
- active: Shape.plain.object({
23
+ active: Shape.plain.struct({
24
24
  phase: Shape.plain.string("active"),
25
25
  mode: ActiveModeSchema,
26
26
  }),
27
- "active-paused": Shape.plain.object({
27
+ "active-paused": Shape.plain.struct({
28
28
  phase: Shape.plain.string("active-paused"),
29
29
  mode: ActiveModeSchema,
30
30
  reason: PauseReasonSchema,
31
31
  }),
32
32
 
33
- ended: Shape.plain.object({
33
+ ended: Shape.plain.struct({
34
34
  phase: Shape.plain.string("ended"),
35
35
  }),
36
36
  })
37
37
  .placeholder({ phase: "not-started" })
38
38
 
39
- const PhaseTransitionSchema = Shape.map({
39
+ const PhaseTransitionSchema = Shape.struct({
40
40
  phase: SessionPhaseSchema,
41
41
  })
42
42
 
@@ -1,7 +1,8 @@
1
1
  import { LoroDoc, LoroMap } from "loro-crdt"
2
2
  import { describe, expect, it } from "vitest"
3
+ import { change } from "./functional-helpers.js"
3
4
  import { Shape } from "./shape.js"
4
- import { createTypedDoc, TypedDoc } from "./typed-doc.js"
5
+ import { createTypedDoc } from "./typed-doc.js"
5
6
 
6
7
  /**
7
8
  * This test file reproduces the "placeholder required" error reported by users
@@ -14,16 +15,16 @@ import { createTypedDoc, TypedDoc } from "./typed-doc.js"
14
15
  */
15
16
  describe("Record with Map entries - placeholder required bug", () => {
16
17
  // Reproduce the user's schema structure
17
- const StudentTomStateSchema = Shape.map({
18
+ const StudentTomStateSchema = Shape.struct({
18
19
  peerId: Shape.plain.string(),
19
20
  authorName: Shape.plain.string(),
20
21
  authorColor: Shape.plain.string(),
21
22
  intentionHistory: Shape.list(
22
- Shape.map({
23
+ Shape.struct({
23
24
  observedAt: Shape.plain.number(),
24
25
  messageTimestamp: Shape.plain.number(),
25
26
  predictions: Shape.list(
26
- Shape.map({
27
+ Shape.struct({
27
28
  horizon: Shape.plain.string("now", "soon", "future"),
28
29
  value: Shape.plain.string(),
29
30
  }),
@@ -31,11 +32,11 @@ describe("Record with Map entries - placeholder required bug", () => {
31
32
  }),
32
33
  ),
33
34
  emotionHistory: Shape.list(
34
- Shape.map({
35
+ Shape.struct({
35
36
  observedAt: Shape.plain.number(),
36
37
  messageTimestamp: Shape.plain.number(),
37
38
  predictions: Shape.list(
38
- Shape.map({
39
+ Shape.struct({
39
40
  horizon: Shape.plain.string("now", "soon", "future"),
40
41
  value: Shape.plain.string(),
41
42
  }),
@@ -63,16 +64,12 @@ describe("Record with Map entries - placeholder required bug", () => {
63
64
  // Note: authorColor is NOT set - this should fall back to placeholder
64
65
 
65
66
  // Now wrap it with TypedDoc
66
- const typedDoc = new TypedDoc(AiStateSchema, loroDoc)
67
-
68
- // Log the raw CRDT value
69
- console.log("Raw CRDT value:", JSON.stringify(loroDoc.toJSON(), null, 2))
67
+ const typedDoc = createTypedDoc(AiStateSchema, loroDoc)
70
68
 
71
69
  // This should not throw "placeholder required"
72
70
  // BUG: Currently throws because the nested MapRef has placeholder: undefined
73
71
  expect(() => {
74
- const json = typedDoc.value.toJSON()
75
- console.log("toJSON result:", JSON.stringify(json, null, 2))
72
+ typedDoc.toJSON()
76
73
  }).not.toThrow()
77
74
  })
78
75
 
@@ -80,7 +77,7 @@ describe("Record with Map entries - placeholder required bug", () => {
80
77
  const typedDoc = createTypedDoc(AiStateSchema)
81
78
 
82
79
  // Add an entry via the typed API
83
- typedDoc.change(draft => {
80
+ change(typedDoc, draft => {
84
81
  draft.tomState.set("peer-123", {
85
82
  peerId: "peer-123",
86
83
  authorName: "Alice",
@@ -92,8 +89,7 @@ describe("Record with Map entries - placeholder required bug", () => {
92
89
 
93
90
  // This should work because all values were set
94
91
  expect(() => {
95
- const json = typedDoc.value.toJSON()
96
- console.log("toJSON result (via change):", JSON.stringify(json, null, 2))
92
+ typedDoc.toJSON()
97
93
  }).not.toThrow()
98
94
  })
99
95
 
@@ -111,18 +107,11 @@ describe("Record with Map entries - placeholder required bug", () => {
111
107
  // Only set peerId - other fields are missing
112
108
  studentMap.set("peerId", "peer-456")
113
109
 
114
- const typedDoc = new TypedDoc(AiStateSchema, loroDoc)
115
-
116
- // Log what we have
117
- console.log(
118
- "Raw CRDT (partial):",
119
- JSON.stringify(loroDoc.toJSON(), null, 2),
120
- )
110
+ const typedDoc = createTypedDoc(AiStateSchema, loroDoc)
121
111
 
122
112
  // This should not throw - missing fields should use placeholder defaults
123
113
  expect(() => {
124
- const json = typedDoc.value.toJSON()
125
- console.log("toJSON (partial):", JSON.stringify(json, null, 2))
114
+ typedDoc.toJSON()
126
115
  }).not.toThrow()
127
116
  })
128
117
  })
@@ -1,24 +1,25 @@
1
1
  import { describe, expect, it } from "vitest"
2
+ import { change } from "./functional-helpers.js"
2
3
  import { mergeValue } from "./overlay.js"
3
4
  import { Shape } from "./shape.js"
4
- import { TypedDoc } from "./typed-doc.js"
5
+ import { createTypedDoc } from "./typed-doc.js"
5
6
  import { validateValue } from "./validation.js"
6
7
 
7
8
  describe("discriminatedUnion", () => {
8
9
  // Define variant shapes
9
- const ClientPresenceShape = Shape.plain.object({
10
+ const ClientPresenceShape = Shape.plain.struct({
10
11
  type: Shape.plain.string("client"),
11
12
  name: Shape.plain.string(),
12
- input: Shape.plain.object({
13
+ input: Shape.plain.struct({
13
14
  force: Shape.plain.number(),
14
15
  angle: Shape.plain.number(),
15
16
  }),
16
17
  })
17
18
 
18
- const ServerPresenceShape = Shape.plain.object({
19
+ const ServerPresenceShape = Shape.plain.struct({
19
20
  type: Shape.plain.string("server"),
20
21
  cars: Shape.plain.record(
21
- Shape.plain.object({
22
+ Shape.plain.struct({
22
23
  x: Shape.plain.number(),
23
24
  y: Shape.plain.number(),
24
25
  }),
@@ -215,14 +216,14 @@ describe("discriminatedUnion", () => {
215
216
  describe("TypedDoc integration", () => {
216
217
  it("should allow setting a discriminated union property in a MapDraftNode", () => {
217
218
  const DocSchema = Shape.doc({
218
- state: Shape.map({
219
+ state: Shape.struct({
219
220
  presence: GamePresenceSchema.placeholder(EmptyClientPresence),
220
221
  }),
221
222
  })
222
223
 
223
- const doc = new TypedDoc(DocSchema)
224
+ const doc = createTypedDoc(DocSchema)
224
225
 
225
- doc.change(draft => {
226
+ change(doc, draft => {
226
227
  // This should work now that MapDraftNode recognizes discriminatedUnion as a value shape
227
228
  draft.state.presence = {
228
229
  type: "server",
@@ -7,13 +7,21 @@ describe("Equality Check", () => {
7
7
  counter: Shape.counter().placeholder(1),
8
8
  })
9
9
 
10
- it("should compare equal to plain object", () => {
10
+ it("should compare CounterRef.value to plain number", () => {
11
11
  const doc = createTypedDoc(schema)
12
- expect(doc.value.counter).toEqual(1)
12
+ // doc.counter returns a CounterRef, use .value to get the number
13
+ expect(doc.counter.value).toEqual(1)
13
14
  })
14
15
 
15
16
  it("should compare equal using toJSON", () => {
16
17
  const doc = createTypedDoc(schema)
17
18
  expect(doc.toJSON()).toEqual({ counter: 1 })
18
19
  })
20
+
21
+ it("should support valueOf for loose comparisons", () => {
22
+ const doc = createTypedDoc(schema)
23
+ // CounterRef has valueOf() so it can be used in arithmetic
24
+ expect(doc.counter.valueOf()).toBe(1)
25
+ expect(+doc.counter).toBe(1)
26
+ })
19
27
  })
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { change, getLoroDoc } from "./functional-helpers.js"
3
+ import { Shape } from "./shape.js"
4
+ import { createTypedDoc } from "./typed-doc.js"
5
+
6
+ const schema = Shape.doc({
7
+ title: Shape.text(),
8
+ count: Shape.counter(),
9
+ users: Shape.record(
10
+ Shape.plain.struct({
11
+ name: Shape.plain.string(),
12
+ }),
13
+ ),
14
+ })
15
+
16
+ describe("functional helpers", () => {
17
+ describe("change()", () => {
18
+ it("should batch multiple mutations into a single transaction", () => {
19
+ const doc = createTypedDoc(schema)
20
+
21
+ change(doc, draft => {
22
+ draft.title.insert(0, "Hello")
23
+ draft.count.increment(5)
24
+ draft.users.set("alice", { name: "Alice" })
25
+ })
26
+
27
+ expect(doc.toJSON().title).toBe("Hello")
28
+ expect(doc.toJSON().count).toBe(5)
29
+ expect(doc.toJSON().users.alice).toEqual({ name: "Alice" })
30
+ })
31
+
32
+ it("should return the doc for chaining", () => {
33
+ const doc = createTypedDoc(schema)
34
+
35
+ const result = change(doc, draft => {
36
+ draft.title.insert(0, "Test")
37
+ draft.count.increment(10)
38
+ })
39
+
40
+ // change() returns the doc for chaining
41
+ expect(result).toBe(doc)
42
+ expect(result.toJSON().title).toBe("Test")
43
+ expect(result.toJSON().count).toBe(10)
44
+ })
45
+
46
+ it("should support chaining mutations", () => {
47
+ const doc = createTypedDoc(schema)
48
+
49
+ // Chain mutations after batch
50
+ change(doc, draft => {
51
+ draft.count.increment(5)
52
+ }).count.increment(3)
53
+
54
+ expect(doc.toJSON().count).toBe(8)
55
+ })
56
+
57
+ it("should support fluent API with toJSON at the end", () => {
58
+ const doc = createTypedDoc(schema)
59
+
60
+ // Fluent API: change -> mutate -> toJSON
61
+ const json = change(doc, draft => {
62
+ draft.title.insert(0, "Hello")
63
+ }).toJSON()
64
+
65
+ expect(json.title).toBe("Hello")
66
+ })
67
+
68
+ it("should commit all changes as one transaction", () => {
69
+ const doc = createTypedDoc(schema)
70
+ const loroDoc = getLoroDoc(doc)
71
+
72
+ const versionBefore = loroDoc.version()
73
+
74
+ change(doc, draft => {
75
+ draft.count.increment(1)
76
+ draft.count.increment(2)
77
+ draft.count.increment(3)
78
+ })
79
+
80
+ const versionAfter = loroDoc.version()
81
+
82
+ // Version should have changed (one commit)
83
+ expect(versionAfter).not.toEqual(versionBefore)
84
+ expect(doc.toJSON().count).toBe(6)
85
+ })
86
+ })
87
+
88
+ describe("getLoroDoc()", () => {
89
+ it("should return the underlying LoroDoc", () => {
90
+ const doc = createTypedDoc(schema)
91
+ const loroDoc = getLoroDoc(doc)
92
+
93
+ expect(loroDoc).toBeDefined()
94
+ expect(typeof loroDoc.version).toBe("function")
95
+ expect(typeof loroDoc.subscribe).toBe("function")
96
+ })
97
+
98
+ it("should return the same LoroDoc as doc.$.loroDoc", () => {
99
+ const doc = createTypedDoc(schema)
100
+
101
+ expect(getLoroDoc(doc)).toBe(doc.$.loroDoc)
102
+ })
103
+ })
104
+
105
+ describe("doc.toJSON()", () => {
106
+ it("should work directly on the doc", () => {
107
+ const doc = createTypedDoc(schema)
108
+
109
+ doc.title.insert(0, "Hello")
110
+ doc.count.increment(5)
111
+
112
+ const json = doc.toJSON()
113
+
114
+ expect(json.title).toBe("Hello")
115
+ expect(json.count).toBe(5)
116
+ })
117
+
118
+ it("should work on refs", () => {
119
+ const doc = createTypedDoc(schema)
120
+
121
+ doc.users.set("alice", { name: "Alice" })
122
+ doc.users.set("bob", { name: "Bob" })
123
+
124
+ // toJSON on the record ref
125
+ const usersJson = doc.users.toJSON()
126
+ expect(usersJson).toEqual({
127
+ alice: { name: "Alice" },
128
+ bob: { name: "Bob" },
129
+ })
130
+
131
+ // toJSON on counter ref
132
+ doc.count.increment(10)
133
+ expect(doc.count.toJSON()).toBe(10)
134
+
135
+ // toJSON on text ref
136
+ doc.title.insert(0, "Test")
137
+ expect(doc.title.toJSON()).toBe("Test")
138
+ })
139
+
140
+ it("should be equivalent to doc.toJSON()", () => {
141
+ const doc = createTypedDoc(schema)
142
+
143
+ doc.title.insert(0, "Hello")
144
+ doc.count.increment(5)
145
+
146
+ expect(doc.toJSON()).toEqual(doc.toJSON())
147
+ })
148
+ })
149
+ })
@@ -0,0 +1,61 @@
1
+ import type { LoroDoc } from "loro-crdt"
2
+ import type { DocShape } from "./shape.js"
3
+ import type { TypedDoc } from "./typed-doc.js"
4
+ import type { Mutable } from "./types.js"
5
+
6
+ /**
7
+ * The primary method of mutating typed documents.
8
+ * Batches multiple mutations into a single transaction.
9
+ * All changes commit together at the end.
10
+ *
11
+ * Use this for:
12
+ * - Find-and-mutate operations (required due to JS limitations)
13
+ * - Performance (fewer commits)
14
+ * - Atomic undo (all changes = one undo step)
15
+ *
16
+ * Returns the doc for chaining.
17
+ *
18
+ * @param doc - The TypedDoc to mutate
19
+ * @param fn - Function that performs mutations on the draft
20
+ * @returns The same TypedDoc for chaining
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * import { change } from "@loro-extended/change"
25
+ *
26
+ * // Chainable API
27
+ * change(doc, draft => {
28
+ * draft.count.increment(10)
29
+ * draft.title.update("Hello")
30
+ * })
31
+ * .count.increment(5) // Optional: continue mutating
32
+ * .toJSON() // Optional: get last item snapshot when needed
33
+ * ```
34
+ */
35
+ export function change<Shape extends DocShape>(
36
+ doc: TypedDoc<Shape>,
37
+ fn: (draft: Mutable<Shape>) => void,
38
+ ): TypedDoc<Shape> {
39
+ return doc.$.change(fn)
40
+ }
41
+
42
+ /**
43
+ * Access the underlying LoroDoc for advanced operations.
44
+ *
45
+ * @param doc - The TypedDoc to unwrap
46
+ * @returns The underlying LoroDoc instance
47
+ *
48
+ * @example
49
+ * ```typescript
50
+ * import { getLoroDoc } from "@loro-extended/change"
51
+ *
52
+ * const loroDoc = getLoroDoc(doc)
53
+ * const version = loroDoc.version()
54
+ * loroDoc.subscribe(() => console.log("changed"))
55
+ * ```
56
+ */
57
+ export function getLoroDoc<Shape extends DocShape>(
58
+ doc: TypedDoc<Shape>,
59
+ ): LoroDoc {
60
+ return doc.$.loroDoc
61
+ }