@loro-extended/change 0.2.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/LICENSE +21 -0
- package/README.md +565 -0
- package/dist/index.d.ts +339 -0
- package/dist/index.js +1491 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
- package/src/change.test.ts +2006 -0
- package/src/change.ts +105 -0
- package/src/conversion.test.ts +728 -0
- package/src/conversion.ts +220 -0
- package/src/draft-nodes/base.ts +34 -0
- package/src/draft-nodes/counter.ts +21 -0
- package/src/draft-nodes/doc.ts +81 -0
- package/src/draft-nodes/list-base.ts +326 -0
- package/src/draft-nodes/list.ts +18 -0
- package/src/draft-nodes/map.ts +156 -0
- package/src/draft-nodes/movable-list.ts +26 -0
- package/src/draft-nodes/record.ts +215 -0
- package/src/draft-nodes/text.ts +48 -0
- package/src/draft-nodes/tree.ts +31 -0
- package/src/draft-nodes/utils.ts +55 -0
- package/src/index.ts +33 -0
- package/src/json-patch.test.ts +697 -0
- package/src/json-patch.ts +391 -0
- package/src/overlay.ts +90 -0
- package/src/record.test.ts +188 -0
- package/src/schema.fixtures.ts +138 -0
- package/src/shape.ts +348 -0
- package/src/types.ts +15 -0
- package/src/utils/type-guards.ts +210 -0
- package/src/validation.ts +261 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Container,
|
|
3
|
+
LoroCounter,
|
|
4
|
+
LoroList,
|
|
5
|
+
LoroMap,
|
|
6
|
+
LoroMovableList,
|
|
7
|
+
LoroText,
|
|
8
|
+
type Value,
|
|
9
|
+
} from "loro-crdt"
|
|
10
|
+
import type {
|
|
11
|
+
ArrayValueShape,
|
|
12
|
+
ContainerOrValueShape,
|
|
13
|
+
ListContainerShape,
|
|
14
|
+
MapContainerShape,
|
|
15
|
+
MovableListContainerShape,
|
|
16
|
+
ObjectValueShape,
|
|
17
|
+
RecordContainerShape,
|
|
18
|
+
RecordValueShape,
|
|
19
|
+
} from "./shape.js"
|
|
20
|
+
import {
|
|
21
|
+
isContainer,
|
|
22
|
+
isContainerShape,
|
|
23
|
+
isObjectValue,
|
|
24
|
+
isValueShape,
|
|
25
|
+
} from "./utils/type-guards.js"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Converts string input to LoroText container
|
|
29
|
+
*/
|
|
30
|
+
function convertTextInput(value: string): LoroText {
|
|
31
|
+
const text = new LoroText()
|
|
32
|
+
|
|
33
|
+
// TODO(duane): condition can be removed when https://github.com/loro-dev/loro/issues/872 is addressed
|
|
34
|
+
if (value.length > 0) {
|
|
35
|
+
text.insert(0, value)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return text
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Converts number input to LoroCounter container
|
|
43
|
+
*/
|
|
44
|
+
function convertCounterInput(value: number): LoroCounter {
|
|
45
|
+
const counter = new LoroCounter()
|
|
46
|
+
counter.increment(value)
|
|
47
|
+
return counter
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Converts array input to LoroList container
|
|
52
|
+
*/
|
|
53
|
+
function convertListInput(
|
|
54
|
+
value: Value[],
|
|
55
|
+
shape: ListContainerShape | ArrayValueShape,
|
|
56
|
+
// parentPath: string[],
|
|
57
|
+
): LoroList | Value[] {
|
|
58
|
+
if (!isContainerShape(shape)) {
|
|
59
|
+
return value
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const list = new LoroList()
|
|
63
|
+
|
|
64
|
+
for (const item of value) {
|
|
65
|
+
const convertedItem = convertInputToNode(item, shape.shape)
|
|
66
|
+
if (isContainer(convertedItem)) {
|
|
67
|
+
list.pushContainer(convertedItem)
|
|
68
|
+
} else {
|
|
69
|
+
list.push(convertedItem)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return list
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Converts array input to LoroMovableList container
|
|
78
|
+
*/
|
|
79
|
+
function convertMovableListInput(
|
|
80
|
+
value: Value[],
|
|
81
|
+
shape: MovableListContainerShape | ArrayValueShape,
|
|
82
|
+
// parentPath: string[],
|
|
83
|
+
): LoroMovableList | Value[] {
|
|
84
|
+
if (!isContainerShape(shape)) {
|
|
85
|
+
return value
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const list = new LoroMovableList()
|
|
89
|
+
|
|
90
|
+
for (const item of value) {
|
|
91
|
+
const convertedItem = convertInputToNode(item, shape.shape)
|
|
92
|
+
if (isContainer(convertedItem)) {
|
|
93
|
+
list.pushContainer(convertedItem)
|
|
94
|
+
} else {
|
|
95
|
+
list.push(convertedItem)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return list
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Converts object input to LoroMap container
|
|
104
|
+
*/
|
|
105
|
+
function convertMapInput(
|
|
106
|
+
value: { [key: string]: Value },
|
|
107
|
+
shape: MapContainerShape | ObjectValueShape,
|
|
108
|
+
): LoroMap | { [key: string]: Value } {
|
|
109
|
+
if (!isContainerShape(shape)) {
|
|
110
|
+
return value
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const map = new LoroMap()
|
|
114
|
+
for (const [k, v] of Object.entries(value)) {
|
|
115
|
+
const nestedSchema = shape.shapes[k]
|
|
116
|
+
if (nestedSchema) {
|
|
117
|
+
const convertedValue = convertInputToNode(v, nestedSchema)
|
|
118
|
+
if (isContainer(convertedValue)) {
|
|
119
|
+
map.setContainer(k, convertedValue)
|
|
120
|
+
} else {
|
|
121
|
+
map.set(k, convertedValue)
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
map.set(k, value)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return map
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Converts object input to LoroMap container (Record)
|
|
133
|
+
*/
|
|
134
|
+
function convertRecordInput(
|
|
135
|
+
value: { [key: string]: Value },
|
|
136
|
+
shape: RecordContainerShape | RecordValueShape,
|
|
137
|
+
): LoroMap | { [key: string]: Value } {
|
|
138
|
+
if (!isContainerShape(shape)) {
|
|
139
|
+
return value
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const map = new LoroMap()
|
|
143
|
+
for (const [k, v] of Object.entries(value)) {
|
|
144
|
+
const convertedValue = convertInputToNode(v, shape.shape)
|
|
145
|
+
if (isContainer(convertedValue)) {
|
|
146
|
+
map.setContainer(k, convertedValue)
|
|
147
|
+
} else {
|
|
148
|
+
map.set(k, convertedValue)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return map
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Main conversion function that transforms input values to appropriate CRDT containers
|
|
157
|
+
* based on schema definitions
|
|
158
|
+
*/
|
|
159
|
+
export function convertInputToNode<Shape extends ContainerOrValueShape>(
|
|
160
|
+
value: Value,
|
|
161
|
+
shape: Shape,
|
|
162
|
+
): Container | Value {
|
|
163
|
+
switch (shape._type) {
|
|
164
|
+
case "text": {
|
|
165
|
+
if (typeof value !== "string") {
|
|
166
|
+
throw new Error("string expected")
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return convertTextInput(value)
|
|
170
|
+
}
|
|
171
|
+
case "counter": {
|
|
172
|
+
if (typeof value !== "number") {
|
|
173
|
+
throw new Error("number expected")
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return convertCounterInput(value)
|
|
177
|
+
}
|
|
178
|
+
case "list": {
|
|
179
|
+
if (!Array.isArray(value)) {
|
|
180
|
+
throw new Error("array expected")
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return convertListInput(value, shape)
|
|
184
|
+
}
|
|
185
|
+
case "movableList": {
|
|
186
|
+
if (!Array.isArray(value)) {
|
|
187
|
+
throw new Error("array expected")
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return convertMovableListInput(value, shape)
|
|
191
|
+
}
|
|
192
|
+
case "map": {
|
|
193
|
+
if (!isObjectValue(value)) {
|
|
194
|
+
throw new Error("object expected")
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return convertMapInput(value, shape)
|
|
198
|
+
}
|
|
199
|
+
case "record": {
|
|
200
|
+
if (!isObjectValue(value)) {
|
|
201
|
+
throw new Error("object expected")
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return convertRecordInput(value, shape)
|
|
205
|
+
}
|
|
206
|
+
case "value": {
|
|
207
|
+
if (!isValueShape(shape)) {
|
|
208
|
+
throw new Error("value expected")
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return value
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
case "tree":
|
|
215
|
+
throw new Error("tree type unimplemented")
|
|
216
|
+
|
|
217
|
+
default:
|
|
218
|
+
throw new Error(`unexpected type: ${(shape as Shape)._type}`)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ContainerShape, DocShape, ShapeToContainer } from "../shape.js"
|
|
2
|
+
import type { InferPlainType } from "../types.js"
|
|
3
|
+
|
|
4
|
+
export type DraftNodeParams<Shape extends DocShape | ContainerShape> = {
|
|
5
|
+
shape: Shape
|
|
6
|
+
emptyState?: InferPlainType<Shape>
|
|
7
|
+
getContainer: () => ShapeToContainer<Shape>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Base class for all draft nodes
|
|
11
|
+
export abstract class DraftNode<Shape extends DocShape | ContainerShape> {
|
|
12
|
+
protected _cachedContainer?: ShapeToContainer<Shape>
|
|
13
|
+
|
|
14
|
+
constructor(protected _params: DraftNodeParams<Shape>) {}
|
|
15
|
+
|
|
16
|
+
abstract absorbPlainValues(): void
|
|
17
|
+
|
|
18
|
+
protected get shape(): Shape {
|
|
19
|
+
return this._params.shape
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
protected get emptyState(): InferPlainType<Shape> | undefined {
|
|
23
|
+
return this._params.emptyState
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
protected get container(): ShapeToContainer<Shape> {
|
|
27
|
+
if (!this._cachedContainer) {
|
|
28
|
+
const container = this._params.getContainer()
|
|
29
|
+
this._cachedContainer = container
|
|
30
|
+
return container
|
|
31
|
+
}
|
|
32
|
+
return this._cachedContainer
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CounterContainerShape } from "../shape.js"
|
|
2
|
+
import { DraftNode } from "./base.js"
|
|
3
|
+
|
|
4
|
+
// Counter draft node
|
|
5
|
+
export class CounterDraftNode extends DraftNode<CounterContainerShape> {
|
|
6
|
+
absorbPlainValues() {
|
|
7
|
+
// no plain values contained within
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
increment(value: number): void {
|
|
11
|
+
this.container.increment(value)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
decrement(value: number): void {
|
|
15
|
+
this.container.decrement(value)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get value(): number {
|
|
19
|
+
return this.container.value
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { LoroDoc } from "loro-crdt"
|
|
2
|
+
import type { InferPlainType } from "../index.js"
|
|
3
|
+
import type { ContainerShape, DocShape } from "../shape.js"
|
|
4
|
+
import { DraftNode, type DraftNodeParams } from "./base.js"
|
|
5
|
+
import { createContainerDraftNode } from "./utils.js"
|
|
6
|
+
|
|
7
|
+
const containerGetter = {
|
|
8
|
+
counter: "getCounter",
|
|
9
|
+
list: "getList",
|
|
10
|
+
map: "getMap",
|
|
11
|
+
movableList: "getMovableList",
|
|
12
|
+
record: "getMap",
|
|
13
|
+
text: "getText",
|
|
14
|
+
tree: "getTree",
|
|
15
|
+
} as const
|
|
16
|
+
|
|
17
|
+
// Draft Document class -- the actual object passed to the change `mutation` function
|
|
18
|
+
export class DraftDoc<Shape extends DocShape> extends DraftNode<Shape> {
|
|
19
|
+
private doc: LoroDoc
|
|
20
|
+
private propertyCache = new Map<string, DraftNode<ContainerShape>>()
|
|
21
|
+
private requiredEmptyState!: InferPlainType<Shape>
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
_params: Omit<DraftNodeParams<Shape>, "getContainer"> & { doc: LoroDoc },
|
|
25
|
+
) {
|
|
26
|
+
super({
|
|
27
|
+
..._params,
|
|
28
|
+
getContainer: () => {
|
|
29
|
+
throw new Error("can't get container on DraftDoc")
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
if (!_params.emptyState) throw new Error("emptyState required")
|
|
33
|
+
this.doc = _params.doc
|
|
34
|
+
this.requiredEmptyState = _params.emptyState
|
|
35
|
+
this.createLazyProperties()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getDraftNodeParams<S extends ContainerShape>(
|
|
39
|
+
key: string,
|
|
40
|
+
shape: S,
|
|
41
|
+
): DraftNodeParams<ContainerShape> {
|
|
42
|
+
const getter = this.doc[containerGetter[shape._type]].bind(this.doc)
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
shape,
|
|
46
|
+
emptyState: this.requiredEmptyState[key],
|
|
47
|
+
getContainer: () => getter(key),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getOrCreateDraftNode(
|
|
52
|
+
key: string,
|
|
53
|
+
shape: ContainerShape,
|
|
54
|
+
): DraftNode<ContainerShape> {
|
|
55
|
+
let node = this.propertyCache.get(key)
|
|
56
|
+
|
|
57
|
+
if (!node) {
|
|
58
|
+
node = createContainerDraftNode(this.getDraftNodeParams(key, shape))
|
|
59
|
+
this.propertyCache.set(key, node)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return node
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private createLazyProperties(): void {
|
|
66
|
+
for (const key in this.shape.shapes) {
|
|
67
|
+
const shape = this.shape.shapes[key]
|
|
68
|
+
Object.defineProperty(this, key, {
|
|
69
|
+
get: () => this.getOrCreateDraftNode(key, shape),
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
absorbPlainValues(): void {
|
|
75
|
+
// By iterating over the propertyCache, we achieve a small optimization
|
|
76
|
+
// by only absorbing values that have been 'touched' in some way
|
|
77
|
+
for (const [, node] of this.propertyCache.entries()) {
|
|
78
|
+
node.absorbPlainValues()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import type { Container, LoroList, LoroMovableList } from "loro-crdt"
|
|
2
|
+
import { convertInputToNode } from "../conversion.js"
|
|
3
|
+
import type {
|
|
4
|
+
ContainerOrValueShape,
|
|
5
|
+
ContainerShape,
|
|
6
|
+
ListContainerShape,
|
|
7
|
+
MovableListContainerShape,
|
|
8
|
+
} from "../shape.js"
|
|
9
|
+
import { isContainer, isValueShape } from "../utils/type-guards.js"
|
|
10
|
+
import { DraftNode, type DraftNodeParams } from "./base.js"
|
|
11
|
+
import { createContainerDraftNode } from "./utils.js"
|
|
12
|
+
|
|
13
|
+
// Shared logic for list operations
|
|
14
|
+
export abstract class ListDraftNodeBase<
|
|
15
|
+
NestedShape extends ContainerOrValueShape,
|
|
16
|
+
Item = NestedShape["_plain"],
|
|
17
|
+
DraftItem = NestedShape["_draft"],
|
|
18
|
+
> extends DraftNode<any> {
|
|
19
|
+
// Cache for items returned by array methods to track mutations
|
|
20
|
+
private itemCache = new Map<number, any>()
|
|
21
|
+
|
|
22
|
+
protected get container(): LoroList | LoroMovableList {
|
|
23
|
+
return super.container as LoroList | LoroMovableList
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
protected get shape():
|
|
27
|
+
| ListContainerShape<NestedShape>
|
|
28
|
+
| MovableListContainerShape<NestedShape> {
|
|
29
|
+
return super.shape as
|
|
30
|
+
| ListContainerShape<NestedShape>
|
|
31
|
+
| MovableListContainerShape<NestedShape>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
absorbPlainValues() {
|
|
35
|
+
// Critical function: absorb mutated plain values back into Loro containers
|
|
36
|
+
// This is called at the end of change() to persist mutations made to plain objects
|
|
37
|
+
for (const [index, cachedItem] of this.itemCache.entries()) {
|
|
38
|
+
if (cachedItem) {
|
|
39
|
+
if (isValueShape(this.shape.shape)) {
|
|
40
|
+
// For value shapes, delegate to subclass-specific absorption logic
|
|
41
|
+
this.absorbValueAtIndex(index, cachedItem)
|
|
42
|
+
} else {
|
|
43
|
+
// For container shapes, the item should be a draft node that handles its own absorption
|
|
44
|
+
if (
|
|
45
|
+
cachedItem &&
|
|
46
|
+
typeof cachedItem === "object" &&
|
|
47
|
+
"absorbPlainValues" in cachedItem
|
|
48
|
+
) {
|
|
49
|
+
;(cachedItem as any).absorbPlainValues()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Clear the cache after absorbing values
|
|
56
|
+
this.itemCache.clear()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Abstract method to be implemented by subclasses
|
|
60
|
+
// Each subclass knows how to handle its specific container type
|
|
61
|
+
protected abstract absorbValueAtIndex(index: number, value: any): void
|
|
62
|
+
|
|
63
|
+
protected insertWithConversion(index: number, item: Item): void {
|
|
64
|
+
const convertedItem = convertInputToNode(item as any, this.shape.shape)
|
|
65
|
+
if (isContainer(convertedItem)) {
|
|
66
|
+
this.container.insertContainer(index, convertedItem)
|
|
67
|
+
} else {
|
|
68
|
+
this.container.insert(index, convertedItem)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
protected pushWithConversion(item: Item): void {
|
|
73
|
+
const convertedItem = convertInputToNode(item as any, this.shape.shape)
|
|
74
|
+
if (isContainer(convertedItem)) {
|
|
75
|
+
this.container.pushContainer(convertedItem)
|
|
76
|
+
} else {
|
|
77
|
+
this.container.push(convertedItem)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
getDraftNodeParams(
|
|
82
|
+
index: number,
|
|
83
|
+
shape: ContainerShape,
|
|
84
|
+
): DraftNodeParams<ContainerShape> {
|
|
85
|
+
return {
|
|
86
|
+
shape,
|
|
87
|
+
emptyState: undefined, // List items don't have empty state
|
|
88
|
+
getContainer: () => {
|
|
89
|
+
const containerItem = this.container.get(index)
|
|
90
|
+
if (!containerItem || !isContainer(containerItem)) {
|
|
91
|
+
throw new Error(`No container found at index ${index}`)
|
|
92
|
+
}
|
|
93
|
+
return containerItem
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Get item for predicate functions - always returns plain Item for filtering logic
|
|
99
|
+
protected getPredicateItem(index: number): Item {
|
|
100
|
+
// CRITICAL FIX: For predicates to work correctly with mutations,
|
|
101
|
+
// we need to check if there's a cached (mutated) version first
|
|
102
|
+
const cachedItem = this.itemCache.get(index)
|
|
103
|
+
if (cachedItem && isValueShape(this.shape.shape)) {
|
|
104
|
+
// For value shapes, if we have a cached item, use it so predicates see mutations
|
|
105
|
+
return cachedItem as Item
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const containerItem = this.container.get(index)
|
|
109
|
+
if (containerItem === undefined) {
|
|
110
|
+
return undefined as Item
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (isValueShape(this.shape.shape)) {
|
|
114
|
+
// For value shapes, return the plain value directly
|
|
115
|
+
return containerItem as Item
|
|
116
|
+
} else {
|
|
117
|
+
// For container shapes, we need to return the plain object representation
|
|
118
|
+
// This allows predicates to access nested properties like article.metadata.author
|
|
119
|
+
if (isContainer(containerItem)) {
|
|
120
|
+
// Convert container to plain object for predicate logic
|
|
121
|
+
// Handle different container types that may not have toJSON method
|
|
122
|
+
if (
|
|
123
|
+
typeof containerItem === "object" &&
|
|
124
|
+
containerItem !== null &&
|
|
125
|
+
"toJSON" in containerItem
|
|
126
|
+
) {
|
|
127
|
+
return (containerItem as any).toJSON() as Item
|
|
128
|
+
} else if (
|
|
129
|
+
typeof containerItem === "object" &&
|
|
130
|
+
containerItem !== null &&
|
|
131
|
+
"getShallowValue" in containerItem
|
|
132
|
+
) {
|
|
133
|
+
// For containers like LoroCounter that don't have toJSON but have getShallowValue
|
|
134
|
+
return (containerItem as any).getShallowValue() as Item
|
|
135
|
+
} else {
|
|
136
|
+
// Fallback for other container types
|
|
137
|
+
return containerItem as Item
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return containerItem as Item
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Get item for return values - returns DraftItem that can be mutated
|
|
145
|
+
protected getDraftItem(index: number): DraftItem {
|
|
146
|
+
// Check if we already have a cached item for this index
|
|
147
|
+
let cachedItem = this.itemCache.get(index)
|
|
148
|
+
if (cachedItem) {
|
|
149
|
+
return cachedItem
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Get the raw container item
|
|
153
|
+
const containerItem = this.container.get(index)
|
|
154
|
+
if (containerItem === undefined) {
|
|
155
|
+
return undefined as DraftItem
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (isValueShape(this.shape.shape)) {
|
|
159
|
+
// For value shapes, we need to ensure mutations persist
|
|
160
|
+
// The key insight: we must return the SAME object for the same index
|
|
161
|
+
// so that mutations to filtered/found items persist back to the cache
|
|
162
|
+
if (typeof containerItem === "object" && containerItem !== null) {
|
|
163
|
+
// Create a deep copy for objects so mutations can be tracked
|
|
164
|
+
// IMPORTANT: Only create the copy once, then always return the same cached object
|
|
165
|
+
cachedItem = JSON.parse(JSON.stringify(containerItem))
|
|
166
|
+
} else {
|
|
167
|
+
// For primitives, just use the value directly
|
|
168
|
+
cachedItem = containerItem
|
|
169
|
+
}
|
|
170
|
+
this.itemCache.set(index, cachedItem)
|
|
171
|
+
return cachedItem as DraftItem
|
|
172
|
+
} else {
|
|
173
|
+
// For container shapes, create a proper draft node using the new pattern
|
|
174
|
+
cachedItem = createContainerDraftNode(
|
|
175
|
+
this.getDraftNodeParams(index, this.shape.shape as ContainerShape),
|
|
176
|
+
)
|
|
177
|
+
this.itemCache.set(index, cachedItem)
|
|
178
|
+
return cachedItem as DraftItem
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Array-like methods for better developer experience
|
|
183
|
+
// DUAL INTERFACE: Predicates get Item (plain data), return values are DraftItem (mutable)
|
|
184
|
+
|
|
185
|
+
find(
|
|
186
|
+
predicate: (item: Item, index: number) => boolean,
|
|
187
|
+
): DraftItem | undefined {
|
|
188
|
+
for (let i = 0; i < this.length; i++) {
|
|
189
|
+
const predicateItem = this.getPredicateItem(i)
|
|
190
|
+
if (predicate(predicateItem, i)) {
|
|
191
|
+
return this.getDraftItem(i) // Return mutable draft item
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return undefined
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
findIndex(predicate: (item: Item, index: number) => boolean): number {
|
|
198
|
+
for (let i = 0; i < this.length; i++) {
|
|
199
|
+
const predicateItem = this.getPredicateItem(i)
|
|
200
|
+
if (predicate(predicateItem, i)) {
|
|
201
|
+
return i
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return -1
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
map<ReturnType>(
|
|
208
|
+
callback: (item: Item, index: number) => ReturnType,
|
|
209
|
+
): ReturnType[] {
|
|
210
|
+
const result: ReturnType[] = []
|
|
211
|
+
for (let i = 0; i < this.length; i++) {
|
|
212
|
+
const predicateItem = this.getPredicateItem(i)
|
|
213
|
+
result.push(callback(predicateItem, i))
|
|
214
|
+
}
|
|
215
|
+
return result
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
filter(predicate: (item: Item, index: number) => boolean): DraftItem[] {
|
|
219
|
+
const result: DraftItem[] = []
|
|
220
|
+
for (let i = 0; i < this.length; i++) {
|
|
221
|
+
const predicateItem = this.getPredicateItem(i)
|
|
222
|
+
if (predicate(predicateItem, i)) {
|
|
223
|
+
result.push(this.getDraftItem(i)) // Return mutable draft items
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return result
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
forEach(callback: (item: Item, index: number) => void): void {
|
|
230
|
+
for (let i = 0; i < this.length; i++) {
|
|
231
|
+
const predicateItem = this.getPredicateItem(i)
|
|
232
|
+
callback(predicateItem, i)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
some(predicate: (item: Item, index: number) => boolean): boolean {
|
|
237
|
+
for (let i = 0; i < this.length; i++) {
|
|
238
|
+
const predicateItem = this.getPredicateItem(i)
|
|
239
|
+
if (predicate(predicateItem, i)) {
|
|
240
|
+
return true
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return false
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
every(predicate: (item: Item, index: number) => boolean): boolean {
|
|
247
|
+
for (let i = 0; i < this.length; i++) {
|
|
248
|
+
const predicateItem = this.getPredicateItem(i)
|
|
249
|
+
if (!predicate(predicateItem, i)) {
|
|
250
|
+
return false
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return true
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
insert(index: number, item: Item): void {
|
|
257
|
+
// Update cache indices before performing the insert operation
|
|
258
|
+
this.updateCacheForInsert(index)
|
|
259
|
+
this.insertWithConversion(index, item)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
delete(index: number, len: number): void {
|
|
263
|
+
// Update cache indices before performing the delete operation
|
|
264
|
+
this.updateCacheForDelete(index, len)
|
|
265
|
+
this.container.delete(index, len)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
push(item: Item): void {
|
|
269
|
+
this.pushWithConversion(item)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
pushContainer(container: Container): Container {
|
|
273
|
+
return this.container.pushContainer(container)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
insertContainer(index: number, container: Container): Container {
|
|
277
|
+
return this.container.insertContainer(index, container)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
get(index: number): DraftItem {
|
|
281
|
+
return this.getDraftItem(index)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
toArray(): Item[] {
|
|
285
|
+
return this.container.toArray() as Item[]
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
get length(): number {
|
|
289
|
+
return this.container.length
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Update cache indices when items are deleted
|
|
293
|
+
private updateCacheForDelete(deleteIndex: number, deleteLen: number): void {
|
|
294
|
+
const newCache = new Map<number, any>()
|
|
295
|
+
|
|
296
|
+
for (const [cachedIndex, cachedItem] of this.itemCache.entries()) {
|
|
297
|
+
if (cachedIndex < deleteIndex) {
|
|
298
|
+
// Items before the deletion point keep their indices
|
|
299
|
+
newCache.set(cachedIndex, cachedItem)
|
|
300
|
+
} else if (cachedIndex >= deleteIndex + deleteLen) {
|
|
301
|
+
// Items after the deletion range shift down by deleteLen
|
|
302
|
+
newCache.set(cachedIndex - deleteLen, cachedItem)
|
|
303
|
+
}
|
|
304
|
+
// Items within the deletion range are removed from cache
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
this.itemCache = newCache
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Update cache indices when items are inserted
|
|
311
|
+
private updateCacheForInsert(insertIndex: number): void {
|
|
312
|
+
const newCache = new Map<number, any>()
|
|
313
|
+
|
|
314
|
+
for (const [cachedIndex, cachedItem] of this.itemCache.entries()) {
|
|
315
|
+
if (cachedIndex < insertIndex) {
|
|
316
|
+
// Items before the insertion point keep their indices
|
|
317
|
+
newCache.set(cachedIndex, cachedItem)
|
|
318
|
+
} else {
|
|
319
|
+
// Items at or after the insertion point shift up by 1
|
|
320
|
+
newCache.set(cachedIndex + 1, cachedItem)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
this.itemCache = newCache
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LoroList } from "loro-crdt"
|
|
2
|
+
import type { ContainerOrValueShape } from "../shape.js"
|
|
3
|
+
import { ListDraftNodeBase } from "./list-base.js"
|
|
4
|
+
|
|
5
|
+
// List draft node
|
|
6
|
+
export class ListDraftNode<
|
|
7
|
+
NestedShape extends ContainerOrValueShape,
|
|
8
|
+
> extends ListDraftNodeBase<NestedShape> {
|
|
9
|
+
protected get container(): LoroList {
|
|
10
|
+
return super.container as LoroList
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
protected absorbValueAtIndex(index: number, value: any): void {
|
|
14
|
+
// LoroList doesn't have set method, need to delete and insert
|
|
15
|
+
this.container.delete(index, 1)
|
|
16
|
+
this.container.insert(index, value)
|
|
17
|
+
}
|
|
18
|
+
}
|