@loro-extended/change 0.8.1 → 0.9.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 +78 -0
- package/dist/index.d.ts +190 -39
- package/dist/index.js +480 -295
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/change.test.ts +277 -1
- package/src/discriminated-union-assignability.test.ts +45 -0
- package/src/discriminated-union-tojson.test.ts +128 -0
- package/src/index.ts +7 -0
- package/src/placeholder-proxy.test.ts +52 -0
- package/src/placeholder-proxy.ts +37 -0
- package/src/presence-interface.ts +52 -0
- package/src/shape.ts +44 -50
- package/src/typed-doc.ts +4 -4
- package/src/typed-presence.ts +96 -0
- package/src/{draft-nodes → typed-refs}/base.ts +4 -4
- package/src/{draft-nodes → typed-refs}/counter.test.ts +1 -1
- package/src/{draft-nodes → typed-refs}/counter.ts +9 -3
- package/src/{draft-nodes → typed-refs}/doc.ts +27 -13
- package/src/typed-refs/json-compatibility.test.ts +255 -0
- package/src/{draft-nodes → typed-refs}/list-base.ts +79 -30
- package/src/{draft-nodes → typed-refs}/list.test.ts +1 -1
- package/src/{draft-nodes → typed-refs}/list.ts +4 -4
- package/src/{draft-nodes → typed-refs}/map.ts +33 -22
- package/src/{draft-nodes → typed-refs}/movable-list.test.ts +1 -1
- package/src/{draft-nodes → typed-refs}/movable-list.ts +6 -6
- package/src/{draft-nodes → typed-refs}/proxy-handlers.ts +25 -26
- package/src/{draft-nodes → typed-refs}/record.test.ts +69 -0
- package/src/{draft-nodes → typed-refs}/record.ts +50 -21
- package/src/{draft-nodes → typed-refs}/text.ts +13 -3
- package/src/{draft-nodes → typed-refs}/tree.ts +6 -3
- package/src/{draft-nodes → typed-refs}/utils.ts +23 -27
- package/src/types.test.ts +97 -2
- package/src/types.ts +62 -5
- package/src/draft-nodes/counter.md +0 -31
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { LoroDoc } from "loro-crdt"
|
|
2
|
+
import { describe, expect, it, vi } from "vitest"
|
|
3
|
+
import { Shape } from "../shape.js"
|
|
4
|
+
import { createTypedDoc, TypedDoc } from "../typed-doc.js"
|
|
5
|
+
|
|
6
|
+
const MessageSchema = Shape.map({
|
|
7
|
+
id: Shape.plain.string(),
|
|
8
|
+
content: Shape.text(),
|
|
9
|
+
timestamp: Shape.plain.number(),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const ChatSchema = Shape.doc({
|
|
13
|
+
messages: Shape.list(MessageSchema),
|
|
14
|
+
meta: Shape.map({
|
|
15
|
+
title: Shape.plain.string(),
|
|
16
|
+
count: Shape.counter(),
|
|
17
|
+
}),
|
|
18
|
+
tags: Shape.movableList(Shape.plain.string()),
|
|
19
|
+
settings: Shape.record(Shape.plain.boolean()),
|
|
20
|
+
// Tree support might be limited in current implementation
|
|
21
|
+
// tree: Shape.tree(Shape.map({ val: Shape.plain.number() }))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe("JSON Compatibility", () => {
|
|
25
|
+
it("should support JSON.stringify on the whole doc", () => {
|
|
26
|
+
const doc = createTypedDoc(ChatSchema)
|
|
27
|
+
|
|
28
|
+
doc.change((root: any) => {
|
|
29
|
+
root.meta.title = "My Chat"
|
|
30
|
+
root.meta.count.increment(5)
|
|
31
|
+
|
|
32
|
+
root.messages.push({
|
|
33
|
+
id: "1",
|
|
34
|
+
content: "Hello",
|
|
35
|
+
timestamp: 123,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
root.tags.push("work")
|
|
39
|
+
root.tags.push("important")
|
|
40
|
+
|
|
41
|
+
root.settings.set("notifications", true)
|
|
42
|
+
root.settings.set("sound", false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const json = JSON.stringify(doc.value)
|
|
46
|
+
const parsed = JSON.parse(json)
|
|
47
|
+
|
|
48
|
+
expect(parsed).toEqual({
|
|
49
|
+
messages: [
|
|
50
|
+
{
|
|
51
|
+
id: "1",
|
|
52
|
+
content: "Hello",
|
|
53
|
+
timestamp: 123,
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
meta: {
|
|
57
|
+
title: "My Chat",
|
|
58
|
+
count: 5,
|
|
59
|
+
},
|
|
60
|
+
tags: ["work", "important"],
|
|
61
|
+
settings: {
|
|
62
|
+
notifications: true,
|
|
63
|
+
sound: false,
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("should support Object.keys and Object.entries", () => {
|
|
69
|
+
const doc = createTypedDoc(ChatSchema)
|
|
70
|
+
doc.change((root: any) => {
|
|
71
|
+
root.meta.title = "Test"
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const keys = Object.keys(doc.value)
|
|
75
|
+
expect(keys).toContain("messages")
|
|
76
|
+
expect(keys).toContain("meta")
|
|
77
|
+
expect(keys).toContain("tags")
|
|
78
|
+
expect(keys).toContain("settings")
|
|
79
|
+
|
|
80
|
+
const entries = Object.entries(doc.value.meta)
|
|
81
|
+
const entryMap = new Map(entries)
|
|
82
|
+
expect(entryMap.get("title")).toBe("Test")
|
|
83
|
+
expect(entryMap.get("count")).toBeDefined()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("should support JSON.stringify on nested structures", () => {
|
|
87
|
+
const doc = createTypedDoc(ChatSchema)
|
|
88
|
+
doc.change((root: any) => {
|
|
89
|
+
root.messages.push({ id: "1", content: "A", timestamp: 1 })
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const messagesJson = JSON.stringify(doc.value.messages)
|
|
93
|
+
expect(JSON.parse(messagesJson)).toEqual([
|
|
94
|
+
{ id: "1", content: "A", timestamp: 1 },
|
|
95
|
+
])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("should support MovableList", () => {
|
|
99
|
+
const doc = createTypedDoc(ChatSchema)
|
|
100
|
+
doc.change((root: any) => {
|
|
101
|
+
root.tags.push("a")
|
|
102
|
+
root.tags.push("b")
|
|
103
|
+
root.tags.move(0, 1) // move 'a' to index 1
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// After move: ["b", "a"]
|
|
107
|
+
const json = JSON.stringify(doc.value.tags)
|
|
108
|
+
expect(JSON.parse(json)).toEqual(["b", "a"])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("should support Record", () => {
|
|
112
|
+
const doc = createTypedDoc(ChatSchema)
|
|
113
|
+
doc.change((root: any) => {
|
|
114
|
+
root.settings.set("dark_mode", true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const json = JSON.stringify(doc.value.settings)
|
|
118
|
+
expect(JSON.parse(json)).toEqual({ dark_mode: true })
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("should support Readonly vs Mutable consistency", () => {
|
|
122
|
+
const doc = createTypedDoc(ChatSchema)
|
|
123
|
+
let mutableJson = ""
|
|
124
|
+
|
|
125
|
+
doc.change((root: any) => {
|
|
126
|
+
root.meta.title = "Draft"
|
|
127
|
+
mutableJson = JSON.stringify(root)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const readonlyJson = JSON.stringify(doc.value)
|
|
131
|
+
expect(readonlyJson).toBe(mutableJson)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("should handle placeholders", () => {
|
|
135
|
+
const doc = createTypedDoc(ChatSchema)
|
|
136
|
+
// No changes made
|
|
137
|
+
|
|
138
|
+
const json = JSON.stringify(doc.value)
|
|
139
|
+
const parsed = JSON.parse(json)
|
|
140
|
+
|
|
141
|
+
expect(parsed.meta.title).toBe("") // Default string placeholder
|
|
142
|
+
expect(parsed.meta.count).toBe(0) // Default counter placeholder
|
|
143
|
+
expect(parsed.messages).toEqual([])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("should support Array methods on Lists", () => {
|
|
147
|
+
const doc = createTypedDoc(ChatSchema)
|
|
148
|
+
doc.change((root: any) => {
|
|
149
|
+
root.messages.push({ id: "1", content: "A", timestamp: 10 })
|
|
150
|
+
root.messages.push({ id: "2", content: "B", timestamp: 20 })
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const mapped = doc.value.messages.map(m => ({ id: m.id, txt: m.content }))
|
|
154
|
+
expect(JSON.stringify(mapped)).toBe(
|
|
155
|
+
'[{"id":"1","txt":"A"},{"id":"2","txt":"B"}]',
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
const filtered = doc.value.messages.filter(m => m.timestamp > 15)
|
|
159
|
+
expect(filtered).toHaveLength(1)
|
|
160
|
+
expect(filtered[0].id).toBe("2")
|
|
161
|
+
// filtered returns MutableItems (TypedRefs), so JSON.stringify should work on them
|
|
162
|
+
expect(JSON.stringify(filtered)).toBe(
|
|
163
|
+
'[{"id":"2","content":"B","timestamp":20}]',
|
|
164
|
+
)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("should support Object.values", () => {
|
|
168
|
+
const doc = createTypedDoc(ChatSchema)
|
|
169
|
+
doc.change((root: any) => {
|
|
170
|
+
root.settings.set("a", true)
|
|
171
|
+
root.settings.set("b", false)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const values = Object.values(doc.value.settings)
|
|
175
|
+
expect(values).toContain(true)
|
|
176
|
+
expect(values).toContain(false)
|
|
177
|
+
expect(values).toHaveLength(2)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it("should be efficient (not access unrelated parts)", () => {
|
|
181
|
+
const loroDoc = new LoroDoc()
|
|
182
|
+
const doc = new TypedDoc(ChatSchema, loroDoc)
|
|
183
|
+
|
|
184
|
+
doc.change((root: any) => {
|
|
185
|
+
root.messages.push({ id: "1", content: "A", timestamp: 1 })
|
|
186
|
+
root.meta.title = "Test"
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// Spy on LoroDoc methods
|
|
190
|
+
const getMapSpy = vi.spyOn(loroDoc, "getMap")
|
|
191
|
+
const getListSpy = vi.spyOn(loroDoc, "getList")
|
|
192
|
+
|
|
193
|
+
// Access messages and call toJSON
|
|
194
|
+
const messagesJson = doc.value.messages.toJSON()
|
|
195
|
+
|
|
196
|
+
expect(messagesJson).toHaveLength(1)
|
|
197
|
+
|
|
198
|
+
// Should have accessed "messages" list
|
|
199
|
+
expect(getListSpy).toHaveBeenCalledWith("messages")
|
|
200
|
+
|
|
201
|
+
// Should NOT have accessed "meta" map
|
|
202
|
+
expect(getMapSpy).not.toHaveBeenCalledWith("meta")
|
|
203
|
+
|
|
204
|
+
// Should NOT have accessed "settings" map (record)
|
|
205
|
+
expect(getMapSpy).not.toHaveBeenCalledWith("settings")
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it("should allow calling toJSON() directly on refs", () => {
|
|
209
|
+
const doc = createTypedDoc(ChatSchema)
|
|
210
|
+
doc.change((root: any) => {
|
|
211
|
+
root.meta.title = "Direct"
|
|
212
|
+
root.meta.count.increment(10)
|
|
213
|
+
root.messages.push({ id: "1", content: "A", timestamp: 1 })
|
|
214
|
+
root.settings.set("opt", true)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// DocRef
|
|
218
|
+
expect(doc.value.toJSON()).toEqual(
|
|
219
|
+
expect.objectContaining({
|
|
220
|
+
meta: expect.objectContaining({ title: "Direct" }),
|
|
221
|
+
}),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
// MapRef
|
|
225
|
+
expect(doc.value.meta.toJSON()).toEqual({
|
|
226
|
+
title: "Direct",
|
|
227
|
+
count: 10,
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
// ListRef
|
|
231
|
+
expect(doc.value.messages.toJSON()).toEqual([
|
|
232
|
+
{ id: "1", content: "A", timestamp: 1 },
|
|
233
|
+
])
|
|
234
|
+
|
|
235
|
+
// RecordRef
|
|
236
|
+
expect(doc.value.settings.toJSON()).toEqual({
|
|
237
|
+
opt: true,
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
doc.change((root: any) => {
|
|
241
|
+
// Inside change, these are mutable refs
|
|
242
|
+
expect(root.meta.toJSON()).toEqual({ title: "Direct", count: 10 })
|
|
243
|
+
expect(root.messages.toJSON()).toEqual([
|
|
244
|
+
{ id: "1", content: "A", timestamp: 1 },
|
|
245
|
+
])
|
|
246
|
+
|
|
247
|
+
// CounterRef
|
|
248
|
+
expect(root.meta.count.toJSON()).toBe(10)
|
|
249
|
+
|
|
250
|
+
// TextRef (inside message)
|
|
251
|
+
// root.messages[0] is a MapRef. content is TextRef.
|
|
252
|
+
expect(root.messages[0].content.toJSON()).toBe("A")
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
})
|
|
@@ -7,15 +7,15 @@ import type {
|
|
|
7
7
|
MovableListContainerShape,
|
|
8
8
|
} from "../shape.js"
|
|
9
9
|
import { isContainer, isValueShape } from "../utils/type-guards.js"
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { TypedRef, type TypedRefParams } from "./base.js"
|
|
11
|
+
import { createContainerTypedRef } from "./utils.js"
|
|
12
12
|
|
|
13
13
|
// Shared logic for list operations
|
|
14
|
-
export abstract class
|
|
14
|
+
export abstract class ListRefBase<
|
|
15
15
|
NestedShape extends ContainerOrValueShape,
|
|
16
16
|
Item = NestedShape["_plain"],
|
|
17
|
-
|
|
18
|
-
> extends
|
|
17
|
+
MutableItem = NestedShape["_mutable"],
|
|
18
|
+
> extends TypedRef<any> {
|
|
19
19
|
// Cache for items returned by array methods to track mutations
|
|
20
20
|
private itemCache = new Map<number, any>()
|
|
21
21
|
|
|
@@ -40,7 +40,7 @@ export abstract class ListDraftNodeBase<
|
|
|
40
40
|
// For value shapes, delegate to subclass-specific absorption logic
|
|
41
41
|
this.absorbValueAtIndex(index, cachedItem)
|
|
42
42
|
} else {
|
|
43
|
-
// For container shapes, the item should be a
|
|
43
|
+
// For container shapes, the item should be a typed ref that handles its own absorption
|
|
44
44
|
if (
|
|
45
45
|
cachedItem &&
|
|
46
46
|
typeof cachedItem === "object" &&
|
|
@@ -78,10 +78,10 @@ export abstract class ListDraftNodeBase<
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
getTypedRefParams(
|
|
82
82
|
index: number,
|
|
83
83
|
shape: ContainerShape,
|
|
84
|
-
):
|
|
84
|
+
): TypedRefParams<ContainerShape> {
|
|
85
85
|
return {
|
|
86
86
|
shape,
|
|
87
87
|
placeholder: undefined, // List items don't have placeholder
|
|
@@ -142,8 +142,8 @@ export abstract class ListDraftNodeBase<
|
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
// Get item for return values - returns
|
|
146
|
-
protected
|
|
145
|
+
// Get item for return values - returns MutableItem that can be mutated
|
|
146
|
+
protected getMutableItem(index: number): any {
|
|
147
147
|
// Check if we already have a cached item for this index
|
|
148
148
|
let cachedItem = this.itemCache.get(index)
|
|
149
149
|
if (cachedItem) {
|
|
@@ -153,7 +153,7 @@ export abstract class ListDraftNodeBase<
|
|
|
153
153
|
// Get the raw container item
|
|
154
154
|
const containerItem = this.container.get(index)
|
|
155
155
|
if (containerItem === undefined) {
|
|
156
|
-
return undefined as
|
|
156
|
+
return undefined as MutableItem
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
if (isValueShape(this.shape.shape)) {
|
|
@@ -172,11 +172,11 @@ export abstract class ListDraftNodeBase<
|
|
|
172
172
|
if (!this.readonly) {
|
|
173
173
|
this.itemCache.set(index, cachedItem)
|
|
174
174
|
}
|
|
175
|
-
return cachedItem as
|
|
175
|
+
return cachedItem as MutableItem
|
|
176
176
|
} else {
|
|
177
|
-
// For container shapes, create a proper
|
|
178
|
-
cachedItem =
|
|
179
|
-
this.
|
|
177
|
+
// For container shapes, create a proper typed ref using the new pattern
|
|
178
|
+
cachedItem = createContainerTypedRef(
|
|
179
|
+
this.getTypedRefParams(index, this.shape.shape as ContainerShape),
|
|
180
180
|
)
|
|
181
181
|
// Cache container nodes
|
|
182
182
|
this.itemCache.set(index, cachedItem)
|
|
@@ -191,20 +191,20 @@ export abstract class ListDraftNodeBase<
|
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
return cachedItem as
|
|
194
|
+
return cachedItem as MutableItem
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
// Array-like methods for better developer experience
|
|
199
|
-
// DUAL INTERFACE: Predicates get Item (plain data), return values are
|
|
199
|
+
// DUAL INTERFACE: Predicates get Item (plain data), return values are MutableItem (mutable)
|
|
200
200
|
|
|
201
201
|
find(
|
|
202
202
|
predicate: (item: Item, index: number) => boolean,
|
|
203
|
-
):
|
|
203
|
+
): MutableItem | undefined {
|
|
204
204
|
for (let i = 0; i < this.length; i++) {
|
|
205
205
|
const predicateItem = this.getPredicateItem(i)
|
|
206
206
|
if (predicate(predicateItem, i)) {
|
|
207
|
-
return this.
|
|
207
|
+
return this.getMutableItem(i) // Return mutable item
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
210
|
return undefined
|
|
@@ -231,12 +231,12 @@ export abstract class ListDraftNodeBase<
|
|
|
231
231
|
return result
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
filter(predicate: (item: Item, index: number) => boolean):
|
|
235
|
-
const result:
|
|
234
|
+
filter(predicate: (item: Item, index: number) => boolean): MutableItem[] {
|
|
235
|
+
const result: MutableItem[] = []
|
|
236
236
|
for (let i = 0; i < this.length; i++) {
|
|
237
237
|
const predicateItem = this.getPredicateItem(i)
|
|
238
238
|
if (predicate(predicateItem, i)) {
|
|
239
|
-
result.push(this.
|
|
239
|
+
result.push(this.getMutableItem(i)) // Return mutable items
|
|
240
240
|
}
|
|
241
241
|
}
|
|
242
242
|
return result
|
|
@@ -269,41 +269,90 @@ export abstract class ListDraftNodeBase<
|
|
|
269
269
|
return true
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
slice(start?: number, end?: number): MutableItem[] {
|
|
273
|
+
const len = this.length
|
|
274
|
+
|
|
275
|
+
// Normalize start index (following JavaScript Array.prototype.slice semantics)
|
|
276
|
+
const startIndex =
|
|
277
|
+
start === undefined
|
|
278
|
+
? 0
|
|
279
|
+
: start < 0
|
|
280
|
+
? Math.max(len + start, 0)
|
|
281
|
+
: Math.min(start, len)
|
|
282
|
+
|
|
283
|
+
// Normalize end index
|
|
284
|
+
const endIndex =
|
|
285
|
+
end === undefined
|
|
286
|
+
? len
|
|
287
|
+
: end < 0
|
|
288
|
+
? Math.max(len + end, 0)
|
|
289
|
+
: Math.min(end, len)
|
|
290
|
+
|
|
291
|
+
const result: MutableItem[] = []
|
|
292
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
293
|
+
result.push(this.getMutableItem(i))
|
|
294
|
+
}
|
|
295
|
+
return result
|
|
296
|
+
}
|
|
297
|
+
|
|
272
298
|
insert(index: number, item: Item): void {
|
|
273
|
-
if (this.readonly) throw new Error("Cannot modify readonly
|
|
299
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
274
300
|
// Update cache indices before performing the insert operation
|
|
275
301
|
this.updateCacheForInsert(index)
|
|
276
302
|
this.insertWithConversion(index, item)
|
|
277
303
|
}
|
|
278
304
|
|
|
279
305
|
delete(index: number, len: number): void {
|
|
280
|
-
if (this.readonly) throw new Error("Cannot modify readonly
|
|
306
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
281
307
|
// Update cache indices before performing the delete operation
|
|
282
308
|
this.updateCacheForDelete(index, len)
|
|
283
309
|
this.container.delete(index, len)
|
|
284
310
|
}
|
|
285
311
|
|
|
286
312
|
push(item: Item): void {
|
|
287
|
-
if (this.readonly) throw new Error("Cannot modify readonly
|
|
313
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
288
314
|
this.pushWithConversion(item)
|
|
289
315
|
}
|
|
290
316
|
|
|
291
317
|
pushContainer(container: Container): Container {
|
|
292
|
-
if (this.readonly) throw new Error("Cannot modify readonly
|
|
318
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
293
319
|
return this.container.pushContainer(container)
|
|
294
320
|
}
|
|
295
321
|
|
|
296
322
|
insertContainer(index: number, container: Container): Container {
|
|
297
|
-
if (this.readonly) throw new Error("Cannot modify readonly
|
|
323
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
298
324
|
return this.container.insertContainer(index, container)
|
|
299
325
|
}
|
|
300
326
|
|
|
301
|
-
get(index: number):
|
|
302
|
-
return this.
|
|
327
|
+
get(index: number): MutableItem {
|
|
328
|
+
return this.getMutableItem(index)
|
|
303
329
|
}
|
|
304
330
|
|
|
305
331
|
toArray(): Item[] {
|
|
306
|
-
|
|
332
|
+
const result: Item[] = []
|
|
333
|
+
for (let i = 0; i < this.length; i++) {
|
|
334
|
+
result.push(this.getPredicateItem(i))
|
|
335
|
+
}
|
|
336
|
+
return result
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
toJSON(): Item[] {
|
|
340
|
+
return this.toArray()
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
[Symbol.iterator](): IterableIterator<MutableItem> {
|
|
344
|
+
let index = 0
|
|
345
|
+
return {
|
|
346
|
+
next: (): IteratorResult<MutableItem> => {
|
|
347
|
+
if (index < this.length) {
|
|
348
|
+
return { value: this.getMutableItem(index++), done: false }
|
|
349
|
+
}
|
|
350
|
+
return { value: undefined, done: true }
|
|
351
|
+
},
|
|
352
|
+
[Symbol.iterator]() {
|
|
353
|
+
return this
|
|
354
|
+
},
|
|
355
|
+
}
|
|
307
356
|
}
|
|
308
357
|
|
|
309
358
|
get length(): number {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
2
|
import { createTypedDoc, Shape } from "../index.js"
|
|
3
3
|
|
|
4
|
-
describe("
|
|
4
|
+
describe("ListRef", () => {
|
|
5
5
|
describe("set via index", () => {
|
|
6
6
|
it("should allow setting a plain object for a list item via index", () => {
|
|
7
7
|
const schema = Shape.doc({
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { LoroList } from "loro-crdt"
|
|
2
2
|
import type { ContainerOrValueShape } from "../shape.js"
|
|
3
3
|
import type { Infer } from "../types.js"
|
|
4
|
-
import {
|
|
4
|
+
import { ListRefBase } from "./list-base.js"
|
|
5
5
|
|
|
6
|
-
// List
|
|
7
|
-
export class
|
|
6
|
+
// List typed ref
|
|
7
|
+
export class ListRef<
|
|
8
8
|
NestedShape extends ContainerOrValueShape,
|
|
9
|
-
> extends
|
|
9
|
+
> extends ListRefBase<NestedShape> {
|
|
10
10
|
[index: number]: Infer<NestedShape>
|
|
11
11
|
|
|
12
12
|
protected get container(): LoroList {
|
|
@@ -15,11 +15,8 @@ import type {
|
|
|
15
15
|
ValueShape,
|
|
16
16
|
} from "../shape.js"
|
|
17
17
|
import { isContainerShape, isValueShape } from "../utils/type-guards.js"
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
assignPlainValueToDraftNode,
|
|
21
|
-
createContainerDraftNode,
|
|
22
|
-
} from "./utils.js"
|
|
18
|
+
import { TypedRef, type TypedRefParams } from "./base.js"
|
|
19
|
+
import { assignPlainValueToTypedRef, createContainerTypedRef } from "./utils.js"
|
|
23
20
|
|
|
24
21
|
const containerConstructor = {
|
|
25
22
|
counter: LoroCounter,
|
|
@@ -31,13 +28,13 @@ const containerConstructor = {
|
|
|
31
28
|
tree: LoroTree,
|
|
32
29
|
} as const
|
|
33
30
|
|
|
34
|
-
// Map
|
|
35
|
-
export class
|
|
31
|
+
// Map typed ref
|
|
32
|
+
export class MapRef<
|
|
36
33
|
NestedShapes extends Record<string, ContainerOrValueShape>,
|
|
37
|
-
> extends
|
|
38
|
-
private propertyCache = new Map<string,
|
|
34
|
+
> extends TypedRef<any> {
|
|
35
|
+
private propertyCache = new Map<string, TypedRef<ContainerShape> | Value>()
|
|
39
36
|
|
|
40
|
-
constructor(params:
|
|
37
|
+
constructor(params: TypedRefParams<MapContainerShape<NestedShapes>>) {
|
|
41
38
|
super(params)
|
|
42
39
|
this.createLazyProperties()
|
|
43
40
|
}
|
|
@@ -52,8 +49,8 @@ export class MapDraftNode<
|
|
|
52
49
|
|
|
53
50
|
absorbPlainValues() {
|
|
54
51
|
for (const [key, node] of this.propertyCache.entries()) {
|
|
55
|
-
if (node instanceof
|
|
56
|
-
// Contains a
|
|
52
|
+
if (node instanceof TypedRef) {
|
|
53
|
+
// Contains a TypedRef, not a plain Value: keep recursing
|
|
57
54
|
node.absorbPlainValues()
|
|
58
55
|
continue
|
|
59
56
|
}
|
|
@@ -63,10 +60,10 @@ export class MapDraftNode<
|
|
|
63
60
|
}
|
|
64
61
|
}
|
|
65
62
|
|
|
66
|
-
|
|
63
|
+
getTypedRefParams<S extends ContainerShape>(
|
|
67
64
|
key: string,
|
|
68
65
|
shape: S,
|
|
69
|
-
):
|
|
66
|
+
): TypedRefParams<ContainerShape> {
|
|
70
67
|
const placeholder = (this.placeholder as any)?.[key]
|
|
71
68
|
|
|
72
69
|
const LoroContainer = containerConstructor[shape._type]
|
|
@@ -87,7 +84,7 @@ export class MapDraftNode<
|
|
|
87
84
|
let node = this.propertyCache.get(key)
|
|
88
85
|
if (!node) {
|
|
89
86
|
if (isContainerShape(shape)) {
|
|
90
|
-
node =
|
|
87
|
+
node = createContainerTypedRef(this.getTypedRefParams(key, shape))
|
|
91
88
|
// We cache container nodes even in readonly mode because they are just handles
|
|
92
89
|
this.propertyCache.set(key, node)
|
|
93
90
|
} else {
|
|
@@ -129,7 +126,7 @@ export class MapDraftNode<
|
|
|
129
126
|
}
|
|
130
127
|
}
|
|
131
128
|
|
|
132
|
-
return node as Shape extends ContainerShape ?
|
|
129
|
+
return node as Shape extends ContainerShape ? TypedRef<Shape> : Value
|
|
133
130
|
}
|
|
134
131
|
|
|
135
132
|
private createLazyProperties(): void {
|
|
@@ -138,7 +135,7 @@ export class MapDraftNode<
|
|
|
138
135
|
Object.defineProperty(this, key, {
|
|
139
136
|
get: () => this.getOrCreateNode(key, shape),
|
|
140
137
|
set: value => {
|
|
141
|
-
if (this.readonly) throw new Error("Cannot modify readonly
|
|
138
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
142
139
|
if (isValueShape(shape)) {
|
|
143
140
|
this.container.set(key, value)
|
|
144
141
|
this.propertyCache.set(key, value)
|
|
@@ -146,36 +143,50 @@ export class MapDraftNode<
|
|
|
146
143
|
if (value && typeof value === "object") {
|
|
147
144
|
const node = this.getOrCreateNode(key, shape)
|
|
148
145
|
|
|
149
|
-
if (
|
|
146
|
+
if (assignPlainValueToTypedRef(node as TypedRef<any>, value)) {
|
|
150
147
|
return
|
|
151
148
|
}
|
|
152
149
|
}
|
|
153
150
|
throw new Error(
|
|
154
|
-
"Cannot set container directly, modify the
|
|
151
|
+
"Cannot set container directly, modify the typed ref instead",
|
|
155
152
|
)
|
|
156
153
|
}
|
|
157
154
|
},
|
|
155
|
+
enumerable: true,
|
|
158
156
|
})
|
|
159
157
|
}
|
|
160
158
|
}
|
|
161
159
|
|
|
160
|
+
toJSON(): any {
|
|
161
|
+
const result: any = {}
|
|
162
|
+
for (const key in this.shape.shapes) {
|
|
163
|
+
const value = (this as any)[key]
|
|
164
|
+
if (value && typeof value === "object" && "toJSON" in value) {
|
|
165
|
+
result[key] = value.toJSON()
|
|
166
|
+
} else {
|
|
167
|
+
result[key] = value
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return result
|
|
171
|
+
}
|
|
172
|
+
|
|
162
173
|
// TOOD(duane): return correct type here
|
|
163
174
|
get(key: string): any {
|
|
164
175
|
return this.container.get(key)
|
|
165
176
|
}
|
|
166
177
|
|
|
167
178
|
set(key: string, value: Value): void {
|
|
168
|
-
if (this.readonly) throw new Error("Cannot modify readonly
|
|
179
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
169
180
|
this.container.set(key, value)
|
|
170
181
|
}
|
|
171
182
|
|
|
172
183
|
setContainer<C extends Container>(key: string, container: C): C {
|
|
173
|
-
if (this.readonly) throw new Error("Cannot modify readonly
|
|
184
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
174
185
|
return this.container.setContainer(key, container)
|
|
175
186
|
}
|
|
176
187
|
|
|
177
188
|
delete(key: string): void {
|
|
178
|
-
if (this.readonly) throw new Error("Cannot modify readonly
|
|
189
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
179
190
|
this.container.delete(key)
|
|
180
191
|
}
|
|
181
192
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
2
|
import { createTypedDoc, Shape } from "../index.js"
|
|
3
3
|
|
|
4
|
-
describe("
|
|
4
|
+
describe("MovableListRef", () => {
|
|
5
5
|
describe("set via index", () => {
|
|
6
6
|
it("should allow setting a plain object for a list item via index", () => {
|
|
7
7
|
const schema = Shape.doc({
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { Container, LoroMovableList } from "loro-crdt"
|
|
2
2
|
import type { ContainerOrValueShape } from "../shape.js"
|
|
3
3
|
import type { Infer } from "../types.js"
|
|
4
|
-
import {
|
|
4
|
+
import { ListRefBase } from "./list-base.js"
|
|
5
5
|
|
|
6
|
-
// Movable list
|
|
7
|
-
export class
|
|
6
|
+
// Movable list typed ref
|
|
7
|
+
export class MovableListRef<
|
|
8
8
|
NestedShape extends ContainerOrValueShape,
|
|
9
9
|
Item = NestedShape["_plain"],
|
|
10
|
-
> extends
|
|
10
|
+
> extends ListRefBase<NestedShape> {
|
|
11
11
|
[index: number]: Infer<NestedShape>
|
|
12
12
|
|
|
13
13
|
protected get container(): LoroMovableList {
|
|
@@ -20,12 +20,12 @@ export class MovableListDraftNode<
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
move(from: number, to: number): void {
|
|
23
|
-
if (this.readonly) throw new Error("Cannot modify readonly
|
|
23
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
24
24
|
this.container.move(from, to)
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
set(index: number, item: Exclude<Item, Container>) {
|
|
28
|
-
if (this.readonly) throw new Error("Cannot modify readonly
|
|
28
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
29
29
|
return this.container.set(index, item)
|
|
30
30
|
}
|
|
31
31
|
}
|