@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.
- package/README.md +289 -150
- package/dist/index.d.ts +1012 -310
- package/dist/index.js +1334 -568
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/change.test.ts +51 -52
- package/src/functional-helpers.test.ts +316 -4
- package/src/functional-helpers.ts +96 -6
- package/src/grand-unified-api.test.ts +35 -29
- package/src/index.ts +27 -1
- package/src/json-patch.test.ts +46 -27
- package/src/loro.test.ts +449 -0
- package/src/loro.ts +273 -0
- package/src/overlay-recursion.test.ts +1 -1
- package/src/overlay.ts +62 -3
- package/src/path-evaluator.ts +1 -1
- package/src/path-selector.test.ts +94 -1
- package/src/shape.ts +107 -14
- package/src/typed-doc.ts +100 -98
- package/src/typed-refs/base.ts +126 -46
- package/src/typed-refs/counter-ref-internals.ts +62 -0
- package/src/typed-refs/{counter.test.ts → counter-ref.test.ts} +5 -4
- package/src/typed-refs/counter-ref.ts +45 -0
- package/src/typed-refs/{doc.ts → doc-ref-internals.ts} +33 -56
- package/src/typed-refs/doc-ref.ts +47 -0
- package/src/typed-refs/encapsulation.test.ts +226 -0
- package/src/typed-refs/list-ref-base-internals.ts +280 -0
- package/src/typed-refs/list-ref-base.ts +518 -0
- package/src/typed-refs/list-ref-internals.ts +21 -0
- package/src/typed-refs/list-ref-value-updates.test.ts +213 -0
- package/src/typed-refs/{list.ts → list-ref.ts} +10 -11
- package/src/typed-refs/movable-list-ref-internals.ts +38 -0
- package/src/typed-refs/movable-list-ref.ts +31 -0
- package/src/typed-refs/proxy-handlers.ts +13 -4
- package/src/typed-refs/record-ref-internals.ts +216 -0
- package/src/typed-refs/record-ref-value-updates.test.ts +214 -0
- package/src/typed-refs/{record.test.ts → record-ref.test.ts} +21 -16
- package/src/typed-refs/record-ref.ts +80 -0
- package/src/typed-refs/struct-ref-internals.ts +195 -0
- package/src/typed-refs/struct-ref.test.ts +202 -0
- package/src/typed-refs/struct-ref.ts +257 -0
- package/src/typed-refs/text-ref-internals.ts +100 -0
- package/src/typed-refs/text-ref.ts +72 -0
- package/src/typed-refs/tree-node-ref-internals.ts +111 -0
- package/src/typed-refs/tree-node-ref.test.ts +234 -0
- package/src/typed-refs/tree-node-ref.ts +200 -0
- package/src/typed-refs/tree-node.test.ts +384 -0
- package/src/typed-refs/tree-ref-internals.ts +110 -0
- package/src/typed-refs/tree-ref.ts +194 -0
- package/src/typed-refs/utils.ts +38 -17
- package/src/types.ts +36 -1
- package/src/utils/type-guards.ts +1 -0
- package/src/typed-refs/counter.ts +0 -64
- package/src/typed-refs/list-base.ts +0 -424
- package/src/typed-refs/movable-list.ts +0 -34
- package/src/typed-refs/record.ts +0 -220
- package/src/typed-refs/struct.ts +0 -206
- package/src/typed-refs/text.ts +0 -97
- package/src/typed-refs/tree.ts +0 -40
- /package/src/typed-refs/{list.test.ts → list-ref.test.ts} +0 -0
- /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
|
-
|
|
16
|
-
draft.scores.
|
|
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.
|
|
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.
|
|
43
|
-
draft.notes.
|
|
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.
|
|
61
|
-
groupA
|
|
62
|
-
groupA
|
|
61
|
+
const groupA = draft.groups.get("groupA")
|
|
62
|
+
groupA?.push("alice")
|
|
63
|
+
groupA?.push("bob")
|
|
63
64
|
|
|
64
|
-
const groupB = draft.groups.
|
|
65
|
-
groupB
|
|
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.
|
|
175
|
-
alice
|
|
176
|
-
|
|
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.
|
|
179
|
-
bob
|
|
180
|
-
|
|
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
|
+
}
|