@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,213 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { change, createTypedDoc, Shape } from "../index.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for List value updates across multiple change() calls.
|
|
6
|
+
*
|
|
7
|
+
* ListRefBase has a different caching pattern than RecordRef/StructRef:
|
|
8
|
+
* - It caches items in itemCache
|
|
9
|
+
* - The cache is cleared in absorbPlainValues() after each change()
|
|
10
|
+
*
|
|
11
|
+
* However, there may still be stale cache issues if:
|
|
12
|
+
* 1. Items are accessed outside of change() (populating the cache)
|
|
13
|
+
* 2. Items are modified in a change() (different list instance)
|
|
14
|
+
* 3. Items are accessed again outside of change() (stale cache?)
|
|
15
|
+
*
|
|
16
|
+
* Note: Lists don't support direct item modification like records/structs.
|
|
17
|
+
* To "update" an item, you typically delete and re-insert, or modify
|
|
18
|
+
* nested container properties.
|
|
19
|
+
*/
|
|
20
|
+
describe("List value updates across change() calls", () => {
|
|
21
|
+
describe("primitive value lists", () => {
|
|
22
|
+
it("reads updated values after delete and insert", () => {
|
|
23
|
+
const Schema = Shape.doc({
|
|
24
|
+
numbers: Shape.list(Shape.plain.number()),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const doc = createTypedDoc(Schema)
|
|
28
|
+
|
|
29
|
+
change(doc, draft => {
|
|
30
|
+
draft.numbers.push(100)
|
|
31
|
+
draft.numbers.push(200)
|
|
32
|
+
draft.numbers.push(300)
|
|
33
|
+
})
|
|
34
|
+
expect(doc.numbers.get(0)).toBe(100)
|
|
35
|
+
expect(doc.numbers.get(1)).toBe(200)
|
|
36
|
+
expect(doc.numbers.get(2)).toBe(300)
|
|
37
|
+
|
|
38
|
+
// Modify by deleting and inserting
|
|
39
|
+
change(doc, draft => {
|
|
40
|
+
draft.numbers.delete(1, 1) // Remove 200
|
|
41
|
+
draft.numbers.insert(1, 999) // Insert 999 at position 1
|
|
42
|
+
})
|
|
43
|
+
expect(doc.numbers.get(0)).toBe(100)
|
|
44
|
+
expect(doc.numbers.get(1)).toBe(999) // Should be 999, not 200
|
|
45
|
+
expect(doc.numbers.get(2)).toBe(300)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("reads correct values after multiple push operations", () => {
|
|
49
|
+
const Schema = Shape.doc({
|
|
50
|
+
items: Shape.list(Shape.plain.string()),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const doc = createTypedDoc(Schema)
|
|
54
|
+
|
|
55
|
+
change(doc, draft => {
|
|
56
|
+
draft.items.push("first")
|
|
57
|
+
})
|
|
58
|
+
expect(doc.items.get(0)).toBe("first")
|
|
59
|
+
|
|
60
|
+
change(doc, draft => {
|
|
61
|
+
draft.items.push("second")
|
|
62
|
+
})
|
|
63
|
+
expect(doc.items.get(0)).toBe("first")
|
|
64
|
+
expect(doc.items.get(1)).toBe("second")
|
|
65
|
+
|
|
66
|
+
change(doc, draft => {
|
|
67
|
+
draft.items.push("third")
|
|
68
|
+
})
|
|
69
|
+
expect(doc.items.get(0)).toBe("first")
|
|
70
|
+
expect(doc.items.get(1)).toBe("second")
|
|
71
|
+
expect(doc.items.get(2)).toBe("third")
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe("object value lists", () => {
|
|
76
|
+
it("reads updated object values after modification", () => {
|
|
77
|
+
const Schema = Shape.doc({
|
|
78
|
+
items: Shape.list(
|
|
79
|
+
Shape.plain.object({
|
|
80
|
+
name: Shape.plain.string(),
|
|
81
|
+
value: Shape.plain.number(),
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const doc = createTypedDoc(Schema)
|
|
87
|
+
|
|
88
|
+
change(doc, draft => {
|
|
89
|
+
draft.items.push({ name: "item1", value: 100 })
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// Access item outside of change() - this may populate cache
|
|
93
|
+
const item0 = doc.items.get(0)
|
|
94
|
+
expect(item0?.name).toBe("item1")
|
|
95
|
+
expect(item0?.value).toBe(100)
|
|
96
|
+
|
|
97
|
+
// Modify by replacing the item
|
|
98
|
+
change(doc, draft => {
|
|
99
|
+
draft.items.delete(0, 1)
|
|
100
|
+
draft.items.insert(0, { name: "updated", value: 999 })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Read again - should see updated values
|
|
104
|
+
const item0After = doc.items.get(0)
|
|
105
|
+
expect(item0After?.name).toBe("updated")
|
|
106
|
+
expect(item0After?.value).toBe(999)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe("list of structs (container shapes)", () => {
|
|
111
|
+
it("reads updated struct properties after modification", () => {
|
|
112
|
+
const Schema = Shape.doc({
|
|
113
|
+
users: Shape.list(
|
|
114
|
+
Shape.struct({
|
|
115
|
+
name: Shape.plain.string(),
|
|
116
|
+
age: Shape.plain.number(),
|
|
117
|
+
}),
|
|
118
|
+
),
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const doc = createTypedDoc(Schema)
|
|
122
|
+
|
|
123
|
+
change(doc, draft => {
|
|
124
|
+
draft.users.push({ name: "Alice", age: 30 })
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// Access outside of change()
|
|
128
|
+
expect(doc.users.get(0)?.name).toBe("Alice")
|
|
129
|
+
expect(doc.users.get(0)?.age).toBe(30)
|
|
130
|
+
|
|
131
|
+
// Modify the struct's properties in a new change()
|
|
132
|
+
change(doc, draft => {
|
|
133
|
+
const user = draft.users.get(0)
|
|
134
|
+
if (user) {
|
|
135
|
+
user.name = "Bob"
|
|
136
|
+
user.age = 25
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// Read again - should see updated values
|
|
141
|
+
// This tests if the cached StructRef returns stale values
|
|
142
|
+
expect(doc.users.get(0)?.name).toBe("Bob") // May fail due to StructRef cache
|
|
143
|
+
expect(doc.users.get(0)?.age).toBe(25) // May fail due to StructRef cache
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("handles multiple updates to same struct in list", () => {
|
|
147
|
+
const Schema = Shape.doc({
|
|
148
|
+
items: Shape.list(
|
|
149
|
+
Shape.struct({
|
|
150
|
+
count: Shape.plain.number(),
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const doc = createTypedDoc(Schema)
|
|
156
|
+
|
|
157
|
+
change(doc, draft => {
|
|
158
|
+
draft.items.push({ count: 0 })
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// Multiple updates
|
|
162
|
+
for (let i = 1; i <= 5; i++) {
|
|
163
|
+
change(doc, draft => {
|
|
164
|
+
const item = draft.items.get(0)
|
|
165
|
+
if (item) {
|
|
166
|
+
item.count = i
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
expect(doc.items.get(0)?.count).toBe(i) // May fail on i > 1
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe("toJSON() consistency", () => {
|
|
175
|
+
it("reflects updates in toJSON()", () => {
|
|
176
|
+
const Schema = Shape.doc({
|
|
177
|
+
values: Shape.list(Shape.plain.number()),
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const doc = createTypedDoc(Schema)
|
|
181
|
+
|
|
182
|
+
change(doc, draft => {
|
|
183
|
+
draft.values.push(1)
|
|
184
|
+
draft.values.push(2)
|
|
185
|
+
})
|
|
186
|
+
expect(doc.toJSON().values).toEqual([1, 2])
|
|
187
|
+
|
|
188
|
+
change(doc, draft => {
|
|
189
|
+
draft.values.delete(0, 1)
|
|
190
|
+
draft.values.insert(0, 99)
|
|
191
|
+
})
|
|
192
|
+
expect(doc.toJSON().values).toEqual([99, 2])
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe("comparison with raw LoroDoc", () => {
|
|
197
|
+
it("underlying CRDT operations work correctly", async () => {
|
|
198
|
+
const { LoroDoc } = await import("loro-crdt")
|
|
199
|
+
|
|
200
|
+
const doc = new LoroDoc()
|
|
201
|
+
const list = doc.getList("items")
|
|
202
|
+
|
|
203
|
+
list.push(100)
|
|
204
|
+
doc.commit()
|
|
205
|
+
expect(list.get(0)).toBe(100)
|
|
206
|
+
|
|
207
|
+
list.delete(0, 1)
|
|
208
|
+
list.insert(0, 999)
|
|
209
|
+
doc.commit()
|
|
210
|
+
expect(list.get(0)).toBe(999) // PASSES: raw Loro works fine
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import type { LoroList } from "loro-crdt"
|
|
2
1
|
import type { ContainerOrValueShape } from "../shape.js"
|
|
3
2
|
import type { InferMutableType } from "../types.js"
|
|
4
|
-
import {
|
|
3
|
+
import type { TypedRefParams } from "./base.js"
|
|
4
|
+
import { ListRefBase } from "./list-ref-base.js"
|
|
5
|
+
import { ListRefInternals } from "./list-ref-internals.js"
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* List typed ref - thin facade that delegates to ListRefInternals.
|
|
9
|
+
*/
|
|
7
10
|
export class ListRef<
|
|
8
11
|
NestedShape extends ContainerOrValueShape,
|
|
9
12
|
> extends ListRefBase<NestedShape> {
|
|
@@ -12,13 +15,9 @@ export class ListRef<
|
|
|
12
15
|
// TypeScript may require type assertions for plain value assignments.
|
|
13
16
|
[index: number]: InferMutableType<NestedShape> | undefined
|
|
14
17
|
|
|
15
|
-
protected
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
protected absorbValueAtIndex(index: number, value: any): void {
|
|
20
|
-
// LoroList doesn't have set method, need to delete and insert
|
|
21
|
-
this.container.delete(index, 1)
|
|
22
|
-
this.container.insert(index, value)
|
|
18
|
+
protected override createInternals(
|
|
19
|
+
params: TypedRefParams<any>,
|
|
20
|
+
): ListRefInternals<NestedShape> {
|
|
21
|
+
return new ListRefInternals(params)
|
|
23
22
|
}
|
|
24
23
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { LoroMovableList } from "loro-crdt"
|
|
2
|
+
import type { ContainerOrValueShape } from "../shape.js"
|
|
3
|
+
import { ListRefBaseInternals } from "./list-ref-base.js"
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// MovableListRefInternals - Internal implementation class
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Internal implementation for MovableListRef.
|
|
11
|
+
* Extends ListRefBaseInternals with LoroMovableList-specific methods.
|
|
12
|
+
*/
|
|
13
|
+
export class MovableListRefInternals<
|
|
14
|
+
NestedShape extends ContainerOrValueShape,
|
|
15
|
+
Item = NestedShape["_plain"],
|
|
16
|
+
MutableItem = NestedShape["_mutable"],
|
|
17
|
+
> extends ListRefBaseInternals<NestedShape, Item, MutableItem> {
|
|
18
|
+
/** Absorb value at specific index for LoroMovableList */
|
|
19
|
+
override absorbValueAtIndex(index: number, value: unknown): void {
|
|
20
|
+
// LoroMovableList has set method
|
|
21
|
+
const container = this.getContainer() as LoroMovableList
|
|
22
|
+
container.set(index, value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Move an item from one index to another */
|
|
26
|
+
move(from: number, to: number): void {
|
|
27
|
+
const container = this.getContainer() as LoroMovableList
|
|
28
|
+
container.move(from, to)
|
|
29
|
+
this.commitIfAuto()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Set an item at a specific index */
|
|
33
|
+
set(index: number, item: unknown): void {
|
|
34
|
+
const container = this.getContainer() as LoroMovableList
|
|
35
|
+
container.set(index, item)
|
|
36
|
+
this.commitIfAuto()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Container } from "loro-crdt"
|
|
2
|
+
import type { ContainerOrValueShape } from "../shape.js"
|
|
3
|
+
import type { InferMutableType } from "../types.js"
|
|
4
|
+
import { INTERNAL_SYMBOL, type TypedRefParams } from "./base.js"
|
|
5
|
+
import { ListRefBase } from "./list-ref-base.js"
|
|
6
|
+
import { MovableListRefInternals } from "./movable-list-ref-internals.js"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Movable list typed ref - thin facade that delegates to MovableListRefInternals.
|
|
10
|
+
*/
|
|
11
|
+
export class MovableListRef<
|
|
12
|
+
NestedShape extends ContainerOrValueShape,
|
|
13
|
+
Item = NestedShape["_plain"],
|
|
14
|
+
> extends ListRefBase<NestedShape> {
|
|
15
|
+
declare [INTERNAL_SYMBOL]: MovableListRefInternals<NestedShape>;
|
|
16
|
+
[index: number]: InferMutableType<NestedShape> | undefined
|
|
17
|
+
|
|
18
|
+
protected override createInternals(
|
|
19
|
+
params: TypedRefParams<any>,
|
|
20
|
+
): MovableListRefInternals<NestedShape> {
|
|
21
|
+
return new MovableListRefInternals(params)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
move(from: number, to: number): void {
|
|
25
|
+
this[INTERNAL_SYMBOL].move(from, to)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
set(index: number, item: Exclude<Item, Container>) {
|
|
29
|
+
this[INTERNAL_SYMBOL].set(index, item)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type {
|
|
3
|
-
import type {
|
|
1
|
+
import { INTERNAL_SYMBOL } from "./base.js"
|
|
2
|
+
import type { ListRef } from "./list-ref.js"
|
|
3
|
+
import type { MovableListRef } from "./movable-list-ref.js"
|
|
4
|
+
import type { RecordRef } from "./record-ref.js"
|
|
5
|
+
import type { RecordRefInternals } from "./record-ref-internals.js"
|
|
4
6
|
|
|
5
7
|
export const recordProxyHandler: ProxyHandler<RecordRef<any>> = {
|
|
6
8
|
get: (target, prop) => {
|
|
7
9
|
if (typeof prop === "string" && !(prop in target)) {
|
|
8
10
|
// Use getRef for reading - returns undefined for non-existent keys
|
|
9
|
-
return target.getRef(prop)
|
|
11
|
+
return (target[INTERNAL_SYMBOL] as RecordRefInternals<any>).getRef(prop)
|
|
10
12
|
}
|
|
11
13
|
return Reflect.get(target, prop)
|
|
12
14
|
},
|
|
15
|
+
|
|
13
16
|
set: (target, prop, value) => {
|
|
14
17
|
if (typeof prop === "string" && !(prop in target)) {
|
|
15
18
|
target.set(prop, value)
|
|
@@ -17,6 +20,7 @@ export const recordProxyHandler: ProxyHandler<RecordRef<any>> = {
|
|
|
17
20
|
}
|
|
18
21
|
return Reflect.set(target, prop, value)
|
|
19
22
|
},
|
|
23
|
+
|
|
20
24
|
deleteProperty: (target, prop) => {
|
|
21
25
|
if (typeof prop === "string" && !(prop in target)) {
|
|
22
26
|
target.delete(prop)
|
|
@@ -24,6 +28,7 @@ export const recordProxyHandler: ProxyHandler<RecordRef<any>> = {
|
|
|
24
28
|
}
|
|
25
29
|
return Reflect.deleteProperty(target, prop)
|
|
26
30
|
},
|
|
31
|
+
|
|
27
32
|
// Support `in` operator for checking key existence
|
|
28
33
|
has: (target, prop) => {
|
|
29
34
|
if (typeof prop === "string") {
|
|
@@ -36,9 +41,11 @@ export const recordProxyHandler: ProxyHandler<RecordRef<any>> = {
|
|
|
36
41
|
}
|
|
37
42
|
return Reflect.has(target, prop)
|
|
38
43
|
},
|
|
44
|
+
|
|
39
45
|
ownKeys: target => {
|
|
40
46
|
return target.keys()
|
|
41
47
|
},
|
|
48
|
+
|
|
42
49
|
getOwnPropertyDescriptor: (target, prop) => {
|
|
43
50
|
if (typeof prop === "string" && target.has(prop)) {
|
|
44
51
|
return {
|
|
@@ -61,6 +68,7 @@ export const listProxyHandler: ProxyHandler<ListRef<any>> = {
|
|
|
61
68
|
}
|
|
62
69
|
return Reflect.get(target, prop)
|
|
63
70
|
},
|
|
71
|
+
|
|
64
72
|
set: (target, prop, value) => {
|
|
65
73
|
if (typeof prop === "string") {
|
|
66
74
|
const index = Number(prop)
|
|
@@ -85,6 +93,7 @@ export const movableListProxyHandler: ProxyHandler<MovableListRef<any>> = {
|
|
|
85
93
|
}
|
|
86
94
|
return Reflect.get(target, prop)
|
|
87
95
|
},
|
|
96
|
+
|
|
88
97
|
set: (target, prop, value) => {
|
|
89
98
|
if (typeof prop === "string") {
|
|
90
99
|
const index = Number(prop)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Container,
|
|
3
|
+
LoroDoc,
|
|
4
|
+
LoroMap,
|
|
5
|
+
Subscription,
|
|
6
|
+
Value,
|
|
7
|
+
} from "loro-crdt"
|
|
8
|
+
import { deriveShapePlaceholder } from "../derive-placeholder.js"
|
|
9
|
+
import type { LoroMapRef } from "../loro.js"
|
|
10
|
+
import type {
|
|
11
|
+
ContainerOrValueShape,
|
|
12
|
+
ContainerShape,
|
|
13
|
+
RecordContainerShape,
|
|
14
|
+
} from "../shape.js"
|
|
15
|
+
import { isContainerShape, 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 RecordRef.
|
|
27
|
+
* Contains all logic, state, and implementation details.
|
|
28
|
+
*/
|
|
29
|
+
export class RecordRefInternals<
|
|
30
|
+
NestedShape extends ContainerOrValueShape,
|
|
31
|
+
> extends BaseRefInternals<any> {
|
|
32
|
+
private refCache = 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
|
+
// First try to get placeholder from the Record's placeholder (if it has an entry for this key)
|
|
40
|
+
let placeholder = (this.getPlaceholder() as any)?.[key]
|
|
41
|
+
|
|
42
|
+
// If no placeholder exists for this key, derive one from the schema's shape
|
|
43
|
+
// This is critical for Records where the placeholder is always {} but nested
|
|
44
|
+
// containers need valid placeholders to fall back to for missing values
|
|
45
|
+
if (placeholder === undefined) {
|
|
46
|
+
placeholder = deriveShapePlaceholder(shape)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// AnyContainerShape is an escape hatch - it doesn't have a constructor
|
|
50
|
+
if (!hasContainerConstructor(shape._type)) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Cannot create typed ref for shape type "${shape._type}". ` +
|
|
53
|
+
`Use Shape.any() only at the document root level.`,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const LoroContainer = containerConstructor[shape._type]
|
|
58
|
+
const container = this.getContainer() as LoroMap
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
shape,
|
|
62
|
+
placeholder,
|
|
63
|
+
getContainer: () =>
|
|
64
|
+
container.getOrCreateContainer(key, new (LoroContainer as any)()),
|
|
65
|
+
autoCommit: this.getAutoCommit(),
|
|
66
|
+
batchedMutation: this.getBatchedMutation(),
|
|
67
|
+
getDoc: () => this.getDoc(),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Get a ref for a key without creating (returns undefined for non-existent container keys) */
|
|
72
|
+
getRef(key: string): unknown {
|
|
73
|
+
const recordShape = this.getShape() as RecordContainerShape<NestedShape>
|
|
74
|
+
const shape = recordShape.shape
|
|
75
|
+
const container = this.getContainer() as LoroMap
|
|
76
|
+
|
|
77
|
+
// For container shapes, check if the key exists first
|
|
78
|
+
// This allows optional chaining (?.) to work correctly for non-existent keys
|
|
79
|
+
if (isContainerShape(shape)) {
|
|
80
|
+
const existing = container.get(key)
|
|
81
|
+
if (existing === undefined) {
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return this.getOrCreateRef(key)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Get or create a ref for a key (always creates for container shapes) */
|
|
90
|
+
getOrCreateRef(key: string): unknown {
|
|
91
|
+
const recordShape = this.getShape() as RecordContainerShape<NestedShape>
|
|
92
|
+
const shape = recordShape.shape
|
|
93
|
+
const container = this.getContainer() as LoroMap
|
|
94
|
+
|
|
95
|
+
if (isValueShape(shape)) {
|
|
96
|
+
// When NOT in batchedMutation mode (direct access outside of change()), ALWAYS read fresh
|
|
97
|
+
// from container (NEVER cache). This ensures we always get the latest value
|
|
98
|
+
// from the CRDT, even when modified by a different ref instance (e.g., drafts from change())
|
|
99
|
+
//
|
|
100
|
+
// When in batchedMutation mode (inside change()), we cache value shapes so that
|
|
101
|
+
// mutations to nested objects persist back to the CRDT via absorbPlainValues()
|
|
102
|
+
if (!this.getBatchedMutation()) {
|
|
103
|
+
const containerValue = container.get(key)
|
|
104
|
+
if (containerValue !== undefined) {
|
|
105
|
+
return containerValue
|
|
106
|
+
}
|
|
107
|
+
// Fall back to placeholder if the container doesn't have the value
|
|
108
|
+
const placeholder = (this.getPlaceholder() as any)?.[key]
|
|
109
|
+
if (placeholder !== undefined) {
|
|
110
|
+
return placeholder
|
|
111
|
+
}
|
|
112
|
+
// Fall back to the default value from the shape
|
|
113
|
+
return (shape as any)._plain
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// In batched mode (within change()), we cache value shapes so that
|
|
117
|
+
// mutations to nested objects persist back to the CRDT via absorbPlainValues()
|
|
118
|
+
let ref = this.refCache.get(key)
|
|
119
|
+
if (!ref) {
|
|
120
|
+
const containerValue = container.get(key)
|
|
121
|
+
if (containerValue !== undefined) {
|
|
122
|
+
// For objects, create a deep copy so mutations can be tracked
|
|
123
|
+
if (typeof containerValue === "object" && containerValue !== null) {
|
|
124
|
+
ref = JSON.parse(JSON.stringify(containerValue))
|
|
125
|
+
} else {
|
|
126
|
+
ref = containerValue as Value
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
// Fall back to placeholder if the container doesn't have the value
|
|
130
|
+
const placeholder = (this.getPlaceholder() as any)?.[key]
|
|
131
|
+
if (placeholder !== undefined) {
|
|
132
|
+
ref = placeholder as Value
|
|
133
|
+
} else {
|
|
134
|
+
// Fall back to the default value from the shape
|
|
135
|
+
ref = (shape as any)._plain
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
this.refCache.set(key, ref)
|
|
139
|
+
}
|
|
140
|
+
return ref
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// For container shapes, we can safely cache the ref since it's a handle
|
|
144
|
+
// to the underlying Loro container, not a value copy.
|
|
145
|
+
let ref = this.refCache.get(key)
|
|
146
|
+
if (!ref) {
|
|
147
|
+
ref = createContainerTypedRef(
|
|
148
|
+
this.getTypedRefParams(key, shape as ContainerShape),
|
|
149
|
+
)
|
|
150
|
+
this.refCache.set(key, ref)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return ref as any
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Set a value at a key */
|
|
157
|
+
set(key: string, value: any): void {
|
|
158
|
+
const recordShape = this.getShape() as RecordContainerShape<NestedShape>
|
|
159
|
+
const shape = recordShape.shape
|
|
160
|
+
const container = this.getContainer() as LoroMap
|
|
161
|
+
|
|
162
|
+
if (isValueShape(shape)) {
|
|
163
|
+
container.set(key, value)
|
|
164
|
+
this.refCache.set(key, value)
|
|
165
|
+
this.commitIfAuto()
|
|
166
|
+
} else {
|
|
167
|
+
// For container shapes, try to assign the plain value
|
|
168
|
+
// Use getOrCreateRef to ensure the container is created
|
|
169
|
+
const ref = this.getOrCreateRef(key)
|
|
170
|
+
if (assignPlainValueToTypedRef(ref as TypedRef<any>, value)) {
|
|
171
|
+
this.commitIfAuto()
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
throw new Error(
|
|
175
|
+
"Cannot set container directly, modify the typed ref instead",
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Delete a key */
|
|
181
|
+
delete(key: string): void {
|
|
182
|
+
const container = this.getContainer() as LoroMap
|
|
183
|
+
container.delete(key)
|
|
184
|
+
this.refCache.delete(key)
|
|
185
|
+
this.commitIfAuto()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Absorb mutated plain values back into Loro containers */
|
|
189
|
+
absorbPlainValues(): void {
|
|
190
|
+
absorbCachedPlainValues(this.refCache, () => this.getContainer() as LoroMap)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Create the loro namespace for record */
|
|
194
|
+
protected override createLoroNamespace(): LoroMapRef {
|
|
195
|
+
const self = this
|
|
196
|
+
return {
|
|
197
|
+
get doc(): LoroDoc {
|
|
198
|
+
return self.getDoc()
|
|
199
|
+
},
|
|
200
|
+
get container(): LoroMap {
|
|
201
|
+
return self.getContainer() as LoroMap
|
|
202
|
+
},
|
|
203
|
+
subscribe(callback: (event: unknown) => void): Subscription {
|
|
204
|
+
return (self.getContainer() as LoroMap).subscribe(callback)
|
|
205
|
+
},
|
|
206
|
+
setContainer(key: string, container: Container): Container {
|
|
207
|
+
const result = (self.getContainer() as LoroMap).setContainer(
|
|
208
|
+
key,
|
|
209
|
+
container,
|
|
210
|
+
)
|
|
211
|
+
self.commitIfAuto()
|
|
212
|
+
return result
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|