@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/README.md +116 -1
- package/dist/index.d.ts +89 -14
- package/dist/index.js +480 -156
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/overlay.ts +62 -3
- package/src/shape.ts +69 -8
- package/src/typed-doc.ts +1 -0
- package/src/typed-refs/base.ts +7 -18
- package/src/typed-refs/counter.ts +0 -2
- package/src/typed-refs/doc.ts +3 -21
- package/src/typed-refs/list-base.ts +28 -29
- package/src/typed-refs/list-value-updates.test.ts +213 -0
- package/src/typed-refs/movable-list.ts +0 -2
- package/src/typed-refs/record-value-updates.test.ts +214 -0
- package/src/typed-refs/record.ts +48 -51
- package/src/typed-refs/struct-value-updates.test.ts +200 -0
- package/src/typed-refs/struct.ts +39 -44
- package/src/typed-refs/text.ts +0 -6
- package/src/typed-refs/tree-node-value-updates.test.ts +234 -0
- package/src/typed-refs/tree-node.ts +236 -0
- package/src/typed-refs/tree.test.ts +384 -0
- package/src/typed-refs/tree.ts +252 -24
- package/src/typed-refs/utils.ts +30 -7
- package/src/types.ts +36 -1
- package/src/utils/type-guards.ts +1 -0
|
@@ -20,13 +20,11 @@ export class MovableListRef<
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
move(from: number, to: number): void {
|
|
23
|
-
this.assertMutable()
|
|
24
23
|
this.container.move(from, to)
|
|
25
24
|
this.commitIfAuto()
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
set(index: number, item: Exclude<Item, Container>) {
|
|
29
|
-
this.assertMutable()
|
|
30
28
|
const result = this.container.set(index, item)
|
|
31
29
|
this.commitIfAuto()
|
|
32
30
|
return result
|
|
@@ -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
|
+
})
|
package/src/typed-refs/record.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Container, LoroMap, Value } from "loro-crdt"
|
|
2
2
|
import { deriveShapePlaceholder } from "../derive-placeholder.js"
|
|
3
|
-
import { mergeValue } from "../overlay.js"
|
|
4
3
|
import type {
|
|
5
4
|
ContainerOrValueShape,
|
|
6
5
|
ContainerShape,
|
|
@@ -16,7 +15,6 @@ import {
|
|
|
16
15
|
createContainerTypedRef,
|
|
17
16
|
hasContainerConstructor,
|
|
18
17
|
serializeRefToJSON,
|
|
19
|
-
unwrapReadonlyPrimitive,
|
|
20
18
|
} from "./utils.js"
|
|
21
19
|
|
|
22
20
|
// Record typed ref
|
|
@@ -67,8 +65,8 @@ export class RecordRef<
|
|
|
67
65
|
placeholder,
|
|
68
66
|
getContainer: () =>
|
|
69
67
|
this.container.getOrCreateContainer(key, new (LoroContainer as any)()),
|
|
70
|
-
readonly: this.readonly,
|
|
71
68
|
autoCommit: this._params.autoCommit,
|
|
69
|
+
batchedMutation: this.batchedMutation,
|
|
72
70
|
getDoc: this._params.getDoc,
|
|
73
71
|
}
|
|
74
72
|
}
|
|
@@ -96,43 +94,64 @@ export class RecordRef<
|
|
|
96
94
|
* This is the method used for write operations.
|
|
97
95
|
*/
|
|
98
96
|
getOrCreateRef(key: string): any {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
// For value shapes, first try to get the value from the container
|
|
97
|
+
const shape = this.shape.shape
|
|
98
|
+
|
|
99
|
+
if (isValueShape(shape)) {
|
|
100
|
+
// When NOT in batchedMutation mode (direct access outside of change()), ALWAYS read fresh
|
|
101
|
+
// from container (NEVER cache). This ensures we always get the latest value
|
|
102
|
+
// from the CRDT, even when modified by a different ref instance (e.g., drafts from change())
|
|
103
|
+
//
|
|
104
|
+
// When in batchedMutation mode (inside change()), we cache value shapes so that
|
|
105
|
+
// mutations to nested objects persist back to the CRDT via absorbPlainValues()
|
|
106
|
+
if (!this.batchedMutation) {
|
|
110
107
|
const containerValue = this.container.get(key)
|
|
111
108
|
if (containerValue !== undefined) {
|
|
112
|
-
|
|
109
|
+
return containerValue
|
|
110
|
+
}
|
|
111
|
+
// Fall back to placeholder if the container doesn't have the value
|
|
112
|
+
const placeholder = (this.placeholder as any)?.[key]
|
|
113
|
+
if (placeholder !== undefined) {
|
|
114
|
+
return placeholder
|
|
115
|
+
}
|
|
116
|
+
// Fall back to the default value from the shape
|
|
117
|
+
return (shape as any)._plain
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// In batched mode (within change()), we cache value shapes so that
|
|
121
|
+
// mutations to nested objects persist back to the CRDT via absorbPlainValues()
|
|
122
|
+
let ref = this.refCache.get(key)
|
|
123
|
+
if (!ref) {
|
|
124
|
+
const containerValue = this.container.get(key)
|
|
125
|
+
if (containerValue !== undefined) {
|
|
126
|
+
// For objects, create a deep copy so mutations can be tracked
|
|
127
|
+
if (typeof containerValue === "object" && containerValue !== null) {
|
|
128
|
+
ref = JSON.parse(JSON.stringify(containerValue))
|
|
129
|
+
} else {
|
|
130
|
+
ref = containerValue as Value
|
|
131
|
+
}
|
|
113
132
|
} else {
|
|
114
|
-
//
|
|
133
|
+
// Fall back to placeholder if the container doesn't have the value
|
|
115
134
|
const placeholder = (this.placeholder as any)?.[key]
|
|
116
|
-
if (placeholder
|
|
117
|
-
// If it's a value type and not in container or placeholder,
|
|
118
|
-
// fallback to the default value from the shape
|
|
119
|
-
ref = (shape as any)._plain
|
|
120
|
-
} else {
|
|
135
|
+
if (placeholder !== undefined) {
|
|
121
136
|
ref = placeholder as Value
|
|
137
|
+
} else {
|
|
138
|
+
// Fall back to the default value from the shape
|
|
139
|
+
ref = (shape as any)._plain
|
|
122
140
|
}
|
|
123
141
|
}
|
|
124
|
-
|
|
125
|
-
if (ref !== undefined && !this.readonly) {
|
|
126
|
-
this.refCache.set(key, ref)
|
|
127
|
-
}
|
|
142
|
+
this.refCache.set(key, ref)
|
|
128
143
|
}
|
|
144
|
+
return ref
|
|
129
145
|
}
|
|
130
146
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
147
|
+
// For container shapes, we can safely cache the ref since it's a handle
|
|
148
|
+
// to the underlying Loro container, not a value copy.
|
|
149
|
+
let ref = this.refCache.get(key)
|
|
150
|
+
if (!ref) {
|
|
151
|
+
ref = createContainerTypedRef(
|
|
152
|
+
this.getTypedRefParams(key, shape as ContainerShape),
|
|
135
153
|
)
|
|
154
|
+
this.refCache.set(key, ref)
|
|
136
155
|
}
|
|
137
156
|
|
|
138
157
|
return ref as any
|
|
@@ -143,7 +162,6 @@ export class RecordRef<
|
|
|
143
162
|
}
|
|
144
163
|
|
|
145
164
|
set(key: string, value: any): void {
|
|
146
|
-
this.assertMutable()
|
|
147
165
|
if (isValueShape(this.shape.shape)) {
|
|
148
166
|
this.container.set(key, value)
|
|
149
167
|
this.refCache.set(key, value)
|
|
@@ -163,14 +181,12 @@ export class RecordRef<
|
|
|
163
181
|
}
|
|
164
182
|
|
|
165
183
|
setContainer<C extends Container>(key: string, container: C): C {
|
|
166
|
-
this.assertMutable()
|
|
167
184
|
const result = this.container.setContainer(key, container)
|
|
168
185
|
this.commitIfAuto()
|
|
169
186
|
return result
|
|
170
187
|
}
|
|
171
188
|
|
|
172
189
|
delete(key: string): void {
|
|
173
|
-
this.assertMutable()
|
|
174
190
|
this.container.delete(key)
|
|
175
191
|
this.refCache.delete(key)
|
|
176
192
|
this.commitIfAuto()
|
|
@@ -193,25 +209,6 @@ export class RecordRef<
|
|
|
193
209
|
}
|
|
194
210
|
|
|
195
211
|
toJSON(): Record<string, Infer<NestedShape>> {
|
|
196
|
-
// Fast path: readonly mode
|
|
197
|
-
if (this.readonly) {
|
|
198
|
-
const nativeJson = this.container.toJSON() as Record<string, any>
|
|
199
|
-
// For records, we need to overlay placeholders for each entry's nested shape
|
|
200
|
-
const result: Record<string, Infer<NestedShape>> = {}
|
|
201
|
-
for (const key of Object.keys(nativeJson)) {
|
|
202
|
-
// For records, the placeholder is always {}, so we need to derive
|
|
203
|
-
// the placeholder for the nested shape on the fly
|
|
204
|
-
const nestedPlaceholderValue = deriveShapePlaceholder(this.shape.shape)
|
|
205
|
-
|
|
206
|
-
result[key] = mergeValue(
|
|
207
|
-
this.shape.shape,
|
|
208
|
-
nativeJson[key],
|
|
209
|
-
nestedPlaceholderValue as Value,
|
|
210
|
-
) as Infer<NestedShape>
|
|
211
|
-
}
|
|
212
|
-
return result
|
|
213
|
-
}
|
|
214
|
-
|
|
215
212
|
return serializeRefToJSON(this, this.keys()) as Record<
|
|
216
213
|
string,
|
|
217
214
|
Infer<NestedShape>
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { change, createTypedDoc, Shape } from "../index.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for StructRef value updates across multiple change() calls.
|
|
6
|
+
*
|
|
7
|
+
* These tests verify that value shapes in structs are always read fresh from the
|
|
8
|
+
* underlying container, preventing stale cache issues when mutations occur in
|
|
9
|
+
* separate change() transactions.
|
|
10
|
+
*
|
|
11
|
+
* BUG: StructRef.getOrCreateRef() caches value shapes in propertyCache, causing
|
|
12
|
+
* stale values to be returned when the underlying container is modified by a
|
|
13
|
+
* different StructRef instance (e.g., drafts created by change()).
|
|
14
|
+
*/
|
|
15
|
+
describe("Struct value updates across change() calls", () => {
|
|
16
|
+
describe("updating existing properties", () => {
|
|
17
|
+
it("updates value property via direct assignment", () => {
|
|
18
|
+
const Schema = Shape.doc({
|
|
19
|
+
config: Shape.struct({
|
|
20
|
+
name: Shape.plain.string(),
|
|
21
|
+
count: Shape.plain.number(),
|
|
22
|
+
}),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const doc = createTypedDoc(Schema)
|
|
26
|
+
|
|
27
|
+
change(doc, draft => {
|
|
28
|
+
draft.config.name = "initial"
|
|
29
|
+
draft.config.count = 1
|
|
30
|
+
})
|
|
31
|
+
expect(doc.config.name).toBe("initial")
|
|
32
|
+
expect(doc.config.count).toBe(1)
|
|
33
|
+
|
|
34
|
+
// Second change - BUG: values stay at initial values
|
|
35
|
+
change(doc, draft => {
|
|
36
|
+
draft.config.name = "updated"
|
|
37
|
+
draft.config.count = 2
|
|
38
|
+
})
|
|
39
|
+
expect(doc.config.name).toBe("updated") // FAILS: returns "initial"
|
|
40
|
+
expect(doc.config.count).toBe(2) // FAILS: returns 1
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("handles multiple sequential updates to same property", () => {
|
|
44
|
+
const Schema = Shape.doc({
|
|
45
|
+
settings: Shape.struct({
|
|
46
|
+
value: Shape.plain.number(),
|
|
47
|
+
}),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const doc = createTypedDoc(Schema)
|
|
51
|
+
|
|
52
|
+
for (let i = 1; i <= 5; i++) {
|
|
53
|
+
change(doc, draft => {
|
|
54
|
+
draft.settings.value = i
|
|
55
|
+
})
|
|
56
|
+
expect(doc.settings.value).toBe(i) // FAILS on i > 1
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("updates boolean property", () => {
|
|
61
|
+
const Schema = Shape.doc({
|
|
62
|
+
flags: Shape.struct({
|
|
63
|
+
enabled: Shape.plain.boolean(),
|
|
64
|
+
}),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const doc = createTypedDoc(Schema)
|
|
68
|
+
|
|
69
|
+
change(doc, draft => {
|
|
70
|
+
draft.flags.enabled = true
|
|
71
|
+
})
|
|
72
|
+
expect(doc.flags.enabled).toBe(true)
|
|
73
|
+
|
|
74
|
+
change(doc, draft => {
|
|
75
|
+
draft.flags.enabled = false
|
|
76
|
+
})
|
|
77
|
+
expect(doc.flags.enabled).toBe(false) // FAILS: returns true
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe("nested structs", () => {
|
|
82
|
+
it("updates value in nested struct", () => {
|
|
83
|
+
const Schema = Shape.doc({
|
|
84
|
+
outer: Shape.struct({
|
|
85
|
+
inner: Shape.struct({
|
|
86
|
+
value: Shape.plain.number(),
|
|
87
|
+
}),
|
|
88
|
+
}),
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const doc = createTypedDoc(Schema)
|
|
92
|
+
|
|
93
|
+
change(doc, draft => {
|
|
94
|
+
draft.outer.inner.value = 100
|
|
95
|
+
})
|
|
96
|
+
expect(doc.outer.inner.value).toBe(100)
|
|
97
|
+
|
|
98
|
+
change(doc, draft => {
|
|
99
|
+
draft.outer.inner.value = 200
|
|
100
|
+
})
|
|
101
|
+
expect(doc.outer.inner.value).toBe(200) // FAILS: returns 100
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe("struct inside record", () => {
|
|
106
|
+
it("updates value in struct that is a record value", () => {
|
|
107
|
+
const Schema = Shape.doc({
|
|
108
|
+
users: Shape.record(
|
|
109
|
+
Shape.struct({
|
|
110
|
+
name: Shape.plain.string(),
|
|
111
|
+
age: Shape.plain.number(),
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const doc = createTypedDoc(Schema)
|
|
117
|
+
|
|
118
|
+
change(doc, draft => {
|
|
119
|
+
draft.users.user1 = { name: "Alice", age: 30 }
|
|
120
|
+
})
|
|
121
|
+
expect(doc.users.user1?.name).toBe("Alice")
|
|
122
|
+
expect(doc.users.user1?.age).toBe(30)
|
|
123
|
+
|
|
124
|
+
// Update the struct's value properties
|
|
125
|
+
change(doc, draft => {
|
|
126
|
+
const user = draft.users.getOrCreateRef("user1")
|
|
127
|
+
user.name = "Bob"
|
|
128
|
+
user.age = 25
|
|
129
|
+
})
|
|
130
|
+
expect(doc.users.user1?.name).toBe("Bob") // FAILS: returns "Alice"
|
|
131
|
+
expect(doc.users.user1?.age).toBe(25) // FAILS: returns 30
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe("toJSON() consistency", () => {
|
|
136
|
+
it("reflects updates in toJSON()", () => {
|
|
137
|
+
const Schema = Shape.doc({
|
|
138
|
+
data: Shape.struct({
|
|
139
|
+
status: Shape.plain.string(),
|
|
140
|
+
}),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const doc = createTypedDoc(Schema)
|
|
144
|
+
|
|
145
|
+
change(doc, draft => {
|
|
146
|
+
draft.data.status = "pending"
|
|
147
|
+
})
|
|
148
|
+
expect(doc.toJSON().data).toEqual({ status: "pending" })
|
|
149
|
+
|
|
150
|
+
change(doc, draft => {
|
|
151
|
+
draft.data.status = "complete"
|
|
152
|
+
})
|
|
153
|
+
expect(doc.toJSON().data).toEqual({ status: "complete" })
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe("reading before first change", () => {
|
|
158
|
+
it("reading before any change should not cause stale cache", () => {
|
|
159
|
+
const Schema = Shape.doc({
|
|
160
|
+
config: Shape.struct({
|
|
161
|
+
value: Shape.plain.number().placeholder(0),
|
|
162
|
+
}),
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const doc = createTypedDoc(Schema)
|
|
166
|
+
|
|
167
|
+
// Read before any change - gets placeholder value
|
|
168
|
+
expect(doc.config.value).toBe(0)
|
|
169
|
+
|
|
170
|
+
// First set
|
|
171
|
+
change(doc, draft => {
|
|
172
|
+
draft.config.value = 100
|
|
173
|
+
})
|
|
174
|
+
expect(doc.config.value).toBe(100)
|
|
175
|
+
|
|
176
|
+
// Second set - this is the key test for the stale cache bug
|
|
177
|
+
change(doc, draft => {
|
|
178
|
+
draft.config.value = 200
|
|
179
|
+
})
|
|
180
|
+
expect(doc.config.value).toBe(200) // FAILS: returns 100
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe("comparison with raw LoroDoc", () => {
|
|
185
|
+
it("underlying CRDT operations work correctly", async () => {
|
|
186
|
+
const { LoroDoc } = await import("loro-crdt")
|
|
187
|
+
|
|
188
|
+
const doc = new LoroDoc()
|
|
189
|
+
const map = doc.getMap("config")
|
|
190
|
+
|
|
191
|
+
map.set("value", 123)
|
|
192
|
+
doc.commit()
|
|
193
|
+
expect(map.get("value")).toBe(123)
|
|
194
|
+
|
|
195
|
+
map.set("value", 456)
|
|
196
|
+
doc.commit()
|
|
197
|
+
expect(map.get("value")).toBe(456) // PASSES: raw Loro works fine
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
})
|