@loro-extended/change 2.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -1
- package/dist/index.d.ts +89 -14
- package/dist/index.js +480 -156
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/overlay.ts +62 -3
- package/src/shape.ts +69 -8
- package/src/typed-doc.ts +1 -0
- package/src/typed-refs/base.ts +7 -18
- package/src/typed-refs/counter.ts +0 -2
- package/src/typed-refs/doc.ts +3 -21
- package/src/typed-refs/list-base.ts +28 -29
- package/src/typed-refs/list-value-updates.test.ts +213 -0
- package/src/typed-refs/movable-list.ts +0 -2
- package/src/typed-refs/record-value-updates.test.ts +214 -0
- package/src/typed-refs/record.ts +48 -51
- package/src/typed-refs/struct-value-updates.test.ts +200 -0
- package/src/typed-refs/struct.ts +39 -44
- package/src/typed-refs/text.ts +0 -6
- package/src/typed-refs/tree-node-value-updates.test.ts +234 -0
- package/src/typed-refs/tree-node.ts +236 -0
- package/src/typed-refs/tree.test.ts +384 -0
- package/src/typed-refs/tree.ts +252 -24
- package/src/typed-refs/utils.ts +30 -7
- package/src/types.ts +36 -1
- package/src/utils/type-guards.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loro-extended/change",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "A schema-driven, type-safe wrapper for Loro CRDT that provides natural JavaScript syntax for collaborative data mutations",
|
|
5
5
|
"author": "Duane Johnson",
|
|
6
6
|
"license": "MIT",
|
package/src/index.ts
CHANGED
package/src/overlay.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import type { Value } from "loro-crdt"
|
|
1
|
+
import type { TreeID, Value } from "loro-crdt"
|
|
2
2
|
import { deriveShapePlaceholder } from "./derive-placeholder.js"
|
|
3
3
|
import type {
|
|
4
4
|
ContainerShape,
|
|
5
5
|
DiscriminatedUnionValueShape,
|
|
6
6
|
DocShape,
|
|
7
|
+
StructContainerShape,
|
|
8
|
+
TreeContainerShape,
|
|
9
|
+
TreeNodeJSON,
|
|
7
10
|
ValueShape,
|
|
8
11
|
} from "./shape.js"
|
|
9
12
|
import { isObjectValue } from "./utils/type-guards.js"
|
|
@@ -110,8 +113,14 @@ export function mergeValue<Shape extends ContainerShape | ValueShape>(
|
|
|
110
113
|
|
|
111
114
|
return result
|
|
112
115
|
}
|
|
113
|
-
case "tree":
|
|
114
|
-
|
|
116
|
+
case "tree": {
|
|
117
|
+
if (crdtValue === undefined) {
|
|
118
|
+
return placeholderValue ?? []
|
|
119
|
+
}
|
|
120
|
+
// Transform Loro's native tree format to our typed format
|
|
121
|
+
const treeShape = shape as TreeContainerShape
|
|
122
|
+
return transformTreeNodes(crdtValue as any[], treeShape.shape) as any
|
|
123
|
+
}
|
|
115
124
|
case "record": {
|
|
116
125
|
if (!isObjectValue(crdtValue) && crdtValue !== undefined) {
|
|
117
126
|
throw new Error("record crdt must be object")
|
|
@@ -206,3 +215,53 @@ function mergeDiscriminatedUnion(
|
|
|
206
215
|
|
|
207
216
|
return mergeValue(variantShape, crdtValue, effectivePlaceholderValue as Value)
|
|
208
217
|
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Loro's native tree node format from toJSON()
|
|
221
|
+
*/
|
|
222
|
+
interface LoroTreeNodeJSON {
|
|
223
|
+
id: string
|
|
224
|
+
parent: string | null
|
|
225
|
+
index: number
|
|
226
|
+
fractional_index: string
|
|
227
|
+
meta: Record<string, Value>
|
|
228
|
+
children: LoroTreeNodeJSON[]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Transforms Loro's native tree format to our typed TreeNodeJSON format.
|
|
233
|
+
* - Renames `meta` to `data`
|
|
234
|
+
* - Renames `fractional_index` to `fractionalIndex`
|
|
235
|
+
* - Applies placeholder merging to node data
|
|
236
|
+
*/
|
|
237
|
+
function transformTreeNodes<DataShape extends StructContainerShape>(
|
|
238
|
+
nodes: LoroTreeNodeJSON[],
|
|
239
|
+
dataShape: DataShape,
|
|
240
|
+
): TreeNodeJSON<DataShape>[] {
|
|
241
|
+
const dataPlaceholder = deriveShapePlaceholder(dataShape) as Value
|
|
242
|
+
|
|
243
|
+
return nodes.map(node => transformTreeNode(node, dataShape, dataPlaceholder))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Transforms a single tree node and its children recursively.
|
|
248
|
+
*/
|
|
249
|
+
function transformTreeNode<DataShape extends StructContainerShape>(
|
|
250
|
+
node: LoroTreeNodeJSON,
|
|
251
|
+
dataShape: DataShape,
|
|
252
|
+
dataPlaceholder: Value,
|
|
253
|
+
): TreeNodeJSON<DataShape> {
|
|
254
|
+
// Merge the node's meta (data) with the placeholder
|
|
255
|
+
const mergedData = mergeValue(dataShape, node.meta, dataPlaceholder)
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
id: node.id as TreeID,
|
|
259
|
+
parent: node.parent as TreeID | null,
|
|
260
|
+
index: node.index,
|
|
261
|
+
fractionalIndex: node.fractional_index,
|
|
262
|
+
data: mergedData as DataShape["_plain"],
|
|
263
|
+
children: node.children.map(child =>
|
|
264
|
+
transformTreeNode(child, dataShape, dataPlaceholder),
|
|
265
|
+
),
|
|
266
|
+
}
|
|
267
|
+
}
|
package/src/shape.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
LoroMovableList,
|
|
8
8
|
LoroText,
|
|
9
9
|
LoroTree,
|
|
10
|
+
TreeID,
|
|
10
11
|
Value,
|
|
11
12
|
} from "loro-crdt"
|
|
12
13
|
|
|
@@ -17,6 +18,9 @@ import type { RecordRef } from "./typed-refs/record.js"
|
|
|
17
18
|
import type { StructRef } from "./typed-refs/struct.js"
|
|
18
19
|
import type { TextRef } from "./typed-refs/text.js"
|
|
19
20
|
|
|
21
|
+
// Note: TreeRef is not imported here to avoid circular dependency.
|
|
22
|
+
// The TreeContainerShape uses a placeholder type that gets resolved at runtime.
|
|
23
|
+
|
|
20
24
|
export interface Shape<Plain, Mutable, Placeholder = Plain> {
|
|
21
25
|
readonly _type: string
|
|
22
26
|
readonly _plain: Plain
|
|
@@ -59,11 +63,48 @@ export interface CounterContainerShape
|
|
|
59
63
|
extends Shape<number, CounterRef, number> {
|
|
60
64
|
readonly _type: "counter"
|
|
61
65
|
}
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
/**
|
|
67
|
+
* JSON representation of a tree node with typed data.
|
|
68
|
+
* Used for serialization (toJSON) of tree structures.
|
|
69
|
+
*/
|
|
70
|
+
export type TreeNodeJSON<DataShape extends StructContainerShape> = {
|
|
71
|
+
id: TreeID
|
|
72
|
+
parent: TreeID | null
|
|
73
|
+
index: number
|
|
74
|
+
fractionalIndex: string
|
|
75
|
+
data: DataShape["_plain"]
|
|
76
|
+
children: TreeNodeJSON<DataShape>[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Container shape for tree (forest) structures.
|
|
81
|
+
* Each node in the tree has typed metadata stored in a LoroMap.
|
|
82
|
+
*
|
|
83
|
+
* Note: The Mutable type (second generic parameter) is `any` here to avoid
|
|
84
|
+
* circular dependency with TreeRef. The actual type is resolved at runtime
|
|
85
|
+
* and through the InferMutableType helper.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```typescript
|
|
89
|
+
* const StateNodeDataShape = Shape.struct({
|
|
90
|
+
* name: Shape.text(),
|
|
91
|
+
* facts: Shape.record(Shape.plain.any()),
|
|
92
|
+
* })
|
|
93
|
+
*
|
|
94
|
+
* const Schema = Shape.doc({
|
|
95
|
+
* states: Shape.tree(StateNodeDataShape),
|
|
96
|
+
* })
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
export interface TreeContainerShape<
|
|
100
|
+
DataShape extends StructContainerShape = StructContainerShape,
|
|
101
|
+
> extends Shape<TreeNodeJSON<DataShape>[], any, never[]> {
|
|
64
102
|
readonly _type: "tree"
|
|
65
|
-
|
|
66
|
-
|
|
103
|
+
/**
|
|
104
|
+
* The shape of each node's data (metadata).
|
|
105
|
+
* This is a StructContainerShape that defines the typed properties on node.data.
|
|
106
|
+
*/
|
|
107
|
+
readonly shape: DataShape
|
|
67
108
|
}
|
|
68
109
|
|
|
69
110
|
// Container schemas using interfaces for recursive references
|
|
@@ -469,12 +510,32 @@ export const Shape = {
|
|
|
469
510
|
})
|
|
470
511
|
},
|
|
471
512
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
513
|
+
/**
|
|
514
|
+
* Creates a tree container shape for hierarchical data structures.
|
|
515
|
+
* Each node in the tree has typed metadata defined by the data shape.
|
|
516
|
+
*
|
|
517
|
+
* @example
|
|
518
|
+
* ```typescript
|
|
519
|
+
* const StateNodeDataShape = Shape.struct({
|
|
520
|
+
* name: Shape.text(),
|
|
521
|
+
* facts: Shape.record(Shape.plain.any()),
|
|
522
|
+
* })
|
|
523
|
+
*
|
|
524
|
+
* const Schema = Shape.doc({
|
|
525
|
+
* states: Shape.tree(StateNodeDataShape),
|
|
526
|
+
* })
|
|
527
|
+
*
|
|
528
|
+
* doc.$.change(draft => {
|
|
529
|
+
* const root = draft.states.createNode({ name: "idle", facts: {} })
|
|
530
|
+
* const child = root.createNode({ name: "running", facts: {} })
|
|
531
|
+
* child.data.name = "active"
|
|
532
|
+
* })
|
|
533
|
+
* ```
|
|
534
|
+
*/
|
|
535
|
+
tree: <T extends StructContainerShape>(shape: T): TreeContainerShape<T> => ({
|
|
475
536
|
_type: "tree" as const,
|
|
476
537
|
shape,
|
|
477
|
-
_plain:
|
|
538
|
+
_plain: [] as any,
|
|
478
539
|
_mutable: {} as any,
|
|
479
540
|
_placeholder: [] as never[],
|
|
480
541
|
}),
|
package/src/typed-doc.ts
CHANGED
|
@@ -130,6 +130,7 @@ class TypedDocInternal<Shape extends DocShape> {
|
|
|
130
130
|
placeholder: this.placeholder as any,
|
|
131
131
|
doc: this.doc,
|
|
132
132
|
autoCommit: false,
|
|
133
|
+
batchedMutation: true, // Enable value shape caching for find-and-mutate patterns
|
|
133
134
|
})
|
|
134
135
|
fn(draft as unknown as Mutable<Shape>)
|
|
135
136
|
draft.absorbPlainValues()
|
package/src/typed-refs/base.ts
CHANGED
|
@@ -6,9 +6,9 @@ export type TypedRefParams<Shape extends DocShape | ContainerShape> = {
|
|
|
6
6
|
shape: Shape
|
|
7
7
|
placeholder?: Infer<Shape>
|
|
8
8
|
getContainer: () => ShapeToContainer<Shape>
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
getDoc?: () => LoroDoc //
|
|
9
|
+
autoCommit?: boolean // Auto-commit after mutations
|
|
10
|
+
batchedMutation?: boolean // True when inside change() block - enables value shape caching for find-and-mutate patterns
|
|
11
|
+
getDoc?: () => LoroDoc // Needed for auto-commit
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
// Base class for all typed refs
|
|
@@ -33,14 +33,14 @@ export abstract class TypedRef<Shape extends DocShape | ContainerShape> {
|
|
|
33
33
|
return this._params.placeholder
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
protected get readonly(): boolean {
|
|
37
|
-
return !!this._params.readonly
|
|
38
|
-
}
|
|
39
|
-
|
|
40
36
|
protected get autoCommit(): boolean {
|
|
41
37
|
return !!this._params.autoCommit
|
|
42
38
|
}
|
|
43
39
|
|
|
40
|
+
protected get batchedMutation(): boolean {
|
|
41
|
+
return !!this._params.batchedMutation
|
|
42
|
+
}
|
|
43
|
+
|
|
44
44
|
protected get doc(): LoroDoc | undefined {
|
|
45
45
|
return this._params.getDoc?.()
|
|
46
46
|
}
|
|
@@ -55,17 +55,6 @@ export abstract class TypedRef<Shape extends DocShape | ContainerShape> {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/**
|
|
59
|
-
* Throws an error if this ref is in readonly mode.
|
|
60
|
-
* Call this at the start of any mutating method.
|
|
61
|
-
* @deprecated Mutations are always allowed now; this will be removed.
|
|
62
|
-
*/
|
|
63
|
-
protected assertMutable(): void {
|
|
64
|
-
if (this.readonly) {
|
|
65
|
-
throw new Error("Cannot modify readonly ref")
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
58
|
protected get container(): ShapeToContainer<Shape> {
|
|
70
59
|
if (!this._cachedContainer) {
|
|
71
60
|
const container = this._params.getContainer()
|
|
@@ -16,14 +16,12 @@ export class CounterRef extends TypedRef<CounterContainerShape> {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
increment(value: number = 1): void {
|
|
19
|
-
this.assertMutable()
|
|
20
19
|
this._materialized = true
|
|
21
20
|
this.container.increment(value)
|
|
22
21
|
this.commitIfAuto()
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
decrement(value: number = 1): void {
|
|
26
|
-
this.assertMutable()
|
|
27
25
|
this._materialized = true
|
|
28
26
|
this.container.decrement(value)
|
|
29
27
|
this.commitIfAuto()
|
package/src/typed-refs/doc.ts
CHANGED
|
@@ -2,11 +2,7 @@ import type { LoroDoc } from "loro-crdt"
|
|
|
2
2
|
import type { Infer } from "../index.js"
|
|
3
3
|
import type { ContainerShape, DocShape } from "../shape.js"
|
|
4
4
|
import { TypedRef, type TypedRefParams } from "./base.js"
|
|
5
|
-
import {
|
|
6
|
-
createContainerTypedRef,
|
|
7
|
-
serializeRefToJSON,
|
|
8
|
-
unwrapReadonlyPrimitive,
|
|
9
|
-
} from "./utils.js"
|
|
5
|
+
import { createContainerTypedRef, serializeRefToJSON } from "./utils.js"
|
|
10
6
|
|
|
11
7
|
const containerGetter = {
|
|
12
8
|
counter: "getCounter",
|
|
@@ -30,6 +26,7 @@ export class DocRef<Shape extends DocShape> extends TypedRef<Shape> {
|
|
|
30
26
|
_params: Omit<TypedRefParams<Shape>, "getContainer" | "getDoc"> & {
|
|
31
27
|
doc: LoroDoc
|
|
32
28
|
autoCommit?: boolean
|
|
29
|
+
batchedMutation?: boolean
|
|
33
30
|
},
|
|
34
31
|
) {
|
|
35
32
|
super({
|
|
@@ -64,8 +61,8 @@ export class DocRef<Shape extends DocShape> extends TypedRef<Shape> {
|
|
|
64
61
|
shape,
|
|
65
62
|
placeholder: this.requiredPlaceholder[key],
|
|
66
63
|
getContainer: () => getter(key),
|
|
67
|
-
readonly: this.readonly,
|
|
68
64
|
autoCommit: this._params.autoCommit,
|
|
65
|
+
batchedMutation: this.batchedMutation,
|
|
69
66
|
getDoc: () => this._doc,
|
|
70
67
|
}
|
|
71
68
|
}
|
|
@@ -74,17 +71,6 @@ export class DocRef<Shape extends DocShape> extends TypedRef<Shape> {
|
|
|
74
71
|
key: string,
|
|
75
72
|
shape: ContainerShape,
|
|
76
73
|
): TypedRef<ContainerShape> | number | string {
|
|
77
|
-
if (
|
|
78
|
-
this.readonly &&
|
|
79
|
-
(shape._type === "counter" || shape._type === "text")
|
|
80
|
-
) {
|
|
81
|
-
// Check if the container exists in the doc without creating it
|
|
82
|
-
const shallow = this._doc.getShallowValue()
|
|
83
|
-
if (!shallow[key]) {
|
|
84
|
-
return this.requiredPlaceholder[key] as any
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
74
|
let ref = this.propertyCache.get(key)
|
|
89
75
|
|
|
90
76
|
if (!ref) {
|
|
@@ -92,10 +78,6 @@ export class DocRef<Shape extends DocShape> extends TypedRef<Shape> {
|
|
|
92
78
|
this.propertyCache.set(key, ref)
|
|
93
79
|
}
|
|
94
80
|
|
|
95
|
-
if (this.readonly) {
|
|
96
|
-
return unwrapReadonlyPrimitive(ref, shape)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
81
|
return ref
|
|
100
82
|
}
|
|
101
83
|
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
isValueShape,
|
|
15
15
|
} from "../utils/type-guards.js"
|
|
16
16
|
import { TypedRef, type TypedRefParams } from "./base.js"
|
|
17
|
-
import { createContainerTypedRef
|
|
17
|
+
import { createContainerTypedRef } from "./utils.js"
|
|
18
18
|
|
|
19
19
|
// Shared logic for list operations
|
|
20
20
|
export abstract class ListRefBase<
|
|
@@ -98,8 +98,8 @@ export abstract class ListRefBase<
|
|
|
98
98
|
}
|
|
99
99
|
return containerItem
|
|
100
100
|
},
|
|
101
|
-
readonly: this.readonly,
|
|
102
101
|
autoCommit: this._params.autoCommit,
|
|
102
|
+
batchedMutation: this.batchedMutation,
|
|
103
103
|
getDoc: this._params.getDoc,
|
|
104
104
|
}
|
|
105
105
|
}
|
|
@@ -152,12 +152,6 @@ export abstract class ListRefBase<
|
|
|
152
152
|
|
|
153
153
|
// Get item for return values - returns MutableItem that can be mutated
|
|
154
154
|
protected getMutableItem(index: number): any {
|
|
155
|
-
// Check if we already have a cached item for this index
|
|
156
|
-
let cachedItem = this.itemCache.get(index)
|
|
157
|
-
if (cachedItem) {
|
|
158
|
-
return cachedItem
|
|
159
|
-
}
|
|
160
|
-
|
|
161
155
|
// Get the raw container item
|
|
162
156
|
const containerItem = this.container.get(index)
|
|
163
157
|
if (containerItem === undefined) {
|
|
@@ -165,6 +159,24 @@ export abstract class ListRefBase<
|
|
|
165
159
|
}
|
|
166
160
|
|
|
167
161
|
if (isValueShape(this.shape.shape)) {
|
|
162
|
+
// When NOT in batchedMutation mode (direct access outside of change()), ALWAYS read fresh
|
|
163
|
+
// from container (NEVER cache). This ensures we always get the latest value
|
|
164
|
+
// from the CRDT, even when modified by a different ref instance (e.g., drafts from change())
|
|
165
|
+
//
|
|
166
|
+
// When in batchedMutation mode (inside change()), we cache value shapes so that
|
|
167
|
+
// mutations to found/filtered items persist back to the CRDT via absorbPlainValues()
|
|
168
|
+
if (!this.batchedMutation) {
|
|
169
|
+
return containerItem as MutableItem
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// In batched mode (within change()), we need to cache value shapes
|
|
173
|
+
// so that mutations to found/filtered items persist back to the CRDT
|
|
174
|
+
// via absorbPlainValues() at the end of change()
|
|
175
|
+
let cachedItem = this.itemCache.get(index)
|
|
176
|
+
if (cachedItem) {
|
|
177
|
+
return cachedItem
|
|
178
|
+
}
|
|
179
|
+
|
|
168
180
|
// For value shapes, we need to ensure mutations persist
|
|
169
181
|
// The key insight: we must return the SAME object for the same index
|
|
170
182
|
// so that mutations to filtered/found items persist back to the cache
|
|
@@ -176,28 +188,20 @@ export abstract class ListRefBase<
|
|
|
176
188
|
// For primitives, just use the value directly
|
|
177
189
|
cachedItem = containerItem
|
|
178
190
|
}
|
|
179
|
-
|
|
180
|
-
if (!this.readonly) {
|
|
181
|
-
this.itemCache.set(index, cachedItem)
|
|
182
|
-
}
|
|
191
|
+
this.itemCache.set(index, cachedItem)
|
|
183
192
|
return cachedItem as MutableItem
|
|
184
|
-
}
|
|
185
|
-
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Container shapes: safe to cache (handles)
|
|
196
|
+
let cachedItem = this.itemCache.get(index)
|
|
197
|
+
if (!cachedItem) {
|
|
186
198
|
cachedItem = createContainerTypedRef(
|
|
187
199
|
this.getTypedRefParams(index, this.shape.shape as ContainerShape),
|
|
188
200
|
)
|
|
189
|
-
// Cache container refs
|
|
190
201
|
this.itemCache.set(index, cachedItem)
|
|
191
|
-
|
|
192
|
-
if (this.readonly) {
|
|
193
|
-
return unwrapReadonlyPrimitive(
|
|
194
|
-
cachedItem,
|
|
195
|
-
this.shape.shape as ContainerShape,
|
|
196
|
-
)
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
return cachedItem as MutableItem
|
|
200
202
|
}
|
|
203
|
+
|
|
204
|
+
return cachedItem as MutableItem
|
|
201
205
|
}
|
|
202
206
|
|
|
203
207
|
// Array-like methods for better developer experience
|
|
@@ -301,7 +305,6 @@ export abstract class ListRefBase<
|
|
|
301
305
|
}
|
|
302
306
|
|
|
303
307
|
insert(index: number, item: Item): void {
|
|
304
|
-
this.assertMutable()
|
|
305
308
|
// Update cache indices before performing the insert operation
|
|
306
309
|
this.updateCacheForInsert(index)
|
|
307
310
|
this.insertWithConversion(index, item)
|
|
@@ -309,7 +312,6 @@ export abstract class ListRefBase<
|
|
|
309
312
|
}
|
|
310
313
|
|
|
311
314
|
delete(index: number, len: number): void {
|
|
312
|
-
this.assertMutable()
|
|
313
315
|
// Update cache indices before performing the delete operation
|
|
314
316
|
this.updateCacheForDelete(index, len)
|
|
315
317
|
this.container.delete(index, len)
|
|
@@ -317,20 +319,17 @@ export abstract class ListRefBase<
|
|
|
317
319
|
}
|
|
318
320
|
|
|
319
321
|
push(item: Item): void {
|
|
320
|
-
this.assertMutable()
|
|
321
322
|
this.pushWithConversion(item)
|
|
322
323
|
this.commitIfAuto()
|
|
323
324
|
}
|
|
324
325
|
|
|
325
326
|
pushContainer(container: Container): Container {
|
|
326
|
-
this.assertMutable()
|
|
327
327
|
const result = this.container.pushContainer(container)
|
|
328
328
|
this.commitIfAuto()
|
|
329
329
|
return result
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
insertContainer(index: number, container: Container): Container {
|
|
333
|
-
this.assertMutable()
|
|
334
333
|
const result = this.container.insertContainer(index, container)
|
|
335
334
|
this.commitIfAuto()
|
|
336
335
|
return result
|
|
@@ -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
|
+
})
|