@loro-extended/change 3.0.0 → 5.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 (61) hide show
  1. package/README.md +289 -150
  2. package/dist/index.d.ts +1012 -310
  3. package/dist/index.js +1334 -568
  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 +27 -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/overlay.ts +62 -3
  16. package/src/path-evaluator.ts +1 -1
  17. package/src/path-selector.test.ts +94 -1
  18. package/src/shape.ts +107 -14
  19. package/src/typed-doc.ts +100 -98
  20. package/src/typed-refs/base.ts +126 -46
  21. package/src/typed-refs/counter-ref-internals.ts +62 -0
  22. package/src/typed-refs/{counter.test.ts → counter-ref.test.ts} +5 -4
  23. package/src/typed-refs/counter-ref.ts +45 -0
  24. package/src/typed-refs/{doc.ts → doc-ref-internals.ts} +33 -56
  25. package/src/typed-refs/doc-ref.ts +47 -0
  26. package/src/typed-refs/encapsulation.test.ts +226 -0
  27. package/src/typed-refs/list-ref-base-internals.ts +280 -0
  28. package/src/typed-refs/list-ref-base.ts +518 -0
  29. package/src/typed-refs/list-ref-internals.ts +21 -0
  30. package/src/typed-refs/list-ref-value-updates.test.ts +213 -0
  31. package/src/typed-refs/{list.ts → list-ref.ts} +10 -11
  32. package/src/typed-refs/movable-list-ref-internals.ts +38 -0
  33. package/src/typed-refs/movable-list-ref.ts +31 -0
  34. package/src/typed-refs/proxy-handlers.ts +13 -4
  35. package/src/typed-refs/record-ref-internals.ts +216 -0
  36. package/src/typed-refs/record-ref-value-updates.test.ts +214 -0
  37. package/src/typed-refs/{record.test.ts → record-ref.test.ts} +21 -16
  38. package/src/typed-refs/record-ref.ts +80 -0
  39. package/src/typed-refs/struct-ref-internals.ts +195 -0
  40. package/src/typed-refs/struct-ref.test.ts +202 -0
  41. package/src/typed-refs/struct-ref.ts +257 -0
  42. package/src/typed-refs/text-ref-internals.ts +100 -0
  43. package/src/typed-refs/text-ref.ts +72 -0
  44. package/src/typed-refs/tree-node-ref-internals.ts +111 -0
  45. package/src/typed-refs/tree-node-ref.test.ts +234 -0
  46. package/src/typed-refs/tree-node-ref.ts +200 -0
  47. package/src/typed-refs/tree-node.test.ts +384 -0
  48. package/src/typed-refs/tree-ref-internals.ts +110 -0
  49. package/src/typed-refs/tree-ref.ts +194 -0
  50. package/src/typed-refs/utils.ts +38 -17
  51. package/src/types.ts +36 -1
  52. package/src/utils/type-guards.ts +1 -0
  53. package/src/typed-refs/counter.ts +0 -64
  54. package/src/typed-refs/list-base.ts +0 -424
  55. package/src/typed-refs/movable-list.ts +0 -34
  56. package/src/typed-refs/record.ts +0 -220
  57. package/src/typed-refs/struct.ts +0 -206
  58. package/src/typed-refs/text.ts +0 -97
  59. package/src/typed-refs/tree.ts +0 -40
  60. /package/src/typed-refs/{list.test.ts → list-ref.test.ts} +0 -0
  61. /package/src/typed-refs/{movable-list.test.ts → movable-list-ref.test.ts} +0 -0
@@ -0,0 +1,214 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { change, createTypedDoc, Shape } from "../index.js"
3
+
4
+ /**
5
+ * Regression tests for Shape.record() value updates across multiple change() calls.
6
+ *
7
+ * These tests ensure that value shapes in records are always read fresh from the
8
+ * underlying container, preventing stale cache issues when mutations occur in
9
+ * separate change() transactions.
10
+ *
11
+ * Fix: RecordRef.getOrCreateRef() no longer caches value shapes - it always reads
12
+ * from the container. Container shapes (handles) are still cached safely.
13
+ */
14
+ describe("Record value updates across change() calls", () => {
15
+ describe("updating existing keys", () => {
16
+ it("updates value with .set() method", () => {
17
+ const Schema = Shape.doc({
18
+ input: Shape.record(Shape.plain.any()),
19
+ })
20
+
21
+ const doc = createTypedDoc(Schema)
22
+
23
+ change(doc, draft => {
24
+ draft.input.set("key1", 123)
25
+ })
26
+ expect(doc.input.get("key1")).toBe(123)
27
+
28
+ change(doc, draft => {
29
+ draft.input.set("key1", 456)
30
+ })
31
+ expect(doc.input.get("key1")).toBe(456)
32
+ })
33
+
34
+ it("updates value with indexed assignment", () => {
35
+ const Schema = Shape.doc({
36
+ input: Shape.record(Shape.plain.number()),
37
+ })
38
+
39
+ const doc = createTypedDoc(Schema)
40
+
41
+ change(doc, draft => {
42
+ draft.input.key1 = 100
43
+ })
44
+ expect(doc.input.key1).toBe(100)
45
+
46
+ change(doc, draft => {
47
+ draft.input.key1 = 200
48
+ })
49
+ expect(doc.input.key1).toBe(200)
50
+ })
51
+
52
+ it("handles multiple sequential updates to same key", () => {
53
+ const Schema = Shape.doc({
54
+ counter: Shape.record(Shape.plain.number()),
55
+ })
56
+
57
+ const doc = createTypedDoc(Schema)
58
+
59
+ for (let i = 1; i <= 5; i++) {
60
+ change(doc, draft => {
61
+ draft.counter.set("count", i)
62
+ })
63
+ expect(doc.counter.get("count")).toBe(i)
64
+ }
65
+ })
66
+ })
67
+
68
+ describe("deleting keys", () => {
69
+ it("removes key after delete", () => {
70
+ const Schema = Shape.doc({
71
+ input: Shape.record(Shape.plain.any()),
72
+ })
73
+
74
+ const doc = createTypedDoc(Schema)
75
+
76
+ change(doc, draft => {
77
+ draft.input.set("key1", "hello")
78
+ })
79
+ expect(doc.input.get("key1")).toBe("hello")
80
+
81
+ change(doc, draft => {
82
+ draft.input.delete("key1")
83
+ })
84
+ expect(doc.input.has("key1")).toBe(false)
85
+ expect(doc.input.get("key1")).toBeUndefined()
86
+ })
87
+ })
88
+
89
+ describe("toJSON() consistency", () => {
90
+ it("reflects updates in toJSON()", () => {
91
+ const Schema = Shape.doc({
92
+ input: Shape.record(Shape.plain.boolean()),
93
+ })
94
+
95
+ const doc = createTypedDoc(Schema)
96
+
97
+ change(doc, draft => {
98
+ draft.input.set("flag", false)
99
+ })
100
+ expect(doc.toJSON().input).toEqual({ flag: false })
101
+
102
+ change(doc, draft => {
103
+ draft.input.set("flag", true)
104
+ })
105
+ expect(doc.toJSON().input).toEqual({ flag: true })
106
+ })
107
+ })
108
+
109
+ describe("edge cases", () => {
110
+ it("handles setting same value then different value", () => {
111
+ const Schema = Shape.doc({
112
+ data: Shape.record(Shape.plain.number()),
113
+ })
114
+
115
+ const doc = createTypedDoc(Schema)
116
+
117
+ change(doc, draft => {
118
+ draft.data.set("x", 42)
119
+ })
120
+
121
+ change(doc, draft => {
122
+ draft.data.set("x", 42)
123
+ })
124
+
125
+ change(doc, draft => {
126
+ draft.data.set("x", 99)
127
+ })
128
+
129
+ expect(doc.data.get("x")).toBe(99)
130
+ })
131
+
132
+ it("handles alternating boolean values", () => {
133
+ const Schema = Shape.doc({
134
+ flags: Shape.record(Shape.plain.boolean()),
135
+ })
136
+
137
+ const doc = createTypedDoc(Schema)
138
+
139
+ change(doc, draft => {
140
+ draft.flags.set("active", false)
141
+ })
142
+ expect(doc.flags.get("active")).toBe(false)
143
+
144
+ change(doc, draft => {
145
+ draft.flags.set("active", true)
146
+ })
147
+ expect(doc.flags.get("active")).toBe(true)
148
+
149
+ change(doc, draft => {
150
+ draft.flags.set("active", false)
151
+ })
152
+ expect(doc.flags.get("active")).toBe(false)
153
+ })
154
+
155
+ it("handles null values", () => {
156
+ const Schema = Shape.doc({
157
+ nullable: Shape.record(Shape.plain.any()),
158
+ })
159
+
160
+ const doc = createTypedDoc(Schema)
161
+
162
+ change(doc, draft => {
163
+ draft.nullable.set("value", "initial")
164
+ })
165
+ expect(doc.nullable.get("value")).toBe("initial")
166
+
167
+ change(doc, draft => {
168
+ draft.nullable.set("value", null)
169
+ })
170
+ expect(doc.nullable.get("value")).toBe(null)
171
+ })
172
+
173
+ it("handles reading before first change", () => {
174
+ const Schema = Shape.doc({
175
+ data: Shape.record(Shape.plain.any()),
176
+ })
177
+
178
+ const doc = createTypedDoc(Schema)
179
+
180
+ expect(doc.data.get("key")).toBeUndefined()
181
+
182
+ change(doc, draft => {
183
+ draft.data.set("key", 100)
184
+ })
185
+ expect(doc.data.get("key")).toBe(100)
186
+
187
+ change(doc, draft => {
188
+ draft.data.set("key", 200)
189
+ })
190
+ expect(doc.data.get("key")).toBe(200)
191
+ })
192
+ })
193
+
194
+ describe("raw LoroDoc comparison", () => {
195
+ it("underlying CRDT operations work correctly", async () => {
196
+ const { LoroDoc } = await import("loro-crdt")
197
+
198
+ const doc = new LoroDoc()
199
+ const map = doc.getMap("input")
200
+
201
+ map.set("key", 123)
202
+ doc.commit()
203
+ expect(map.get("key")).toBe(123)
204
+
205
+ map.set("key", 456)
206
+ doc.commit()
207
+ expect(map.get("key")).toBe(456)
208
+
209
+ map.delete("key")
210
+ doc.commit()
211
+ expect(map.get("key")).toBeUndefined()
212
+ })
213
+ })
214
+ })
@@ -12,8 +12,9 @@ describe("Record Types", () => {
12
12
  const doc = createTypedDoc(schema)
13
13
 
14
14
  change(doc, draft => {
15
- draft.scores.getOrCreateRef("alice").increment(10)
16
- draft.scores.getOrCreateRef("bob").increment(5)
15
+ // Use get() to access container refs - it creates if not exists
16
+ draft.scores.get("alice")?.increment(10)
17
+ draft.scores.get("bob")?.increment(5)
17
18
  })
18
19
 
19
20
  expect(doc.toJSON().scores).toEqual({
@@ -22,7 +23,7 @@ describe("Record Types", () => {
22
23
  })
23
24
 
24
25
  change(doc, draft => {
25
- draft.scores.getOrCreateRef("alice").increment(5)
26
+ draft.scores.get("alice")?.increment(5)
26
27
  draft.scores.delete("bob")
27
28
  })
28
29
 
@@ -39,8 +40,8 @@ describe("Record Types", () => {
39
40
  const doc = createTypedDoc(schema)
40
41
 
41
42
  change(doc, draft => {
42
- draft.notes.getOrCreateRef("todo").insert(0, "Buy milk")
43
- draft.notes.getOrCreateRef("reminders").insert(0, "Call mom")
43
+ draft.notes.get("todo")?.insert(0, "Buy milk")
44
+ draft.notes.get("reminders")?.insert(0, "Call mom")
44
45
  })
45
46
 
46
47
  expect(doc.toJSON().notes).toEqual({
@@ -57,12 +58,12 @@ describe("Record Types", () => {
57
58
  const doc = createTypedDoc(schema)
58
59
 
59
60
  change(doc, draft => {
60
- const groupA = draft.groups.getOrCreateRef("groupA")
61
- groupA.push("alice")
62
- groupA.push("bob")
61
+ const groupA = draft.groups.get("groupA")
62
+ groupA?.push("alice")
63
+ groupA?.push("bob")
63
64
 
64
- const groupB = draft.groups.getOrCreateRef("groupB")
65
- groupB.push("charlie")
65
+ const groupB = draft.groups.get("groupB")
66
+ groupB?.push("charlie")
66
67
  })
67
68
 
68
69
  expect(doc.toJSON().groups).toEqual({
@@ -171,13 +172,17 @@ describe("Record Types", () => {
171
172
  const doc = createTypedDoc(schema)
172
173
 
173
174
  change(doc, draft => {
174
- const alice = draft.users.getOrCreateRef("u1")
175
- alice.name = "Alice"
176
- alice.age = 30
175
+ const alice = draft.users.get("u1")
176
+ if (alice) {
177
+ alice.name = "Alice"
178
+ alice.age = 30
179
+ }
177
180
 
178
- const bob = draft.users.getOrCreateRef("u2")
179
- bob.name = "Bob"
180
- bob.age = 25
181
+ const bob = draft.users.get("u2")
182
+ if (bob) {
183
+ bob.name = "Bob"
184
+ bob.age = 25
185
+ }
181
186
  })
182
187
 
183
188
  expect(doc.toJSON().users).toEqual({
@@ -0,0 +1,80 @@
1
+ import type { Container, LoroMap } from "loro-crdt"
2
+ import type { ContainerOrValueShape } from "../shape.js"
3
+ import type { Infer, InferMutableType } from "../types.js"
4
+ import { INTERNAL_SYMBOL, TypedRef, type TypedRefParams } from "./base.js"
5
+ import { RecordRefInternals } from "./record-ref-internals.js"
6
+ import { serializeRefToJSON } from "./utils.js"
7
+
8
+ /**
9
+ * Record typed ref - thin facade that delegates to RecordRefInternals.
10
+ */
11
+ export class RecordRef<
12
+ NestedShape extends ContainerOrValueShape,
13
+ > extends TypedRef<any> {
14
+ [key: string]: InferMutableType<NestedShape> | undefined | any
15
+
16
+ [INTERNAL_SYMBOL]: RecordRefInternals<NestedShape>
17
+
18
+ constructor(params: TypedRefParams<any>) {
19
+ super()
20
+ this[INTERNAL_SYMBOL] = new RecordRefInternals(params)
21
+ }
22
+
23
+ /** Set a value at a key */
24
+ set(key: string, value: any): void {
25
+ this[INTERNAL_SYMBOL].set(key, value)
26
+ }
27
+
28
+ /** Delete a key */
29
+ delete(key: string): void {
30
+ this[INTERNAL_SYMBOL].delete(key)
31
+ }
32
+
33
+ get(key: string): InferMutableType<NestedShape> | undefined {
34
+ // In batched mutation mode (inside change()), use getOrCreateRef to create containers
35
+ // This allows patterns like: draft.scores.get("alice")!.increment(10)
36
+ if (this[INTERNAL_SYMBOL].getBatchedMutation()) {
37
+ return this[INTERNAL_SYMBOL].getOrCreateRef(key) as
38
+ | InferMutableType<NestedShape>
39
+ | undefined
40
+ }
41
+ // In readonly mode, use getRef which returns undefined for non-existent keys
42
+ return this[INTERNAL_SYMBOL].getRef(key) as
43
+ | InferMutableType<NestedShape>
44
+ | undefined
45
+ }
46
+
47
+ setContainer<C extends Container>(key: string, container: C): C {
48
+ const loroContainer = this[INTERNAL_SYMBOL].getContainer() as LoroMap
49
+ const result = loroContainer.setContainer(key, container)
50
+ this[INTERNAL_SYMBOL].commitIfAuto()
51
+ return result
52
+ }
53
+
54
+ has(key: string): boolean {
55
+ const container = this[INTERNAL_SYMBOL].getContainer() as LoroMap
56
+ return container.get(key) !== undefined
57
+ }
58
+
59
+ keys(): string[] {
60
+ const container = this[INTERNAL_SYMBOL].getContainer() as LoroMap
61
+ return container.keys()
62
+ }
63
+
64
+ values(): any[] {
65
+ const container = this[INTERNAL_SYMBOL].getContainer() as LoroMap
66
+ return container.values()
67
+ }
68
+
69
+ get size(): number {
70
+ const container = this[INTERNAL_SYMBOL].getContainer() as LoroMap
71
+ return container.size
72
+ }
73
+
74
+ toJSON(): Record<string, Infer<NestedShape>> {
75
+ return serializeRefToJSON(this, this.keys()) as Record<
76
+ string,
77
+ Infer<NestedShape>
78
+ >
79
+ }
80
+ }
@@ -0,0 +1,195 @@
1
+ import type {
2
+ Container,
3
+ LoroDoc,
4
+ LoroMap,
5
+ Subscription,
6
+ Value,
7
+ } from "loro-crdt"
8
+ import type { LoroMapRef } from "../loro.js"
9
+ import type {
10
+ ContainerOrValueShape,
11
+ ContainerShape,
12
+ StructContainerShape,
13
+ ValueShape,
14
+ } from "../shape.js"
15
+ import { isValueShape } from "../utils/type-guards.js"
16
+ import { BaseRefInternals, type TypedRef, type TypedRefParams } from "./base.js"
17
+ import {
18
+ absorbCachedPlainValues,
19
+ assignPlainValueToTypedRef,
20
+ containerConstructor,
21
+ createContainerTypedRef,
22
+ hasContainerConstructor,
23
+ } from "./utils.js"
24
+
25
+ /**
26
+ * Internal implementation for StructRef.
27
+ * Contains all logic, state, and implementation details.
28
+ */
29
+ export class StructRefInternals<
30
+ NestedShapes extends Record<string, ContainerOrValueShape>,
31
+ > extends BaseRefInternals<any> {
32
+ private propertyCache = new Map<string, TypedRef<ContainerShape> | Value>()
33
+
34
+ /** Get typed ref params for creating child refs at a key */
35
+ getTypedRefParams(
36
+ key: string,
37
+ shape: ContainerShape,
38
+ ): TypedRefParams<ContainerShape> {
39
+ const placeholder = (this.getPlaceholder() as any)?.[key]
40
+
41
+ // AnyContainerShape is an escape hatch - it doesn't have a constructor
42
+ if (!hasContainerConstructor(shape._type)) {
43
+ throw new Error(
44
+ `Cannot create typed ref for shape type "${shape._type}". ` +
45
+ `Use Shape.any() only at the document root level.`,
46
+ )
47
+ }
48
+
49
+ const LoroContainer = containerConstructor[shape._type]
50
+ const container = this.getContainer() as LoroMap
51
+
52
+ return {
53
+ shape,
54
+ placeholder,
55
+ getContainer: () =>
56
+ container.getOrCreateContainer(key, new (LoroContainer as any)()),
57
+ autoCommit: this.getAutoCommit(),
58
+ batchedMutation: this.getBatchedMutation(),
59
+ getDoc: () => this.getDoc(),
60
+ }
61
+ }
62
+
63
+ /** Get or create a ref for a key */
64
+ getOrCreateRef<Shape extends ContainerShape | ValueShape>(
65
+ key: string,
66
+ shape?: Shape,
67
+ ): unknown {
68
+ const structShape = this.getShape() as StructContainerShape<NestedShapes>
69
+ const actualShape = shape || structShape.shapes[key]
70
+ const container = this.getContainer() as LoroMap
71
+
72
+ if (isValueShape(actualShape)) {
73
+ // When NOT in batchedMutation mode (direct access outside of change()), ALWAYS read fresh
74
+ // from container (NEVER cache). This ensures we always get the latest value
75
+ // from the CRDT, even when modified by a different ref instance (e.g., drafts from change())
76
+ //
77
+ // When in batchedMutation mode (inside change()), we cache value shapes so that
78
+ // mutations to nested objects persist back to the CRDT via absorbPlainValues()
79
+ if (!this.getBatchedMutation()) {
80
+ const containerValue = container.get(key)
81
+ if (containerValue !== undefined) {
82
+ return containerValue
83
+ }
84
+ // Only fall back to placeholder if the container doesn't have the value
85
+ const placeholder = (this.getPlaceholder() as any)?.[key]
86
+ if (placeholder === undefined) {
87
+ throw new Error("placeholder required")
88
+ }
89
+ return placeholder
90
+ }
91
+
92
+ // In batched mode (within change()), we cache value shapes so that
93
+ // mutations to nested objects persist back to the CRDT via absorbPlainValues()
94
+ let ref = this.propertyCache.get(key)
95
+ if (!ref) {
96
+ const containerValue = container.get(key)
97
+ if (containerValue !== undefined) {
98
+ // For objects, create a deep copy so mutations can be tracked
99
+ if (typeof containerValue === "object" && containerValue !== null) {
100
+ ref = JSON.parse(JSON.stringify(containerValue))
101
+ } else {
102
+ ref = containerValue as Value
103
+ }
104
+ } else {
105
+ // Only fall back to placeholder if the container doesn't have the value
106
+ const placeholder = (this.getPlaceholder() as any)?.[key]
107
+ if (placeholder === undefined) {
108
+ throw new Error("placeholder required")
109
+ }
110
+ ref = placeholder as Value
111
+ }
112
+ this.propertyCache.set(key, ref)
113
+ }
114
+ return ref
115
+ }
116
+
117
+ // Container shapes: safe to cache (handles)
118
+ let ref = this.propertyCache.get(key)
119
+ if (!ref) {
120
+ ref = createContainerTypedRef(
121
+ this.getTypedRefParams(key, actualShape as ContainerShape),
122
+ )
123
+ this.propertyCache.set(key, ref)
124
+ }
125
+
126
+ return ref as Shape extends ContainerShape ? TypedRef<Shape> : Value
127
+ }
128
+
129
+ /** Set a property value */
130
+ setPropertyValue(key: string, value: unknown): void {
131
+ const structShape = this.getShape() as StructContainerShape<NestedShapes>
132
+ const shape = structShape.shapes[key]
133
+ const container = this.getContainer() as LoroMap
134
+
135
+ if (!shape) {
136
+ throw new Error(`Unknown property: ${key}`)
137
+ }
138
+
139
+ if (isValueShape(shape)) {
140
+ container.set(key, value)
141
+ this.propertyCache.set(key, value as Value)
142
+ this.commitIfAuto()
143
+ } else {
144
+ // For container shapes, try to assign the plain value
145
+ const ref = this.getOrCreateRef(key, shape)
146
+ if (assignPlainValueToTypedRef(ref as TypedRef<any>, value)) {
147
+ this.commitIfAuto()
148
+ return
149
+ }
150
+ throw new Error(
151
+ "Cannot set container directly, modify the typed ref instead",
152
+ )
153
+ }
154
+ }
155
+
156
+ /** Delete a property */
157
+ deleteProperty(key: string): void {
158
+ const container = this.getContainer() as LoroMap
159
+ container.delete(key)
160
+ this.propertyCache.delete(key)
161
+ this.commitIfAuto()
162
+ }
163
+
164
+ /** Absorb mutated plain values back into Loro containers */
165
+ absorbPlainValues(): void {
166
+ absorbCachedPlainValues(
167
+ this.propertyCache,
168
+ () => this.getContainer() as LoroMap,
169
+ )
170
+ }
171
+
172
+ /** Create the loro namespace for struct */
173
+ protected override createLoroNamespace(): LoroMapRef {
174
+ const self = this
175
+ return {
176
+ get doc(): LoroDoc {
177
+ return self.getDoc()
178
+ },
179
+ get container(): LoroMap {
180
+ return self.getContainer() as LoroMap
181
+ },
182
+ subscribe(callback: (event: unknown) => void): Subscription {
183
+ return (self.getContainer() as LoroMap).subscribe(callback)
184
+ },
185
+ setContainer(key: string, container: Container): Container {
186
+ const result = (self.getContainer() as LoroMap).setContainer(
187
+ key,
188
+ container,
189
+ )
190
+ self.commitIfAuto()
191
+ return result
192
+ },
193
+ }
194
+ }
195
+ }