@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
@@ -1,4 +1,10 @@
1
- import type { LoroCounter, LoroDoc, Subscription } from "loro-crdt"
1
+ import type {
2
+ CounterDiff,
3
+ LoroCounter,
4
+ LoroDoc,
5
+ LoroEventBatch,
6
+ Subscription,
7
+ } from "loro-crdt"
2
8
  import type { LoroCounterRef } from "../loro.js"
3
9
  import type { CounterContainerShape } from "../shape.js"
4
10
  import { BaseRefInternals } from "./base.js"
@@ -28,6 +34,14 @@ export class CounterRefInternals extends BaseRefInternals<CounterContainerShape>
28
34
  getValue(): number {
29
35
  const container = this.getContainer() as LoroCounter
30
36
  const containerValue = container.value
37
+ const overlay = this.getOverlay()
38
+ if (overlay) {
39
+ const diff = overlay.get((container as any).id)
40
+ if (diff && diff.type === "counter") {
41
+ const counterDiff = diff as CounterDiff
42
+ return containerValue + counterDiff.increment
43
+ }
44
+ }
31
45
  if (containerValue !== 0 || this.materialized) {
32
46
  return containerValue
33
47
  }
@@ -54,7 +68,7 @@ export class CounterRefInternals extends BaseRefInternals<CounterContainerShape>
54
68
  get container(): LoroCounter {
55
69
  return self.getContainer() as LoroCounter
56
70
  },
57
- subscribe(callback: (event: unknown) => void): Subscription {
71
+ subscribe(callback: (event: LoroEventBatch) => void): Subscription {
58
72
  return (self.getContainer() as LoroCounter).subscribe(callback)
59
73
  },
60
74
  }
@@ -74,6 +74,7 @@ export class DocRefInternals<
74
74
  autoCommit: this.getAutoCommit(),
75
75
  batchedMutation: this.getBatchedMutation(),
76
76
  getDoc: () => this.doc,
77
+ overlay: this.getOverlay(),
77
78
  }
78
79
  }
79
80
 
@@ -0,0 +1,78 @@
1
+ import { LoroDoc } from "loro-crdt"
2
+ import { describe, expect, it } from "vitest"
3
+ import { derivePlaceholder } from "../derive-placeholder.js"
4
+ import { Shape } from "../index.js"
5
+ import { DocRef } from "./doc-ref.js"
6
+
7
+ /**
8
+ * Tests for DocRef class ownKeys behavior.
9
+ *
10
+ * Issue: DocRef extends TypedRef which has Symbol properties:
11
+ * - [INTERNAL_SYMBOL]: DocRefInternals<Shape>
12
+ * - [LORO_SYMBOL]: getter for loro() access
13
+ *
14
+ * When Reflect.ownKeys() is called on a DocRef instance, it returns these
15
+ * Symbol properties along with the schema keys.
16
+ *
17
+ * This is the underlying cause of the TypedDoc proxy issue - the proxy
18
+ * delegates ownKeys to the DocRef target.
19
+ *
20
+ * Note: DocRef is not a proxy, it's a class. The fix for this is in the
21
+ * TypedDoc proxy's ownKeys trap, which should filter out Symbols.
22
+ */
23
+ describe("DocRef ownKeys", () => {
24
+ const schema = Shape.doc({
25
+ title: Shape.text(),
26
+ count: Shape.counter(),
27
+ })
28
+
29
+ function createDocRef() {
30
+ const loroDoc = new LoroDoc()
31
+ const placeholder = derivePlaceholder(schema)
32
+ return new DocRef({
33
+ shape: schema,
34
+ placeholder,
35
+ doc: loroDoc,
36
+ autoCommit: true,
37
+ })
38
+ }
39
+
40
+ describe("Reflect.ownKeys() on DocRef instance", () => {
41
+ it("returns Symbol properties from the class", () => {
42
+ const docRef = createDocRef()
43
+
44
+ const keys = Reflect.ownKeys(docRef)
45
+
46
+ // DocRef has Symbol properties from TypedRef base class
47
+ // This test documents the current behavior
48
+ const symbolKeys = keys.filter(k => typeof k === "symbol")
49
+ const stringKeys = keys.filter(k => typeof k === "string")
50
+
51
+ // Should have schema keys as strings
52
+ expect(stringKeys).toContain("title")
53
+ expect(stringKeys).toContain("count")
54
+
55
+ // Currently has Symbol keys (this is the root cause of the issue)
56
+ // The TypedDoc proxy should filter these out
57
+ expect(symbolKeys.length).toBeGreaterThan(0)
58
+ })
59
+ })
60
+
61
+ describe("Object.keys() on DocRef instance", () => {
62
+ it("should return only enumerable string keys", () => {
63
+ const docRef = createDocRef()
64
+
65
+ const keys = Object.keys(docRef)
66
+
67
+ // Object.keys only returns enumerable string keys
68
+ // Symbol properties are not enumerable by default
69
+ for (const key of keys) {
70
+ expect(typeof key).toBe("string")
71
+ }
72
+
73
+ // Should include schema keys
74
+ expect(keys).toContain("title")
75
+ expect(keys).toContain("count")
76
+ })
77
+ })
78
+ })
@@ -0,0 +1,17 @@
1
+ export type { DiffOverlay } from "./base.js"
2
+
3
+ export type { CounterRef } from "./counter-ref.js"
4
+
5
+ export type { ListRef } from "./list-ref.js"
6
+
7
+ export type { MovableListRef } from "./movable-list-ref.js"
8
+
9
+ export type { RecordRef } from "./record-ref.js"
10
+
11
+ export type { StructRef } from "./struct-ref.js"
12
+
13
+ export type { TextRef } from "./text-ref.js"
14
+
15
+ export type { TreeNodeRef } from "./tree-node-ref.js"
16
+
17
+ export type { TreeRef } from "./tree-ref.js"
@@ -185,7 +185,7 @@ describe("JSON Compatibility", () => {
185
185
 
186
186
  it("should be efficient (not access unrelated parts)", () => {
187
187
  const loroDoc = new LoroDoc()
188
- const doc = createTypedDoc(ChatSchema, loroDoc)
188
+ const doc = createTypedDoc(ChatSchema, { doc: loroDoc })
189
189
 
190
190
  change(doc, (root: any) => {
191
191
  root.messages.push({ id: "1", content: "A", timestamp: 1 })
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  Container,
3
3
  LoroDoc,
4
+ LoroEventBatch,
4
5
  LoroList,
5
6
  LoroMovableList,
6
7
  Subscription,
@@ -256,7 +257,7 @@ export class ListRefBaseInternals<
256
257
  get container(): LoroList | LoroMovableList {
257
258
  return self.getContainer() as LoroList | LoroMovableList
258
259
  },
259
- subscribe(callback: (event: unknown) => void): Subscription {
260
+ subscribe(callback: (event: LoroEventBatch) => void): Subscription {
260
261
  return (self.getContainer() as LoroList | LoroMovableList).subscribe(
261
262
  callback,
262
263
  )
@@ -1,6 +1,9 @@
1
1
  import type {
2
2
  Container,
3
+ Delta,
4
+ ListDiff,
3
5
  LoroDoc,
6
+ LoroEventBatch,
4
7
  LoroList,
5
8
  LoroMovableList,
6
9
  Subscription,
@@ -37,6 +40,39 @@ export class ListRefBaseInternals<
37
40
  MutableItem = NestedShape["_mutable"],
38
41
  > extends BaseRefInternals<any> {
39
42
  private itemCache = new Map<number, any>()
43
+ private overlayListCache?: Item[]
44
+
45
+ getOverlayList(): Item[] | undefined {
46
+ const overlay = this.getOverlay()
47
+ if (!overlay) {
48
+ return undefined
49
+ }
50
+
51
+ const shape = this.getShape()
52
+ if (!isValueShape(shape.shape)) {
53
+ return undefined
54
+ }
55
+
56
+ if (!this.overlayListCache) {
57
+ const container = this.getContainer() as LoroList | LoroMovableList
58
+ const diff = overlay.get(container.id)
59
+ if (!diff || diff.type !== "list") {
60
+ return undefined
61
+ }
62
+
63
+ const afterValues: Item[] = []
64
+ for (let i = 0; i < container.length; i++) {
65
+ afterValues.push(container.get(i) as Item)
66
+ }
67
+
68
+ this.overlayListCache = applyListDelta(
69
+ afterValues,
70
+ (diff as ListDiff).diff as Delta<Item[]>[],
71
+ )
72
+ }
73
+
74
+ return this.overlayListCache
75
+ }
40
76
 
41
77
  /** Get typed ref params for creating child refs at an index */
42
78
  getChildTypedRefParams(
@@ -57,6 +93,7 @@ export class ListRefBaseInternals<
57
93
  autoCommit: this.getAutoCommit(),
58
94
  batchedMutation: this.getBatchedMutation(),
59
95
  getDoc: () => this.getDoc(),
96
+ overlay: this.getOverlay(),
60
97
  }
61
98
  }
62
99
 
@@ -64,6 +101,7 @@ export class ListRefBaseInternals<
64
101
  getPredicateItem(index: number): Item | undefined {
65
102
  const shape = this.getShape()
66
103
  const container = this.getContainer() as LoroList | LoroMovableList
104
+ const overlayList = this.getOverlayList()
67
105
 
68
106
  // CRITICAL FIX: For predicates to work correctly with mutations,
69
107
  // we need to check if there's a cached (mutated) version first
@@ -73,6 +111,10 @@ export class ListRefBaseInternals<
73
111
  return cachedItem as Item
74
112
  }
75
113
 
114
+ if (overlayList && isValueShape(shape.shape)) {
115
+ return overlayList[index]
116
+ }
117
+
76
118
  const containerItem = container.get(index)
77
119
  if (containerItem === undefined) {
78
120
  return undefined as Item
@@ -113,9 +155,12 @@ export class ListRefBaseInternals<
113
155
  getMutableItem(index: number): MutableItem | undefined {
114
156
  const shape = this.getShape()
115
157
  const container = this.getContainer() as LoroList | LoroMovableList
158
+ const overlayList = this.getOverlayList()
116
159
 
117
160
  // Get the raw container item
118
- const containerItem = container.get(index)
161
+ const containerItem = overlayList
162
+ ? (overlayList[index] as Item | undefined)
163
+ : (container.get(index) as Item | undefined)
119
164
  if (containerItem === undefined) {
120
165
  return undefined as MutableItem
121
166
  }
@@ -267,7 +312,7 @@ export class ListRefBaseInternals<
267
312
  get container(): LoroList | LoroMovableList {
268
313
  return self.getContainer() as LoroList | LoroMovableList
269
314
  },
270
- subscribe(callback: (event: unknown) => void): Subscription {
315
+ subscribe(callback: (event: LoroEventBatch) => void): Subscription {
271
316
  return (self.getContainer() as LoroList | LoroMovableList).subscribe(
272
317
  callback,
273
318
  )
@@ -461,6 +506,10 @@ export abstract class ListRefBase<
461
506
  }
462
507
 
463
508
  toArray(): Item[] {
509
+ const overlayList = this[INTERNAL_SYMBOL].getOverlayList()
510
+ if (overlayList) {
511
+ return [...overlayList]
512
+ }
464
513
  const result: Item[] = []
465
514
  for (let i = 0; i < this.length; i++) {
466
515
  result.push(this[INTERNAL_SYMBOL].getPredicateItem(i) as Item)
@@ -470,10 +519,11 @@ export abstract class ListRefBase<
470
519
 
471
520
  toJSON(): Item[] {
472
521
  const shape = this[INTERNAL_SYMBOL].getShape()
522
+ const overlayList = this[INTERNAL_SYMBOL].getOverlayList()
473
523
  const container = this[INTERNAL_SYMBOL].getContainer() as
474
524
  | LoroList
475
525
  | LoroMovableList
476
- const nativeJson = container.toJSON() as any[]
526
+ const nativeJson = overlayList ?? (container.toJSON() as any[])
477
527
 
478
528
  // If the nested shape is a container shape (map, record, etc.) or an object value shape,
479
529
  // we need to overlay placeholders for each item
@@ -510,9 +560,35 @@ export abstract class ListRefBase<
510
560
  }
511
561
 
512
562
  get length(): number {
563
+ const overlayList = this[INTERNAL_SYMBOL].getOverlayList()
564
+ if (overlayList) {
565
+ return overlayList.length
566
+ }
513
567
  const container = this[INTERNAL_SYMBOL].getContainer() as
514
568
  | LoroList
515
569
  | LoroMovableList
516
570
  return container.length
517
571
  }
518
572
  }
573
+
574
+ function applyListDelta<T>(input: T[], delta: Delta<T[]>[]): T[] {
575
+ const result: T[] = []
576
+ let index = 0
577
+
578
+ for (const op of delta) {
579
+ if (op.retain !== undefined) {
580
+ result.push(...input.slice(index, index + op.retain))
581
+ index += op.retain
582
+ } else if (op.delete !== undefined) {
583
+ index += op.delete
584
+ } else if (op.insert !== undefined) {
585
+ result.push(...op.insert)
586
+ }
587
+ }
588
+
589
+ if (index < input.length) {
590
+ result.push(...input.slice(index))
591
+ }
592
+
593
+ return result
594
+ }
@@ -1,7 +1,9 @@
1
1
  import type {
2
2
  Container,
3
3
  LoroDoc,
4
+ LoroEventBatch,
4
5
  LoroMap,
6
+ MapDiff,
5
7
  Subscription,
6
8
  Value,
7
9
  } from "loro-crdt"
@@ -93,6 +95,17 @@ export class RecordRefInternals<
93
95
  const container = this.getContainer() as LoroMap
94
96
 
95
97
  if (isValueShape(shape)) {
98
+ const overlay = this.getOverlay()
99
+ if (overlay) {
100
+ const containerId = (container as any).id
101
+ const diff = overlay.get(containerId)
102
+ if (diff && diff.type === "map") {
103
+ const mapDiff = diff as MapDiff
104
+ if (key in mapDiff.updated) {
105
+ return mapDiff.updated[key] as Value
106
+ }
107
+ }
108
+ }
96
109
  // When NOT in batchedMutation mode (direct access outside of change()), ALWAYS read fresh
97
110
  // from container (NEVER cache). This ensures we always get the latest value
98
111
  // from the CRDT, even when modified by a different ref instance (e.g., drafts from change())
@@ -166,9 +179,10 @@ export class RecordRefInternals<
166
179
  } else {
167
180
  // For container shapes, try to assign the plain value
168
181
  // Use getOrCreateRef to ensure the container is created
182
+ // assignPlainValueToTypedRef handles batching and commits internally
169
183
  const ref = this.getOrCreateRef(key)
170
184
  if (assignPlainValueToTypedRef(ref as TypedRef<any>, value)) {
171
- this.commitIfAuto()
185
+ // Don't call commitIfAuto here - assignPlainValueToTypedRef handles it
172
186
  return
173
187
  }
174
188
  throw new Error(
@@ -185,6 +199,106 @@ export class RecordRefInternals<
185
199
  this.commitIfAuto()
186
200
  }
187
201
 
202
+ /**
203
+ * Replace entire contents with new values.
204
+ * Keys not in `values` are removed.
205
+ */
206
+ replace(values: Record<string, any>): void {
207
+ const container = this.getContainer() as LoroMap
208
+ const currentKeys = new Set(container.keys())
209
+ const newKeys = new Set(Object.keys(values))
210
+
211
+ // Suppress auto-commit during batch operations
212
+ const wasSuppressed = this.isSuppressAutoCommit()
213
+ if (!wasSuppressed) {
214
+ this.setSuppressAutoCommit(true)
215
+ }
216
+
217
+ try {
218
+ // Delete keys that are not in the new values
219
+ for (const key of currentKeys) {
220
+ if (!newKeys.has(key)) {
221
+ container.delete(key)
222
+ this.refCache.delete(key)
223
+ }
224
+ }
225
+
226
+ // Set new/updated values
227
+ for (const key of newKeys) {
228
+ this.set(key, values[key])
229
+ }
230
+ } finally {
231
+ // Restore auto-commit state
232
+ if (!wasSuppressed) {
233
+ this.setSuppressAutoCommit(false)
234
+ }
235
+ }
236
+
237
+ // Commit once after all operations
238
+ this.commitIfAuto()
239
+ }
240
+
241
+ /**
242
+ * Merge values into record.
243
+ * Existing keys not in `values` are kept.
244
+ */
245
+ merge(values: Record<string, any>): void {
246
+ // Suppress auto-commit during batch operations
247
+ const wasSuppressed = this.isSuppressAutoCommit()
248
+ if (!wasSuppressed) {
249
+ this.setSuppressAutoCommit(true)
250
+ }
251
+
252
+ try {
253
+ // Set new/updated values (no deletions)
254
+ for (const key of Object.keys(values)) {
255
+ this.set(key, values[key])
256
+ }
257
+ } finally {
258
+ // Restore auto-commit state
259
+ if (!wasSuppressed) {
260
+ this.setSuppressAutoCommit(false)
261
+ }
262
+ }
263
+
264
+ // Commit once after all operations
265
+ this.commitIfAuto()
266
+ }
267
+
268
+ /**
269
+ * Remove all entries from the record.
270
+ */
271
+ clear(): void {
272
+ const container = this.getContainer() as LoroMap
273
+ const keys = container.keys()
274
+
275
+ if (keys.length === 0) {
276
+ return // No-op on empty record
277
+ }
278
+
279
+ // Suppress auto-commit during batch operations
280
+ const wasSuppressed = this.isSuppressAutoCommit()
281
+ if (!wasSuppressed) {
282
+ this.setSuppressAutoCommit(true)
283
+ }
284
+
285
+ try {
286
+ // Delete all keys
287
+ for (const key of keys) {
288
+ container.delete(key)
289
+ this.refCache.delete(key)
290
+ }
291
+ } finally {
292
+ // Restore auto-commit state
293
+ if (!wasSuppressed) {
294
+ this.setSuppressAutoCommit(false)
295
+ }
296
+ }
297
+
298
+ // Commit once after all operations
299
+ this.commitIfAuto()
300
+ }
301
+
188
302
  /** Absorb mutated plain values back into Loro containers */
189
303
  absorbPlainValues(): void {
190
304
  absorbCachedPlainValues(this.refCache, () => this.getContainer() as LoroMap)
@@ -200,7 +314,7 @@ export class RecordRefInternals<
200
314
  get container(): LoroMap {
201
315
  return self.getContainer() as LoroMap
202
316
  },
203
- subscribe(callback: (event: unknown) => void): Subscription {
317
+ subscribe(callback: (event: LoroEventBatch) => void): Subscription {
204
318
  return (self.getContainer() as LoroMap).subscribe(callback)
205
319
  },
206
320
  setContainer(key: string, container: Container): Container {