@loro-extended/change 4.0.0 → 5.1.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 (56) hide show
  1. package/README.md +173 -149
  2. package/dist/index.d.ts +962 -335
  3. package/dist/index.js +1040 -598
  4. package/dist/index.js.map +1 -1
  5. package/package.json +4 -4
  6. package/src/change.test.ts +51 -52
  7. package/src/functional-helpers.test.ts +316 -4
  8. package/src/functional-helpers.ts +96 -6
  9. package/src/grand-unified-api.test.ts +35 -29
  10. package/src/index.ts +25 -1
  11. package/src/json-patch.test.ts +46 -27
  12. package/src/loro.test.ts +449 -0
  13. package/src/loro.ts +273 -0
  14. package/src/overlay-recursion.test.ts +1 -1
  15. package/src/path-evaluator.ts +1 -1
  16. package/src/path-selector.test.ts +94 -1
  17. package/src/shape.ts +47 -15
  18. package/src/typed-doc.ts +99 -98
  19. package/src/typed-refs/base.ts +126 -35
  20. package/src/typed-refs/counter-ref-internals.ts +62 -0
  21. package/src/typed-refs/{counter.test.ts → counter-ref.test.ts} +5 -4
  22. package/src/typed-refs/counter-ref.ts +45 -0
  23. package/src/typed-refs/{doc.ts → doc-ref-internals.ts} +33 -38
  24. package/src/typed-refs/doc-ref.ts +47 -0
  25. package/src/typed-refs/encapsulation.test.ts +226 -0
  26. package/src/typed-refs/list-ref-base-internals.ts +280 -0
  27. package/src/typed-refs/{list-base.ts → list-ref-base.ts} +255 -160
  28. package/src/typed-refs/list-ref-internals.ts +21 -0
  29. package/src/typed-refs/{list.ts → list-ref.ts} +10 -11
  30. package/src/typed-refs/movable-list-ref-internals.ts +38 -0
  31. package/src/typed-refs/movable-list-ref.ts +31 -0
  32. package/src/typed-refs/proxy-handlers.ts +13 -4
  33. package/src/typed-refs/{record.ts → record-ref-internals.ts} +78 -79
  34. package/src/typed-refs/{record.test.ts → record-ref.test.ts} +21 -16
  35. package/src/typed-refs/record-ref.ts +80 -0
  36. package/src/typed-refs/struct-ref-internals.ts +195 -0
  37. package/src/typed-refs/{struct-value-updates.test.ts → struct-ref.test.ts} +5 -3
  38. package/src/typed-refs/struct-ref.ts +257 -0
  39. package/src/typed-refs/text-ref-internals.ts +100 -0
  40. package/src/typed-refs/text-ref.ts +72 -0
  41. package/src/typed-refs/tree-node-ref-internals.ts +111 -0
  42. package/src/typed-refs/{tree-node.ts → tree-node-ref.ts} +58 -94
  43. package/src/typed-refs/tree-ref-internals.ts +110 -0
  44. package/src/typed-refs/tree-ref.ts +194 -0
  45. package/src/typed-refs/utils.ts +21 -23
  46. package/src/typed-refs/counter.ts +0 -62
  47. package/src/typed-refs/movable-list.ts +0 -32
  48. package/src/typed-refs/struct.ts +0 -201
  49. package/src/typed-refs/text.ts +0 -91
  50. package/src/typed-refs/tree.ts +0 -268
  51. /package/src/typed-refs/{list-value-updates.test.ts → list-ref-value-updates.test.ts} +0 -0
  52. /package/src/typed-refs/{list.test.ts → list-ref.test.ts} +0 -0
  53. /package/src/typed-refs/{movable-list.test.ts → movable-list-ref.test.ts} +0 -0
  54. /package/src/typed-refs/{record-value-updates.test.ts → record-ref-value-updates.test.ts} +0 -0
  55. /package/src/typed-refs/{tree-node-value-updates.test.ts → tree-node-ref.test.ts} +0 -0
  56. /package/src/typed-refs/{tree.test.ts → tree-node.test.ts} +0 -0
@@ -17,19 +17,19 @@ import type {
17
17
  TextContainerShape,
18
18
  TreeContainerShape,
19
19
  } from "../shape.js"
20
- import type { TypedRef, TypedRefParams } from "./base.js"
21
- import { CounterRef } from "./counter.js"
22
- import { ListRef } from "./list.js"
23
- import { MovableListRef } from "./movable-list.js"
20
+ import { INTERNAL_SYMBOL, type TypedRef, type TypedRefParams } from "./base.js"
21
+ import { CounterRef } from "./counter-ref.js"
22
+ import { ListRef } from "./list-ref.js"
23
+ import { MovableListRef } from "./movable-list-ref.js"
24
24
  import {
25
25
  listProxyHandler,
26
26
  movableListProxyHandler,
27
27
  recordProxyHandler,
28
28
  } from "./proxy-handlers.js"
29
- import { RecordRef } from "./record.js"
30
- import { StructRef } from "./struct.js"
31
- import { TextRef } from "./text.js"
32
- import { TreeRef } from "./tree.js"
29
+ import { RecordRef } from "./record-ref.js"
30
+ import { createStructRef } from "./struct-ref.js"
31
+ import { TextRef } from "./text-ref.js"
32
+ import { TreeRef } from "./tree-ref.js"
33
33
 
34
34
  /**
35
35
  * Mapping from container shape types to their Loro constructor classes.
@@ -77,22 +77,17 @@ export function unwrapReadonlyPrimitive(
77
77
  }
78
78
 
79
79
  /**
80
- * Type guard to check if a value has an absorbPlainValues method.
80
+ * Type guard to check if a value has internal methods via INTERNAL_SYMBOL.
81
81
  */
82
- function hasAbsorbPlainValues(
82
+ function hasInternalSymbol(
83
83
  value: unknown,
84
- ): value is { absorbPlainValues(): void } {
85
- return (
86
- value !== null &&
87
- typeof value === "object" &&
88
- "absorbPlainValues" in value &&
89
- typeof (value as any).absorbPlainValues === "function"
90
- )
84
+ ): value is { [INTERNAL_SYMBOL]: { absorbPlainValues(): void } } {
85
+ return value !== null && typeof value === "object" && INTERNAL_SYMBOL in value
91
86
  }
92
87
 
93
88
  /**
94
89
  * Absorbs cached plain values back into a LoroMap container.
95
- * For TypedRef entries (or any object with absorbPlainValues), recursively calls absorbPlainValues().
90
+ * For TypedRef entries (or any object with INTERNAL_SYMBOL), recursively calls absorbPlainValues().
96
91
  * For plain Value entries, sets them directly on the container.
97
92
  */
98
93
  export function absorbCachedPlainValues(
@@ -102,9 +97,9 @@ export function absorbCachedPlainValues(
102
97
  let container: LoroMap | undefined
103
98
 
104
99
  for (const [key, ref] of cache.entries()) {
105
- if (hasAbsorbPlainValues(ref)) {
100
+ if (hasInternalSymbol(ref)) {
106
101
  // Contains a TypedRef or TreeRef, not a plain Value: keep recursing
107
- ref.absorbPlainValues()
102
+ ref[INTERNAL_SYMBOL].absorbPlainValues()
108
103
  } else {
109
104
  // Plain value!
110
105
  if (!container) container = getContainer()
@@ -152,7 +147,9 @@ export function createContainerTypedRef(
152
147
  listProxyHandler,
153
148
  )
154
149
  case "struct":
155
- return new StructRef(params as TypedRefParams<StructContainerShape>)
150
+ return createStructRef(
151
+ params as TypedRefParams<StructContainerShape>,
152
+ ) as unknown as TypedRef<ContainerShape>
156
153
  case "movableList":
157
154
  return new Proxy(
158
155
  new MovableListRef(params as TypedRefParams<MovableListContainerShape>),
@@ -171,7 +168,6 @@ export function createContainerTypedRef(
171
168
  shape: treeShape,
172
169
  placeholder: params.placeholder as never[],
173
170
  getContainer: params.getContainer as () => LoroTree,
174
- readonly: params.readonly,
175
171
  autoCommit: params.autoCommit,
176
172
  getDoc: params.getDoc,
177
173
  })
@@ -187,7 +183,9 @@ export function assignPlainValueToTypedRef(
187
183
  ref: TypedRef<any>,
188
184
  value: any,
189
185
  ): boolean {
190
- const shapeType = (ref as any).shape._type
186
+ // Access shape via INTERNAL_SYMBOL or fallback to direct property access for StructRef proxy
187
+ const shape = ref[INTERNAL_SYMBOL]?.getShape?.() ?? (ref as any).shape
188
+ const shapeType = shape?._type
191
189
 
192
190
  if (shapeType === "struct" || shapeType === "record") {
193
191
  for (const k in value) {
@@ -1,62 +0,0 @@
1
- import type { LoroCounter } from "loro-crdt"
2
- import type { CounterContainerShape } from "../shape.js"
3
- import { TypedRef } from "./base.js"
4
-
5
- // Counter typed ref
6
- export class CounterRef extends TypedRef<CounterContainerShape> {
7
- // Track if we've materialized the container (made any changes)
8
- private _materialized = false
9
-
10
- protected get container(): LoroCounter {
11
- return super.container as LoroCounter
12
- }
13
-
14
- absorbPlainValues() {
15
- // no plain values contained within
16
- }
17
-
18
- increment(value: number = 1): void {
19
- this._materialized = true
20
- this.container.increment(value)
21
- this.commitIfAuto()
22
- }
23
-
24
- decrement(value: number = 1): void {
25
- this._materialized = true
26
- this.container.decrement(value)
27
- this.commitIfAuto()
28
- }
29
-
30
- /**
31
- * Returns the counter value.
32
- * If the counter hasn't been materialized (no operations performed),
33
- * returns the placeholder value if available.
34
- */
35
- get value(): number {
36
- // Check if the container has any value (non-zero means it was modified)
37
- const containerValue = this.container.value
38
- if (containerValue !== 0 || this._materialized) {
39
- return containerValue
40
- }
41
- // Return placeholder if available and container is at default state
42
- if (this.placeholder !== undefined) {
43
- return this.placeholder as number
44
- }
45
- return containerValue
46
- }
47
-
48
- valueOf(): number {
49
- return this.value
50
- }
51
-
52
- toJSON(): number {
53
- return this.value
54
- }
55
-
56
- [Symbol.toPrimitive](hint: string): number | string {
57
- if (hint === "string") {
58
- return String(this.value)
59
- }
60
- return this.value
61
- }
62
- }
@@ -1,32 +0,0 @@
1
- import type { Container, LoroMovableList } from "loro-crdt"
2
- import type { ContainerOrValueShape } from "../shape.js"
3
- import type { InferMutableType } from "../types.js"
4
- import { ListRefBase } from "./list-base.js"
5
-
6
- // Movable list typed ref
7
- export class MovableListRef<
8
- NestedShape extends ContainerOrValueShape,
9
- Item = NestedShape["_plain"],
10
- > extends ListRefBase<NestedShape> {
11
- [index: number]: InferMutableType<NestedShape> | undefined
12
-
13
- protected get container(): LoroMovableList {
14
- return super.container as LoroMovableList
15
- }
16
-
17
- protected absorbValueAtIndex(index: number, value: any): void {
18
- // LoroMovableList has set method
19
- this.container.set(index, value)
20
- }
21
-
22
- move(from: number, to: number): void {
23
- this.container.move(from, to)
24
- this.commitIfAuto()
25
- }
26
-
27
- set(index: number, item: Exclude<Item, Container>) {
28
- const result = this.container.set(index, item)
29
- this.commitIfAuto()
30
- return result
31
- }
32
- }
@@ -1,201 +0,0 @@
1
- import type { Container, LoroMap, Value } from "loro-crdt"
2
- import type {
3
- ContainerOrValueShape,
4
- ContainerShape,
5
- StructContainerShape,
6
- ValueShape,
7
- } from "../shape.js"
8
- import type { Infer } from "../types.js"
9
- import { isValueShape } from "../utils/type-guards.js"
10
- import { TypedRef, type TypedRefParams } from "./base.js"
11
- import {
12
- absorbCachedPlainValues,
13
- assignPlainValueToTypedRef,
14
- containerConstructor,
15
- createContainerTypedRef,
16
- hasContainerConstructor,
17
- serializeRefToJSON,
18
- } from "./utils.js"
19
-
20
- /**
21
- * Typed ref for struct containers (objects with fixed keys).
22
- * Uses LoroMap as the underlying container.
23
- */
24
- export class StructRef<
25
- NestedShapes extends Record<string, ContainerOrValueShape>,
26
- > extends TypedRef<any> {
27
- private propertyCache = new Map<string, TypedRef<ContainerShape> | Value>()
28
-
29
- constructor(params: TypedRefParams<StructContainerShape<NestedShapes>>) {
30
- super(params)
31
- this.createLazyProperties()
32
- }
33
-
34
- protected get shape(): StructContainerShape<NestedShapes> {
35
- return super.shape as StructContainerShape<NestedShapes>
36
- }
37
-
38
- protected get container(): LoroMap {
39
- return super.container as LoroMap
40
- }
41
-
42
- absorbPlainValues() {
43
- absorbCachedPlainValues(this.propertyCache, () => this.container)
44
- }
45
-
46
- getTypedRefParams<S extends ContainerShape>(
47
- key: string,
48
- shape: S,
49
- ): TypedRefParams<ContainerShape> {
50
- const placeholder = (this.placeholder as any)?.[key]
51
-
52
- // AnyContainerShape is an escape hatch - it doesn't have a constructor
53
- if (!hasContainerConstructor(shape._type)) {
54
- throw new Error(
55
- `Cannot create typed ref for shape type "${shape._type}". ` +
56
- `Use Shape.any() only at the document root level.`,
57
- )
58
- }
59
-
60
- const LoroContainer = containerConstructor[shape._type]
61
-
62
- return {
63
- shape,
64
- placeholder,
65
- getContainer: () =>
66
- this.container.getOrCreateContainer(key, new (LoroContainer as any)()),
67
- autoCommit: this._params.autoCommit,
68
- batchedMutation: this.batchedMutation,
69
- getDoc: this._params.getDoc,
70
- }
71
- }
72
-
73
- getOrCreateRef<Shape extends ContainerShape | ValueShape>(
74
- key: string,
75
- shape: Shape,
76
- ): any {
77
- if (isValueShape(shape)) {
78
- // When NOT in batchedMutation mode (direct access outside of change()), ALWAYS read fresh
79
- // from container (NEVER cache). This ensures we always get the latest value
80
- // from the CRDT, even when modified by a different ref instance (e.g., drafts from change())
81
- //
82
- // When in batchedMutation mode (inside change()), we cache value shapes so that
83
- // mutations to nested objects persist back to the CRDT via absorbPlainValues()
84
- if (!this.batchedMutation) {
85
- const containerValue = this.container.get(key)
86
- if (containerValue !== undefined) {
87
- return containerValue
88
- }
89
- // Only fall back to placeholder if the container doesn't have the value
90
- const placeholder = (this.placeholder as any)?.[key]
91
- if (placeholder === undefined) {
92
- throw new Error("placeholder required")
93
- }
94
- return placeholder
95
- }
96
-
97
- // In batched mode (within change()), we cache value shapes so that
98
- // mutations to nested objects persist back to the CRDT via absorbPlainValues()
99
- let ref = this.propertyCache.get(key)
100
- if (!ref) {
101
- const containerValue = this.container.get(key)
102
- if (containerValue !== undefined) {
103
- // For objects, create a deep copy so mutations can be tracked
104
- if (typeof containerValue === "object" && containerValue !== null) {
105
- ref = JSON.parse(JSON.stringify(containerValue))
106
- } else {
107
- ref = containerValue as Value
108
- }
109
- } else {
110
- // Only fall back to placeholder if the container doesn't have the value
111
- const placeholder = (this.placeholder as any)?.[key]
112
- if (placeholder === undefined) {
113
- throw new Error("placeholder required")
114
- }
115
- ref = placeholder as Value
116
- }
117
- this.propertyCache.set(key, ref)
118
- }
119
- return ref
120
- }
121
-
122
- // Container shapes: safe to cache (handles)
123
- let ref = this.propertyCache.get(key)
124
- if (!ref) {
125
- ref = createContainerTypedRef(this.getTypedRefParams(key, shape))
126
- this.propertyCache.set(key, ref)
127
- }
128
-
129
- return ref as Shape extends ContainerShape ? TypedRef<Shape> : Value
130
- }
131
-
132
- private createLazyProperties(): void {
133
- for (const key in this.shape.shapes) {
134
- const shape = this.shape.shapes[key]
135
- Object.defineProperty(this, key, {
136
- get: () => this.getOrCreateRef(key, shape),
137
- set: value => {
138
- if (isValueShape(shape)) {
139
- this.container.set(key, value)
140
- this.propertyCache.set(key, value)
141
- } else {
142
- // For container shapes, try to assign the plain value
143
- const ref = this.getOrCreateRef(key, shape)
144
- if (assignPlainValueToTypedRef(ref as TypedRef<any>, value)) {
145
- return
146
- }
147
- throw new Error(
148
- "Cannot set container directly, modify the typed ref instead",
149
- )
150
- }
151
- },
152
- enumerable: true,
153
- })
154
- }
155
- }
156
-
157
- toJSON(): Infer<StructContainerShape<NestedShapes>> {
158
- return serializeRefToJSON(
159
- this as any,
160
- Object.keys(this.shape.shapes),
161
- ) as Infer<StructContainerShape<NestedShapes>>
162
- }
163
-
164
- // TODO(duane): return correct type here
165
- get(key: string): any {
166
- return this.container.get(key)
167
- }
168
-
169
- set(key: string, value: Value): void {
170
- this.container.set(key, value)
171
- this.commitIfAuto()
172
- }
173
-
174
- setContainer<C extends Container>(key: string, container: C): C {
175
- const result = this.container.setContainer(key, container)
176
- this.commitIfAuto()
177
- return result
178
- }
179
-
180
- delete(key: string): void {
181
- this.container.delete(key)
182
- this.commitIfAuto()
183
- }
184
-
185
- has(key: string): boolean {
186
- // LoroMap doesn't have a has method, so we check if get returns undefined
187
- return this.container.get(key) !== undefined
188
- }
189
-
190
- keys(): string[] {
191
- return this.container.keys()
192
- }
193
-
194
- values(): any[] {
195
- return this.container.values()
196
- }
197
-
198
- get size(): number {
199
- return this.container.size
200
- }
201
- }
@@ -1,91 +0,0 @@
1
- import type { LoroText } from "loro-crdt"
2
- import type { TextContainerShape } from "../shape.js"
3
- import { TypedRef } from "./base.js"
4
-
5
- // Text typed ref
6
- export class TextRef extends TypedRef<TextContainerShape> {
7
- // Track if we've materialized the container (made any changes)
8
- private _materialized = false
9
-
10
- protected get container(): LoroText {
11
- return super.container as LoroText
12
- }
13
-
14
- absorbPlainValues() {
15
- // no plain values contained within
16
- }
17
-
18
- // Text methods
19
- insert(index: number, content: string): void {
20
- this._materialized = true
21
- this.container.insert(index, content)
22
- this.commitIfAuto()
23
- }
24
-
25
- delete(index: number, len: number): void {
26
- this._materialized = true
27
- this.container.delete(index, len)
28
- this.commitIfAuto()
29
- }
30
-
31
- /**
32
- * Returns the text content.
33
- * If the text hasn't been materialized (no operations performed),
34
- * returns the placeholder value if available.
35
- */
36
- toString(): string {
37
- const containerValue = this.container.toString()
38
- if (containerValue !== "" || this._materialized) {
39
- return containerValue
40
- }
41
- // Return placeholder if available and container is at default state
42
- if (this.placeholder !== undefined) {
43
- return this.placeholder as string
44
- }
45
- return containerValue
46
- }
47
-
48
- valueOf(): string {
49
- return this.toString()
50
- }
51
-
52
- toJSON(): string {
53
- return this.toString()
54
- }
55
-
56
- [Symbol.toPrimitive](_hint: string): string {
57
- return this.toString()
58
- }
59
-
60
- update(text: string): void {
61
- this._materialized = true
62
- this.container.update(text)
63
- this.commitIfAuto()
64
- }
65
-
66
- mark(range: { start: number; end: number }, key: string, value: any): void {
67
- this._materialized = true
68
- this.container.mark(range, key, value)
69
- this.commitIfAuto()
70
- }
71
-
72
- unmark(range: { start: number; end: number }, key: string): void {
73
- this._materialized = true
74
- this.container.unmark(range, key)
75
- this.commitIfAuto()
76
- }
77
-
78
- toDelta(): any[] {
79
- return this.container.toDelta()
80
- }
81
-
82
- applyDelta(delta: any[]): void {
83
- this._materialized = true
84
- this.container.applyDelta(delta)
85
- this.commitIfAuto()
86
- }
87
-
88
- get length(): number {
89
- return this.container.length
90
- }
91
- }