@loro-extended/change 2.0.0 → 4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loro-extended/change",
3
- "version": "2.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "A schema-driven, type-safe wrapper for Loro CRDT that provides natural JavaScript syntax for collaborative data mutations",
5
5
  "author": "Duane Johnson",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -61,6 +61,8 @@ export type {
61
61
  Infer,
62
62
  InferMutableType,
63
63
  InferPlaceholderType,
64
+ // InferRaw<T> preserves type alias names (like TreeNodeJSON) in hover displays
65
+ InferRaw,
64
66
  Mutable,
65
67
  } from "./types.js"
66
68
  // Utility exports
package/src/overlay.ts CHANGED
@@ -1,9 +1,12 @@
1
- import type { Value } from "loro-crdt"
1
+ import type { TreeID, Value } from "loro-crdt"
2
2
  import { deriveShapePlaceholder } from "./derive-placeholder.js"
3
3
  import type {
4
4
  ContainerShape,
5
5
  DiscriminatedUnionValueShape,
6
6
  DocShape,
7
+ StructContainerShape,
8
+ TreeContainerShape,
9
+ TreeNodeJSON,
7
10
  ValueShape,
8
11
  } from "./shape.js"
9
12
  import { isObjectValue } from "./utils/type-guards.js"
@@ -110,8 +113,14 @@ export function mergeValue<Shape extends ContainerShape | ValueShape>(
110
113
 
111
114
  return result
112
115
  }
113
- case "tree":
114
- return crdtValue !== undefined ? crdtValue : (placeholderValue ?? [])
116
+ case "tree": {
117
+ if (crdtValue === undefined) {
118
+ return placeholderValue ?? []
119
+ }
120
+ // Transform Loro's native tree format to our typed format
121
+ const treeShape = shape as TreeContainerShape
122
+ return transformTreeNodes(crdtValue as any[], treeShape.shape) as any
123
+ }
115
124
  case "record": {
116
125
  if (!isObjectValue(crdtValue) && crdtValue !== undefined) {
117
126
  throw new Error("record crdt must be object")
@@ -206,3 +215,53 @@ function mergeDiscriminatedUnion(
206
215
 
207
216
  return mergeValue(variantShape, crdtValue, effectivePlaceholderValue as Value)
208
217
  }
218
+
219
+ /**
220
+ * Loro's native tree node format from toJSON()
221
+ */
222
+ interface LoroTreeNodeJSON {
223
+ id: string
224
+ parent: string | null
225
+ index: number
226
+ fractional_index: string
227
+ meta: Record<string, Value>
228
+ children: LoroTreeNodeJSON[]
229
+ }
230
+
231
+ /**
232
+ * Transforms Loro's native tree format to our typed TreeNodeJSON format.
233
+ * - Renames `meta` to `data`
234
+ * - Renames `fractional_index` to `fractionalIndex`
235
+ * - Applies placeholder merging to node data
236
+ */
237
+ function transformTreeNodes<DataShape extends StructContainerShape>(
238
+ nodes: LoroTreeNodeJSON[],
239
+ dataShape: DataShape,
240
+ ): TreeNodeJSON<DataShape>[] {
241
+ const dataPlaceholder = deriveShapePlaceholder(dataShape) as Value
242
+
243
+ return nodes.map(node => transformTreeNode(node, dataShape, dataPlaceholder))
244
+ }
245
+
246
+ /**
247
+ * Transforms a single tree node and its children recursively.
248
+ */
249
+ function transformTreeNode<DataShape extends StructContainerShape>(
250
+ node: LoroTreeNodeJSON,
251
+ dataShape: DataShape,
252
+ dataPlaceholder: Value,
253
+ ): TreeNodeJSON<DataShape> {
254
+ // Merge the node's meta (data) with the placeholder
255
+ const mergedData = mergeValue(dataShape, node.meta, dataPlaceholder)
256
+
257
+ return {
258
+ id: node.id as TreeID,
259
+ parent: node.parent as TreeID | null,
260
+ index: node.index,
261
+ fractionalIndex: node.fractional_index,
262
+ data: mergedData as DataShape["_plain"],
263
+ children: node.children.map(child =>
264
+ transformTreeNode(child, dataShape, dataPlaceholder),
265
+ ),
266
+ }
267
+ }
package/src/shape.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  LoroMovableList,
8
8
  LoroText,
9
9
  LoroTree,
10
+ TreeID,
10
11
  Value,
11
12
  } from "loro-crdt"
12
13
 
@@ -17,6 +18,9 @@ import type { RecordRef } from "./typed-refs/record.js"
17
18
  import type { StructRef } from "./typed-refs/struct.js"
18
19
  import type { TextRef } from "./typed-refs/text.js"
19
20
 
21
+ // Note: TreeRef is not imported here to avoid circular dependency.
22
+ // The TreeContainerShape uses a placeholder type that gets resolved at runtime.
23
+
20
24
  export interface Shape<Plain, Mutable, Placeholder = Plain> {
21
25
  readonly _type: string
22
26
  readonly _plain: Plain
@@ -59,11 +63,48 @@ export interface CounterContainerShape
59
63
  extends Shape<number, CounterRef, number> {
60
64
  readonly _type: "counter"
61
65
  }
62
- export interface TreeContainerShape<NestedShape = ContainerOrValueShape>
63
- extends Shape<any, any, never[]> {
66
+ /**
67
+ * JSON representation of a tree node with typed data.
68
+ * Used for serialization (toJSON) of tree structures.
69
+ */
70
+ export type TreeNodeJSON<DataShape extends StructContainerShape> = {
71
+ id: TreeID
72
+ parent: TreeID | null
73
+ index: number
74
+ fractionalIndex: string
75
+ data: DataShape["_plain"]
76
+ children: TreeNodeJSON<DataShape>[]
77
+ }
78
+
79
+ /**
80
+ * Container shape for tree (forest) structures.
81
+ * Each node in the tree has typed metadata stored in a LoroMap.
82
+ *
83
+ * Note: The Mutable type (second generic parameter) is `any` here to avoid
84
+ * circular dependency with TreeRef. The actual type is resolved at runtime
85
+ * and through the InferMutableType helper.
86
+ *
87
+ * @example
88
+ * ```typescript
89
+ * const StateNodeDataShape = Shape.struct({
90
+ * name: Shape.text(),
91
+ * facts: Shape.record(Shape.plain.any()),
92
+ * })
93
+ *
94
+ * const Schema = Shape.doc({
95
+ * states: Shape.tree(StateNodeDataShape),
96
+ * })
97
+ * ```
98
+ */
99
+ export interface TreeContainerShape<
100
+ DataShape extends StructContainerShape = StructContainerShape,
101
+ > extends Shape<TreeNodeJSON<DataShape>[], any, never[]> {
64
102
  readonly _type: "tree"
65
- // TODO(duane): What does a tree contain? One type, or many?
66
- readonly shape: NestedShape
103
+ /**
104
+ * The shape of each node's data (metadata).
105
+ * This is a StructContainerShape that defines the typed properties on node.data.
106
+ */
107
+ readonly shape: DataShape
67
108
  }
68
109
 
69
110
  // Container schemas using interfaces for recursive references
@@ -469,12 +510,32 @@ export const Shape = {
469
510
  })
470
511
  },
471
512
 
472
- tree: <T extends MapContainerShape | StructContainerShape>(
473
- shape: T,
474
- ): TreeContainerShape => ({
513
+ /**
514
+ * Creates a tree container shape for hierarchical data structures.
515
+ * Each node in the tree has typed metadata defined by the data shape.
516
+ *
517
+ * @example
518
+ * ```typescript
519
+ * const StateNodeDataShape = Shape.struct({
520
+ * name: Shape.text(),
521
+ * facts: Shape.record(Shape.plain.any()),
522
+ * })
523
+ *
524
+ * const Schema = Shape.doc({
525
+ * states: Shape.tree(StateNodeDataShape),
526
+ * })
527
+ *
528
+ * doc.$.change(draft => {
529
+ * const root = draft.states.createNode({ name: "idle", facts: {} })
530
+ * const child = root.createNode({ name: "running", facts: {} })
531
+ * child.data.name = "active"
532
+ * })
533
+ * ```
534
+ */
535
+ tree: <T extends StructContainerShape>(shape: T): TreeContainerShape<T> => ({
475
536
  _type: "tree" as const,
476
537
  shape,
477
- _plain: {} as any,
538
+ _plain: [] as any,
478
539
  _mutable: {} as any,
479
540
  _placeholder: [] as never[],
480
541
  }),
package/src/typed-doc.ts CHANGED
@@ -130,6 +130,7 @@ class TypedDocInternal<Shape extends DocShape> {
130
130
  placeholder: this.placeholder as any,
131
131
  doc: this.doc,
132
132
  autoCommit: false,
133
+ batchedMutation: true, // Enable value shape caching for find-and-mutate patterns
133
134
  })
134
135
  fn(draft as unknown as Mutable<Shape>)
135
136
  draft.absorbPlainValues()
@@ -6,9 +6,9 @@ export type TypedRefParams<Shape extends DocShape | ContainerShape> = {
6
6
  shape: Shape
7
7
  placeholder?: Infer<Shape>
8
8
  getContainer: () => ShapeToContainer<Shape>
9
- readonly?: boolean // DEPRECATED - remove in future
10
- autoCommit?: boolean // NEW: auto-commit after mutations
11
- getDoc?: () => LoroDoc // NEW: needed for auto-commit
9
+ autoCommit?: boolean // Auto-commit after mutations
10
+ batchedMutation?: boolean // True when inside change() block - enables value shape caching for find-and-mutate patterns
11
+ getDoc?: () => LoroDoc // Needed for auto-commit
12
12
  }
13
13
 
14
14
  // Base class for all typed refs
@@ -33,14 +33,14 @@ export abstract class TypedRef<Shape extends DocShape | ContainerShape> {
33
33
  return this._params.placeholder
34
34
  }
35
35
 
36
- protected get readonly(): boolean {
37
- return !!this._params.readonly
38
- }
39
-
40
36
  protected get autoCommit(): boolean {
41
37
  return !!this._params.autoCommit
42
38
  }
43
39
 
40
+ protected get batchedMutation(): boolean {
41
+ return !!this._params.batchedMutation
42
+ }
43
+
44
44
  protected get doc(): LoroDoc | undefined {
45
45
  return this._params.getDoc?.()
46
46
  }
@@ -55,17 +55,6 @@ export abstract class TypedRef<Shape extends DocShape | ContainerShape> {
55
55
  }
56
56
  }
57
57
 
58
- /**
59
- * Throws an error if this ref is in readonly mode.
60
- * Call this at the start of any mutating method.
61
- * @deprecated Mutations are always allowed now; this will be removed.
62
- */
63
- protected assertMutable(): void {
64
- if (this.readonly) {
65
- throw new Error("Cannot modify readonly ref")
66
- }
67
- }
68
-
69
58
  protected get container(): ShapeToContainer<Shape> {
70
59
  if (!this._cachedContainer) {
71
60
  const container = this._params.getContainer()
@@ -16,14 +16,12 @@ export class CounterRef extends TypedRef<CounterContainerShape> {
16
16
  }
17
17
 
18
18
  increment(value: number = 1): void {
19
- this.assertMutable()
20
19
  this._materialized = true
21
20
  this.container.increment(value)
22
21
  this.commitIfAuto()
23
22
  }
24
23
 
25
24
  decrement(value: number = 1): void {
26
- this.assertMutable()
27
25
  this._materialized = true
28
26
  this.container.decrement(value)
29
27
  this.commitIfAuto()
@@ -2,11 +2,7 @@ import type { LoroDoc } from "loro-crdt"
2
2
  import type { Infer } from "../index.js"
3
3
  import type { ContainerShape, DocShape } from "../shape.js"
4
4
  import { TypedRef, type TypedRefParams } from "./base.js"
5
- import {
6
- createContainerTypedRef,
7
- serializeRefToJSON,
8
- unwrapReadonlyPrimitive,
9
- } from "./utils.js"
5
+ import { createContainerTypedRef, serializeRefToJSON } from "./utils.js"
10
6
 
11
7
  const containerGetter = {
12
8
  counter: "getCounter",
@@ -30,6 +26,7 @@ export class DocRef<Shape extends DocShape> extends TypedRef<Shape> {
30
26
  _params: Omit<TypedRefParams<Shape>, "getContainer" | "getDoc"> & {
31
27
  doc: LoroDoc
32
28
  autoCommit?: boolean
29
+ batchedMutation?: boolean
33
30
  },
34
31
  ) {
35
32
  super({
@@ -64,8 +61,8 @@ export class DocRef<Shape extends DocShape> extends TypedRef<Shape> {
64
61
  shape,
65
62
  placeholder: this.requiredPlaceholder[key],
66
63
  getContainer: () => getter(key),
67
- readonly: this.readonly,
68
64
  autoCommit: this._params.autoCommit,
65
+ batchedMutation: this.batchedMutation,
69
66
  getDoc: () => this._doc,
70
67
  }
71
68
  }
@@ -74,17 +71,6 @@ export class DocRef<Shape extends DocShape> extends TypedRef<Shape> {
74
71
  key: string,
75
72
  shape: ContainerShape,
76
73
  ): TypedRef<ContainerShape> | number | string {
77
- if (
78
- this.readonly &&
79
- (shape._type === "counter" || shape._type === "text")
80
- ) {
81
- // Check if the container exists in the doc without creating it
82
- const shallow = this._doc.getShallowValue()
83
- if (!shallow[key]) {
84
- return this.requiredPlaceholder[key] as any
85
- }
86
- }
87
-
88
74
  let ref = this.propertyCache.get(key)
89
75
 
90
76
  if (!ref) {
@@ -92,10 +78,6 @@ export class DocRef<Shape extends DocShape> extends TypedRef<Shape> {
92
78
  this.propertyCache.set(key, ref)
93
79
  }
94
80
 
95
- if (this.readonly) {
96
- return unwrapReadonlyPrimitive(ref, shape)
97
- }
98
-
99
81
  return ref
100
82
  }
101
83
 
@@ -14,7 +14,7 @@ import {
14
14
  isValueShape,
15
15
  } from "../utils/type-guards.js"
16
16
  import { TypedRef, type TypedRefParams } from "./base.js"
17
- import { createContainerTypedRef, unwrapReadonlyPrimitive } from "./utils.js"
17
+ import { createContainerTypedRef } from "./utils.js"
18
18
 
19
19
  // Shared logic for list operations
20
20
  export abstract class ListRefBase<
@@ -98,8 +98,8 @@ export abstract class ListRefBase<
98
98
  }
99
99
  return containerItem
100
100
  },
101
- readonly: this.readonly,
102
101
  autoCommit: this._params.autoCommit,
102
+ batchedMutation: this.batchedMutation,
103
103
  getDoc: this._params.getDoc,
104
104
  }
105
105
  }
@@ -152,12 +152,6 @@ export abstract class ListRefBase<
152
152
 
153
153
  // Get item for return values - returns MutableItem that can be mutated
154
154
  protected getMutableItem(index: number): any {
155
- // Check if we already have a cached item for this index
156
- let cachedItem = this.itemCache.get(index)
157
- if (cachedItem) {
158
- return cachedItem
159
- }
160
-
161
155
  // Get the raw container item
162
156
  const containerItem = this.container.get(index)
163
157
  if (containerItem === undefined) {
@@ -165,6 +159,24 @@ export abstract class ListRefBase<
165
159
  }
166
160
 
167
161
  if (isValueShape(this.shape.shape)) {
162
+ // When NOT in batchedMutation mode (direct access outside of change()), ALWAYS read fresh
163
+ // from container (NEVER cache). This ensures we always get the latest value
164
+ // from the CRDT, even when modified by a different ref instance (e.g., drafts from change())
165
+ //
166
+ // When in batchedMutation mode (inside change()), we cache value shapes so that
167
+ // mutations to found/filtered items persist back to the CRDT via absorbPlainValues()
168
+ if (!this.batchedMutation) {
169
+ return containerItem as MutableItem
170
+ }
171
+
172
+ // In batched mode (within change()), we need to cache value shapes
173
+ // so that mutations to found/filtered items persist back to the CRDT
174
+ // via absorbPlainValues() at the end of change()
175
+ let cachedItem = this.itemCache.get(index)
176
+ if (cachedItem) {
177
+ return cachedItem
178
+ }
179
+
168
180
  // For value shapes, we need to ensure mutations persist
169
181
  // The key insight: we must return the SAME object for the same index
170
182
  // so that mutations to filtered/found items persist back to the cache
@@ -176,28 +188,20 @@ export abstract class ListRefBase<
176
188
  // For primitives, just use the value directly
177
189
  cachedItem = containerItem
178
190
  }
179
- // Only cache primitive values if NOT readonly
180
- if (!this.readonly) {
181
- this.itemCache.set(index, cachedItem)
182
- }
191
+ this.itemCache.set(index, cachedItem)
183
192
  return cachedItem as MutableItem
184
- } else {
185
- // For container shapes, create a proper typed ref using the new pattern
193
+ }
194
+
195
+ // Container shapes: safe to cache (handles)
196
+ let cachedItem = this.itemCache.get(index)
197
+ if (!cachedItem) {
186
198
  cachedItem = createContainerTypedRef(
187
199
  this.getTypedRefParams(index, this.shape.shape as ContainerShape),
188
200
  )
189
- // Cache container refs
190
201
  this.itemCache.set(index, cachedItem)
191
-
192
- if (this.readonly) {
193
- return unwrapReadonlyPrimitive(
194
- cachedItem,
195
- this.shape.shape as ContainerShape,
196
- )
197
- }
198
-
199
- return cachedItem as MutableItem
200
202
  }
203
+
204
+ return cachedItem as MutableItem
201
205
  }
202
206
 
203
207
  // Array-like methods for better developer experience
@@ -301,7 +305,6 @@ export abstract class ListRefBase<
301
305
  }
302
306
 
303
307
  insert(index: number, item: Item): void {
304
- this.assertMutable()
305
308
  // Update cache indices before performing the insert operation
306
309
  this.updateCacheForInsert(index)
307
310
  this.insertWithConversion(index, item)
@@ -309,7 +312,6 @@ export abstract class ListRefBase<
309
312
  }
310
313
 
311
314
  delete(index: number, len: number): void {
312
- this.assertMutable()
313
315
  // Update cache indices before performing the delete operation
314
316
  this.updateCacheForDelete(index, len)
315
317
  this.container.delete(index, len)
@@ -317,20 +319,17 @@ export abstract class ListRefBase<
317
319
  }
318
320
 
319
321
  push(item: Item): void {
320
- this.assertMutable()
321
322
  this.pushWithConversion(item)
322
323
  this.commitIfAuto()
323
324
  }
324
325
 
325
326
  pushContainer(container: Container): Container {
326
- this.assertMutable()
327
327
  const result = this.container.pushContainer(container)
328
328
  this.commitIfAuto()
329
329
  return result
330
330
  }
331
331
 
332
332
  insertContainer(index: number, container: Container): Container {
333
- this.assertMutable()
334
333
  const result = this.container.insertContainer(index, container)
335
334
  this.commitIfAuto()
336
335
  return result
@@ -0,0 +1,213 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { change, createTypedDoc, Shape } from "../index.js"
3
+
4
+ /**
5
+ * Tests for List value updates across multiple change() calls.
6
+ *
7
+ * ListRefBase has a different caching pattern than RecordRef/StructRef:
8
+ * - It caches items in itemCache
9
+ * - The cache is cleared in absorbPlainValues() after each change()
10
+ *
11
+ * However, there may still be stale cache issues if:
12
+ * 1. Items are accessed outside of change() (populating the cache)
13
+ * 2. Items are modified in a change() (different list instance)
14
+ * 3. Items are accessed again outside of change() (stale cache?)
15
+ *
16
+ * Note: Lists don't support direct item modification like records/structs.
17
+ * To "update" an item, you typically delete and re-insert, or modify
18
+ * nested container properties.
19
+ */
20
+ describe("List value updates across change() calls", () => {
21
+ describe("primitive value lists", () => {
22
+ it("reads updated values after delete and insert", () => {
23
+ const Schema = Shape.doc({
24
+ numbers: Shape.list(Shape.plain.number()),
25
+ })
26
+
27
+ const doc = createTypedDoc(Schema)
28
+
29
+ change(doc, draft => {
30
+ draft.numbers.push(100)
31
+ draft.numbers.push(200)
32
+ draft.numbers.push(300)
33
+ })
34
+ expect(doc.numbers.get(0)).toBe(100)
35
+ expect(doc.numbers.get(1)).toBe(200)
36
+ expect(doc.numbers.get(2)).toBe(300)
37
+
38
+ // Modify by deleting and inserting
39
+ change(doc, draft => {
40
+ draft.numbers.delete(1, 1) // Remove 200
41
+ draft.numbers.insert(1, 999) // Insert 999 at position 1
42
+ })
43
+ expect(doc.numbers.get(0)).toBe(100)
44
+ expect(doc.numbers.get(1)).toBe(999) // Should be 999, not 200
45
+ expect(doc.numbers.get(2)).toBe(300)
46
+ })
47
+
48
+ it("reads correct values after multiple push operations", () => {
49
+ const Schema = Shape.doc({
50
+ items: Shape.list(Shape.plain.string()),
51
+ })
52
+
53
+ const doc = createTypedDoc(Schema)
54
+
55
+ change(doc, draft => {
56
+ draft.items.push("first")
57
+ })
58
+ expect(doc.items.get(0)).toBe("first")
59
+
60
+ change(doc, draft => {
61
+ draft.items.push("second")
62
+ })
63
+ expect(doc.items.get(0)).toBe("first")
64
+ expect(doc.items.get(1)).toBe("second")
65
+
66
+ change(doc, draft => {
67
+ draft.items.push("third")
68
+ })
69
+ expect(doc.items.get(0)).toBe("first")
70
+ expect(doc.items.get(1)).toBe("second")
71
+ expect(doc.items.get(2)).toBe("third")
72
+ })
73
+ })
74
+
75
+ describe("object value lists", () => {
76
+ it("reads updated object values after modification", () => {
77
+ const Schema = Shape.doc({
78
+ items: Shape.list(
79
+ Shape.plain.object({
80
+ name: Shape.plain.string(),
81
+ value: Shape.plain.number(),
82
+ }),
83
+ ),
84
+ })
85
+
86
+ const doc = createTypedDoc(Schema)
87
+
88
+ change(doc, draft => {
89
+ draft.items.push({ name: "item1", value: 100 })
90
+ })
91
+
92
+ // Access item outside of change() - this may populate cache
93
+ const item0 = doc.items.get(0)
94
+ expect(item0?.name).toBe("item1")
95
+ expect(item0?.value).toBe(100)
96
+
97
+ // Modify by replacing the item
98
+ change(doc, draft => {
99
+ draft.items.delete(0, 1)
100
+ draft.items.insert(0, { name: "updated", value: 999 })
101
+ })
102
+
103
+ // Read again - should see updated values
104
+ const item0After = doc.items.get(0)
105
+ expect(item0After?.name).toBe("updated")
106
+ expect(item0After?.value).toBe(999)
107
+ })
108
+ })
109
+
110
+ describe("list of structs (container shapes)", () => {
111
+ it("reads updated struct properties after modification", () => {
112
+ const Schema = Shape.doc({
113
+ users: Shape.list(
114
+ Shape.struct({
115
+ name: Shape.plain.string(),
116
+ age: Shape.plain.number(),
117
+ }),
118
+ ),
119
+ })
120
+
121
+ const doc = createTypedDoc(Schema)
122
+
123
+ change(doc, draft => {
124
+ draft.users.push({ name: "Alice", age: 30 })
125
+ })
126
+
127
+ // Access outside of change()
128
+ expect(doc.users.get(0)?.name).toBe("Alice")
129
+ expect(doc.users.get(0)?.age).toBe(30)
130
+
131
+ // Modify the struct's properties in a new change()
132
+ change(doc, draft => {
133
+ const user = draft.users.get(0)
134
+ if (user) {
135
+ user.name = "Bob"
136
+ user.age = 25
137
+ }
138
+ })
139
+
140
+ // Read again - should see updated values
141
+ // This tests if the cached StructRef returns stale values
142
+ expect(doc.users.get(0)?.name).toBe("Bob") // May fail due to StructRef cache
143
+ expect(doc.users.get(0)?.age).toBe(25) // May fail due to StructRef cache
144
+ })
145
+
146
+ it("handles multiple updates to same struct in list", () => {
147
+ const Schema = Shape.doc({
148
+ items: Shape.list(
149
+ Shape.struct({
150
+ count: Shape.plain.number(),
151
+ }),
152
+ ),
153
+ })
154
+
155
+ const doc = createTypedDoc(Schema)
156
+
157
+ change(doc, draft => {
158
+ draft.items.push({ count: 0 })
159
+ })
160
+
161
+ // Multiple updates
162
+ for (let i = 1; i <= 5; i++) {
163
+ change(doc, draft => {
164
+ const item = draft.items.get(0)
165
+ if (item) {
166
+ item.count = i
167
+ }
168
+ })
169
+ expect(doc.items.get(0)?.count).toBe(i) // May fail on i > 1
170
+ }
171
+ })
172
+ })
173
+
174
+ describe("toJSON() consistency", () => {
175
+ it("reflects updates in toJSON()", () => {
176
+ const Schema = Shape.doc({
177
+ values: Shape.list(Shape.plain.number()),
178
+ })
179
+
180
+ const doc = createTypedDoc(Schema)
181
+
182
+ change(doc, draft => {
183
+ draft.values.push(1)
184
+ draft.values.push(2)
185
+ })
186
+ expect(doc.toJSON().values).toEqual([1, 2])
187
+
188
+ change(doc, draft => {
189
+ draft.values.delete(0, 1)
190
+ draft.values.insert(0, 99)
191
+ })
192
+ expect(doc.toJSON().values).toEqual([99, 2])
193
+ })
194
+ })
195
+
196
+ describe("comparison with raw LoroDoc", () => {
197
+ it("underlying CRDT operations work correctly", async () => {
198
+ const { LoroDoc } = await import("loro-crdt")
199
+
200
+ const doc = new LoroDoc()
201
+ const list = doc.getList("items")
202
+
203
+ list.push(100)
204
+ doc.commit()
205
+ expect(list.get(0)).toBe(100)
206
+
207
+ list.delete(0, 1)
208
+ list.insert(0, 999)
209
+ doc.commit()
210
+ expect(list.get(0)).toBe(999) // PASSES: raw Loro works fine
211
+ })
212
+ })
213
+ })