@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
@@ -298,7 +298,7 @@ describe("Conversion Functions", () => {
298
298
 
299
299
  describe("convertInputToNode - Map Conversion", () => {
300
300
  it("should convert object to LoroMap with value properties", () => {
301
- const shape = Shape.map({
301
+ const shape = Shape.struct({
302
302
  name: Shape.plain.string(),
303
303
  age: Shape.plain.number(),
304
304
  active: Shape.plain.boolean(),
@@ -322,7 +322,7 @@ describe("Conversion Functions", () => {
322
322
  })
323
323
 
324
324
  it("should convert object to LoroMap with container properties", () => {
325
- const shape = Shape.map({
325
+ const shape = Shape.struct({
326
326
  title: Shape.text(),
327
327
  count: Shape.counter(),
328
328
  })
@@ -343,7 +343,7 @@ describe("Conversion Functions", () => {
343
343
  })
344
344
 
345
345
  it("should handle empty object", () => {
346
- const shape = Shape.map({})
346
+ const shape = Shape.struct({})
347
347
  const result = convertInputToRef({}, shape)
348
348
 
349
349
  expect(isLoroMap(result as any)).toBe(true)
@@ -352,7 +352,7 @@ describe("Conversion Functions", () => {
352
352
  })
353
353
 
354
354
  it("should handle object with extra properties not in schema", () => {
355
- const shape = Shape.map({
355
+ const shape = Shape.struct({
356
356
  name: Shape.plain.string(),
357
357
  })
358
358
 
@@ -371,10 +371,10 @@ describe("Conversion Functions", () => {
371
371
  })
372
372
 
373
373
  it("should handle nested map structures", () => {
374
- const shape = Shape.map({
375
- user: Shape.map({
374
+ const shape = Shape.struct({
375
+ user: Shape.struct({
376
376
  name: Shape.plain.string(),
377
- profile: Shape.map({
377
+ profile: Shape.struct({
378
378
  bio: Shape.text(),
379
379
  }),
380
380
  }),
@@ -398,7 +398,7 @@ describe("Conversion Functions", () => {
398
398
  })
399
399
 
400
400
  it("should return plain object for value shape", () => {
401
- const shape = Shape.plain.object({
401
+ const shape = Shape.plain.struct({
402
402
  name: Shape.plain.string(),
403
403
  age: Shape.plain.number(),
404
404
  })
@@ -412,7 +412,7 @@ describe("Conversion Functions", () => {
412
412
  })
413
413
 
414
414
  it("should throw error for non-object input", () => {
415
- const shape = Shape.map({
415
+ const shape = Shape.struct({
416
416
  name: Shape.plain.string(),
417
417
  })
418
418
 
@@ -446,7 +446,7 @@ describe("Conversion Functions", () => {
446
446
 
447
447
  it("should handle complex value shapes", () => {
448
448
  const arrayShape = Shape.plain.array(Shape.plain.string())
449
- const objectShape = Shape.plain.object({
449
+ const objectShape = Shape.plain.struct({
450
450
  name: Shape.plain.string(),
451
451
  count: Shape.plain.number(),
452
452
  })
@@ -470,7 +470,7 @@ describe("Conversion Functions", () => {
470
470
 
471
471
  describe("convertInputToNode - Error Cases", () => {
472
472
  it("should throw error for tree type (unimplemented)", () => {
473
- const shape = Shape.tree(Shape.map({}))
473
+ const shape = Shape.tree(Shape.struct({}))
474
474
 
475
475
  expect(() => convertInputToRef({}, shape)).toThrow(
476
476
  "tree type unimplemented",
@@ -489,9 +489,9 @@ describe("Conversion Functions", () => {
489
489
  describe("convertInputToNode - Complex Nested Structures", () => {
490
490
  it("should handle deeply nested container structures", () => {
491
491
  const shape = Shape.list(
492
- Shape.map({
492
+ Shape.struct({
493
493
  title: Shape.text(),
494
- metadata: Shape.map({
494
+ metadata: Shape.struct({
495
495
  views: Shape.counter(),
496
496
  tags: Shape.list(Shape.plain.string()),
497
497
  }),
@@ -523,12 +523,12 @@ describe("Conversion Functions", () => {
523
523
  })
524
524
 
525
525
  it("should handle mixed container and value types", () => {
526
- const shape = Shape.map({
526
+ const shape = Shape.struct({
527
527
  plainString: Shape.plain.string(),
528
528
  plainArray: Shape.plain.array(Shape.plain.number()),
529
529
  loroText: Shape.text(),
530
530
  loroList: Shape.list(Shape.plain.string()),
531
- nestedMap: Shape.map({
531
+ nestedMap: Shape.struct({
532
532
  counter: Shape.counter(),
533
533
  plainBool: Shape.plain.boolean(),
534
534
  }),
@@ -573,7 +573,7 @@ describe("Conversion Functions", () => {
573
573
 
574
574
  it("should handle movable lists with complex items", () => {
575
575
  const shape = Shape.movableList(
576
- Shape.map({
576
+ Shape.struct({
577
577
  id: Shape.plain.string(),
578
578
  title: Shape.text(),
579
579
  completed: Shape.plain.boolean(),
@@ -604,7 +604,7 @@ describe("Conversion Functions", () => {
604
604
 
605
605
  it("should handle empty containers", () => {
606
606
  const emptyListShape = Shape.list(Shape.plain.string())
607
- const emptyMapShape = Shape.map({})
607
+ const emptyMapShape = Shape.struct({})
608
608
  const emptyMovableListShape = Shape.movableList(Shape.plain.number())
609
609
 
610
610
  const emptyList = convertInputToRef([], emptyListShape)
@@ -662,7 +662,7 @@ describe("Conversion Functions", () => {
662
662
  input[`prop${i}`] = `value${i}`
663
663
  }
664
664
 
665
- const shape = Shape.map(shapes)
665
+ const shape = Shape.struct(shapes)
666
666
  const result = convertInputToRef(input, shape)
667
667
 
668
668
  expect(isLoroMap(result as any)).toBe(true)
@@ -708,7 +708,7 @@ describe("Conversion Functions", () => {
708
708
 
709
709
  it("should handle recursive structures without infinite loops", () => {
710
710
  // Test that the conversion doesn't get stuck in infinite recursion
711
- const shape = Shape.map({
711
+ const shape = Shape.struct({
712
712
  name: Shape.plain.string(),
713
713
  children: Shape.list(Shape.plain.string()), // Not recursive, but nested
714
714
  })
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,
@@ -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
@@ -186,12 +186,12 @@ export function convertInputToRef<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
+ })