@loro-extended/change 0.7.0 → 0.8.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 +26 -31
- package/dist/index.d.ts +86 -54
- package/dist/index.js +723 -400
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/change.test.ts +97 -403
- package/src/derive-placeholder.test.ts +245 -0
- package/src/derive-placeholder.ts +132 -0
- package/src/discriminated-union.test.ts +5 -9
- package/src/draft-nodes/base.ts +9 -4
- package/src/draft-nodes/counter.md +31 -0
- package/src/draft-nodes/counter.test.ts +53 -0
- package/src/draft-nodes/doc.ts +27 -6
- package/src/draft-nodes/list-base.ts +24 -3
- package/src/draft-nodes/list.test.ts +43 -0
- package/src/draft-nodes/list.ts +3 -0
- package/src/draft-nodes/map.ts +57 -16
- package/src/draft-nodes/movable-list.test.ts +27 -0
- package/src/draft-nodes/movable-list.ts +5 -0
- package/src/draft-nodes/proxy-handlers.ts +87 -0
- package/src/{record.test.ts → draft-nodes/record.test.ts} +98 -18
- package/src/draft-nodes/record.ts +42 -80
- package/src/draft-nodes/utils.ts +46 -5
- package/src/equality.test.ts +19 -0
- package/src/index.ts +12 -6
- package/src/json-patch.test.ts +33 -167
- package/src/overlay.ts +45 -44
- package/src/readonly.test.ts +92 -0
- package/src/shape.ts +132 -77
- package/src/{change.ts → typed-doc.ts} +50 -28
- package/src/types.test.ts +26 -7
- package/src/types.ts +7 -7
- package/src/validation.ts +12 -12
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { derivePlaceholder } from "./derive-placeholder.js"
|
|
3
|
+
import { Shape } from "./shape.js"
|
|
4
|
+
|
|
5
|
+
describe("derivePlaceholder", () => {
|
|
6
|
+
it("uses default values when no placeholder set", () => {
|
|
7
|
+
const schema = Shape.doc({
|
|
8
|
+
title: Shape.text(),
|
|
9
|
+
count: Shape.counter(),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
expect(derivePlaceholder(schema)).toEqual({
|
|
13
|
+
title: "",
|
|
14
|
+
count: 0,
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("uses placeholder values when set", () => {
|
|
19
|
+
const schema = Shape.doc({
|
|
20
|
+
title: Shape.text().placeholder("Untitled"),
|
|
21
|
+
count: Shape.counter().placeholder(100),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
expect(derivePlaceholder(schema)).toEqual({
|
|
25
|
+
title: "Untitled",
|
|
26
|
+
count: 100,
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("composes nested map placeholders", () => {
|
|
31
|
+
const schema = Shape.doc({
|
|
32
|
+
settings: Shape.map({
|
|
33
|
+
theme: Shape.plain.string().placeholder("dark"),
|
|
34
|
+
fontSize: Shape.plain.number().placeholder(14),
|
|
35
|
+
}),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
expect(derivePlaceholder(schema)).toEqual({
|
|
39
|
+
settings: {
|
|
40
|
+
theme: "dark",
|
|
41
|
+
fontSize: 14,
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("uses empty arrays for lists", () => {
|
|
47
|
+
const schema = Shape.doc({
|
|
48
|
+
items: Shape.list(Shape.plain.string()),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
expect(derivePlaceholder(schema)).toEqual({
|
|
52
|
+
items: [],
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("uses empty objects for records", () => {
|
|
57
|
+
const schema = Shape.doc({
|
|
58
|
+
data: Shape.record(Shape.plain.number()),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
expect(derivePlaceholder(schema)).toEqual({
|
|
62
|
+
data: {},
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("handles plain value shapes with defaults", () => {
|
|
67
|
+
const schema = Shape.doc({
|
|
68
|
+
config: Shape.map({
|
|
69
|
+
name: Shape.plain.string(),
|
|
70
|
+
count: Shape.plain.number(),
|
|
71
|
+
enabled: Shape.plain.boolean(),
|
|
72
|
+
nothing: Shape.plain.null(),
|
|
73
|
+
}),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expect(derivePlaceholder(schema)).toEqual({
|
|
77
|
+
config: {
|
|
78
|
+
name: "",
|
|
79
|
+
count: 0,
|
|
80
|
+
enabled: false,
|
|
81
|
+
nothing: null,
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("handles plain value shapes with custom placeholders", () => {
|
|
87
|
+
const schema = Shape.doc({
|
|
88
|
+
config: Shape.map({
|
|
89
|
+
name: Shape.plain.string().placeholder("default-name"),
|
|
90
|
+
count: Shape.plain.number().placeholder(42),
|
|
91
|
+
enabled: Shape.plain.boolean().placeholder(true),
|
|
92
|
+
}),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expect(derivePlaceholder(schema)).toEqual({
|
|
96
|
+
config: {
|
|
97
|
+
name: "default-name",
|
|
98
|
+
count: 42,
|
|
99
|
+
enabled: true,
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("handles nested plain objects", () => {
|
|
105
|
+
const schema = Shape.doc({
|
|
106
|
+
user: Shape.map({
|
|
107
|
+
profile: Shape.plain.object({
|
|
108
|
+
name: Shape.plain.string().placeholder("Anonymous"),
|
|
109
|
+
age: Shape.plain.number().placeholder(0),
|
|
110
|
+
}),
|
|
111
|
+
}),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
expect(derivePlaceholder(schema)).toEqual({
|
|
115
|
+
user: {
|
|
116
|
+
profile: {
|
|
117
|
+
name: "Anonymous",
|
|
118
|
+
age: 0,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("handles plain arrays as empty", () => {
|
|
125
|
+
const schema = Shape.doc({
|
|
126
|
+
tags: Shape.map({
|
|
127
|
+
items: Shape.plain.array(Shape.plain.string()),
|
|
128
|
+
}),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
expect(derivePlaceholder(schema)).toEqual({
|
|
132
|
+
tags: {
|
|
133
|
+
items: [],
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("handles plain records as empty", () => {
|
|
139
|
+
const schema = Shape.doc({
|
|
140
|
+
metadata: Shape.map({
|
|
141
|
+
values: Shape.plain.record(Shape.plain.number()),
|
|
142
|
+
}),
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
expect(derivePlaceholder(schema)).toEqual({
|
|
146
|
+
metadata: {
|
|
147
|
+
values: {},
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("handles union types by deriving from first variant", () => {
|
|
153
|
+
const schema = Shape.doc({
|
|
154
|
+
value: Shape.map({
|
|
155
|
+
data: Shape.plain.union([Shape.plain.string(), Shape.plain.null()]),
|
|
156
|
+
}),
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
expect(derivePlaceholder(schema)).toEqual({
|
|
160
|
+
value: {
|
|
161
|
+
data: "", // First variant is string, default is ""
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("handles union types with explicit placeholder", () => {
|
|
167
|
+
const schema = Shape.doc({
|
|
168
|
+
value: Shape.map({
|
|
169
|
+
data: Shape.plain
|
|
170
|
+
.union([Shape.plain.string(), Shape.plain.null()])
|
|
171
|
+
.placeholder(null),
|
|
172
|
+
}),
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
expect(derivePlaceholder(schema)).toEqual({
|
|
176
|
+
value: {
|
|
177
|
+
data: null,
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it("handles movable lists as empty arrays", () => {
|
|
183
|
+
const schema = Shape.doc({
|
|
184
|
+
tasks: Shape.movableList(Shape.plain.string()),
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
expect(derivePlaceholder(schema)).toEqual({
|
|
188
|
+
tasks: [],
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("handles tree containers as empty arrays", () => {
|
|
193
|
+
const schema = Shape.doc({
|
|
194
|
+
hierarchy: Shape.tree(Shape.map({ name: Shape.text() })),
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
expect(derivePlaceholder(schema)).toEqual({
|
|
198
|
+
hierarchy: [],
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("handles complex nested structures", () => {
|
|
203
|
+
const schema = Shape.doc({
|
|
204
|
+
article: Shape.map({
|
|
205
|
+
title: Shape.text().placeholder("Untitled Article"),
|
|
206
|
+
metadata: Shape.map({
|
|
207
|
+
views: Shape.counter().placeholder(0),
|
|
208
|
+
author: Shape.plain.object({
|
|
209
|
+
name: Shape.plain.string().placeholder("Anonymous"),
|
|
210
|
+
email: Shape.plain.string(),
|
|
211
|
+
}),
|
|
212
|
+
tags: Shape.list(Shape.plain.string()),
|
|
213
|
+
}),
|
|
214
|
+
}),
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
expect(derivePlaceholder(schema)).toEqual({
|
|
218
|
+
article: {
|
|
219
|
+
title: "Untitled Article",
|
|
220
|
+
metadata: {
|
|
221
|
+
views: 0,
|
|
222
|
+
author: {
|
|
223
|
+
name: "Anonymous",
|
|
224
|
+
email: "",
|
|
225
|
+
},
|
|
226
|
+
tags: [],
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it("handles string literal options", () => {
|
|
233
|
+
const schema = Shape.doc({
|
|
234
|
+
status: Shape.map({
|
|
235
|
+
value: Shape.plain.string("active", "inactive", "pending"),
|
|
236
|
+
}),
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
expect(derivePlaceholder(schema)).toEqual({
|
|
240
|
+
status: {
|
|
241
|
+
value: "active", // First option is the default
|
|
242
|
+
},
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { ContainerOrValueShape, DocShape, ValueShape } from "./shape.js"
|
|
2
|
+
import type { InferPlaceholderType } from "./types.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Derives the placeholder state from a schema by composing placeholder values.
|
|
6
|
+
*
|
|
7
|
+
* For leaf nodes (text, counter, values): uses _placeholder directly
|
|
8
|
+
* For containers (map, list, record): recurses into nested shapes
|
|
9
|
+
*/
|
|
10
|
+
export function derivePlaceholder<T extends DocShape>(
|
|
11
|
+
schema: T,
|
|
12
|
+
): InferPlaceholderType<T> {
|
|
13
|
+
const result: Record<string, unknown> = {}
|
|
14
|
+
|
|
15
|
+
for (const [key, shape] of Object.entries(schema.shapes)) {
|
|
16
|
+
result[key] = deriveShapePlaceholder(shape)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return result as InferPlaceholderType<T>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Derives placeholder for a single shape.
|
|
24
|
+
*
|
|
25
|
+
* Leaf nodes: return _placeholder directly
|
|
26
|
+
* Containers: recurse into nested shapes (ignore _placeholder on containers)
|
|
27
|
+
*/
|
|
28
|
+
export function deriveShapePlaceholder(shape: ContainerOrValueShape): unknown {
|
|
29
|
+
switch (shape._type) {
|
|
30
|
+
// Leaf containers - use _placeholder directly
|
|
31
|
+
case "text":
|
|
32
|
+
return shape._placeholder
|
|
33
|
+
case "counter":
|
|
34
|
+
return shape._placeholder
|
|
35
|
+
|
|
36
|
+
// Dynamic containers - always empty (no per-entry merging)
|
|
37
|
+
case "list":
|
|
38
|
+
case "movableList":
|
|
39
|
+
case "tree":
|
|
40
|
+
return []
|
|
41
|
+
case "record":
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
// Structured container - recurse into nested shapes
|
|
45
|
+
case "map": {
|
|
46
|
+
const result: Record<string, unknown> = {}
|
|
47
|
+
for (const [key, nestedShape] of Object.entries(shape.shapes)) {
|
|
48
|
+
result[key] = deriveShapePlaceholder(nestedShape)
|
|
49
|
+
}
|
|
50
|
+
return result
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case "value":
|
|
54
|
+
return deriveValueShapePlaceholder(shape)
|
|
55
|
+
|
|
56
|
+
default:
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function deriveValueShapePlaceholder(shape: ValueShape): unknown {
|
|
62
|
+
switch (shape.valueType) {
|
|
63
|
+
// Leaf values - use _placeholder directly
|
|
64
|
+
case "string":
|
|
65
|
+
return shape._placeholder
|
|
66
|
+
case "number":
|
|
67
|
+
return shape._placeholder
|
|
68
|
+
case "boolean":
|
|
69
|
+
return shape._placeholder
|
|
70
|
+
case "null":
|
|
71
|
+
return null
|
|
72
|
+
case "undefined":
|
|
73
|
+
return undefined
|
|
74
|
+
case "uint8array":
|
|
75
|
+
return shape._placeholder
|
|
76
|
+
|
|
77
|
+
// Structured value - recurse into nested shapes (like map)
|
|
78
|
+
case "object": {
|
|
79
|
+
const result: Record<string, unknown> = {}
|
|
80
|
+
for (const [key, nestedShape] of Object.entries(shape.shape)) {
|
|
81
|
+
result[key] = deriveValueShapePlaceholder(nestedShape)
|
|
82
|
+
}
|
|
83
|
+
return result
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Dynamic values - always empty
|
|
87
|
+
case "array":
|
|
88
|
+
return []
|
|
89
|
+
case "record":
|
|
90
|
+
return {}
|
|
91
|
+
|
|
92
|
+
// Unions - use _placeholder if explicitly set, otherwise derive from first variant
|
|
93
|
+
case "union": {
|
|
94
|
+
// Check if _placeholder was explicitly set (not the default empty object)
|
|
95
|
+
// We need to check if it's a primitive value OR a non-empty object
|
|
96
|
+
const placeholder = shape._placeholder
|
|
97
|
+
if (placeholder !== undefined) {
|
|
98
|
+
// If it's a primitive (null, string, number, boolean), use it
|
|
99
|
+
if (placeholder === null || typeof placeholder !== "object") {
|
|
100
|
+
return placeholder
|
|
101
|
+
}
|
|
102
|
+
// If it's an object with keys, use it
|
|
103
|
+
if (Object.keys(placeholder as object).length > 0) {
|
|
104
|
+
return placeholder
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Otherwise derive from first variant
|
|
108
|
+
return deriveValueShapePlaceholder(shape.shapes[0])
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
case "discriminatedUnion": {
|
|
112
|
+
// Check if _placeholder was explicitly set (not the default empty object)
|
|
113
|
+
const placeholder = shape._placeholder
|
|
114
|
+
if (placeholder !== undefined) {
|
|
115
|
+
// If it's a primitive (null, string, number, boolean), use it
|
|
116
|
+
if (placeholder === null || typeof placeholder !== "object") {
|
|
117
|
+
return placeholder
|
|
118
|
+
}
|
|
119
|
+
// If it's an object with keys, use it
|
|
120
|
+
if (Object.keys(placeholder as object).length > 0) {
|
|
121
|
+
return placeholder
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Otherwise derive from first variant
|
|
125
|
+
const firstKey = Object.keys(shape.variants)[0]
|
|
126
|
+
return deriveValueShapePlaceholder(shape.variants[firstKey])
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
default:
|
|
130
|
+
return undefined
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { TypedDoc } from "./change.js"
|
|
3
2
|
import { mergeValue } from "./overlay.js"
|
|
4
3
|
import { Shape } from "./shape.js"
|
|
4
|
+
import { TypedDoc } from "./typed-doc.js"
|
|
5
5
|
import { validateValue } from "./validation.js"
|
|
6
6
|
|
|
7
7
|
describe("discriminatedUnion", () => {
|
|
@@ -107,7 +107,7 @@ describe("discriminatedUnion", () => {
|
|
|
107
107
|
EmptyClientPresence,
|
|
108
108
|
)
|
|
109
109
|
|
|
110
|
-
// Should use client variant based on
|
|
110
|
+
// Should use client variant based on placeholder's type
|
|
111
111
|
expect(result).toEqual({
|
|
112
112
|
type: "client",
|
|
113
113
|
name: "Bob",
|
|
@@ -216,15 +216,11 @@ describe("discriminatedUnion", () => {
|
|
|
216
216
|
it("should allow setting a discriminated union property in a MapDraftNode", () => {
|
|
217
217
|
const DocSchema = Shape.doc({
|
|
218
218
|
state: Shape.map({
|
|
219
|
-
presence: GamePresenceSchema,
|
|
219
|
+
presence: GamePresenceSchema.placeholder(EmptyClientPresence),
|
|
220
220
|
}),
|
|
221
221
|
})
|
|
222
222
|
|
|
223
|
-
const doc = new TypedDoc(DocSchema
|
|
224
|
-
state: {
|
|
225
|
-
presence: EmptyClientPresence,
|
|
226
|
-
},
|
|
227
|
-
})
|
|
223
|
+
const doc = new TypedDoc(DocSchema)
|
|
228
224
|
|
|
229
225
|
doc.change(draft => {
|
|
230
226
|
// This should work now that MapDraftNode recognizes discriminatedUnion as a value shape
|
|
@@ -235,7 +231,7 @@ describe("discriminatedUnion", () => {
|
|
|
235
231
|
}
|
|
236
232
|
})
|
|
237
233
|
|
|
238
|
-
expect(doc.
|
|
234
|
+
expect(doc.toJSON().state.presence).toEqual({
|
|
239
235
|
type: "server",
|
|
240
236
|
cars: { p1: { x: 10, y: 20 } },
|
|
241
237
|
tick: 100,
|
package/src/draft-nodes/base.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { ContainerShape, DocShape, ShapeToContainer } from "../shape.js"
|
|
2
|
-
import type {
|
|
2
|
+
import type { Infer } from "../types.js"
|
|
3
3
|
|
|
4
4
|
export type DraftNodeParams<Shape extends DocShape | ContainerShape> = {
|
|
5
5
|
shape: Shape
|
|
6
|
-
|
|
6
|
+
placeholder?: Infer<Shape>
|
|
7
7
|
getContainer: () => ShapeToContainer<Shape>
|
|
8
|
+
readonly?: boolean
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
// Base class for all draft nodes
|
|
@@ -19,8 +20,12 @@ export abstract class DraftNode<Shape extends DocShape | ContainerShape> {
|
|
|
19
20
|
return this._params.shape
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
protected get
|
|
23
|
-
return this._params.
|
|
23
|
+
protected get placeholder(): Infer<Shape> | undefined {
|
|
24
|
+
return this._params.placeholder
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
protected get readonly(): boolean {
|
|
28
|
+
return !!this._params.readonly
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
protected get container(): ShapeToContainer<Shape> {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
### Summary: The `getCounter` Side-Effect & Read-Only Access
|
|
2
|
+
|
|
3
|
+
We have uncovered a subtle and unexpected between Loro's API and our `TypedDoc` "empty state" logic.
|
|
4
|
+
|
|
5
|
+
#### 1. The Problem
|
|
6
|
+
|
|
7
|
+
A test expects a LoroConter at `doc.value.counter` to return `1` (from `emptyState`), but it returns `0`.
|
|
8
|
+
|
|
9
|
+
- **`doc.toJSON()`** works correctly: It sees the doc is empty, so it overlays `emptyState` (`{ counter: 1 }`).
|
|
10
|
+
- **`doc.value`** fails: It returns `0`.
|
|
11
|
+
|
|
12
|
+
#### 2. The Root Cause: "Heisenberg" Observation
|
|
13
|
+
|
|
14
|
+
Accessing a root container in Loro changes it.
|
|
15
|
+
|
|
16
|
+
- **Observation:** Calling `doc.getCounter("counter")` **materializes** the container in the CRDT with a default value of `0`.
|
|
17
|
+
- **Consequence:** `DraftDoc` calls `getCounter` to read the value. This inadvertently "writes" a `0` to the document.
|
|
18
|
+
- **Result:** The "empty" state is lost. The document now effectively contains `counter: 0`.
|
|
19
|
+
|
|
20
|
+
#### 3. The Solution: `getShallowValue()`
|
|
21
|
+
|
|
22
|
+
We need to peek at the document to see if a container exists _before_ we try to retrieve (and thus create) it.
|
|
23
|
+
|
|
24
|
+
- **Investigation:** We verified that `doc.getShallowValue()` returns a list of existing root containers _without_ creating new ones.
|
|
25
|
+
- **Strategy:**
|
|
26
|
+
1. In `DraftDoc` (which handles root properties), check `readonly` mode.
|
|
27
|
+
2. Call `doc.getShallowValue()`.
|
|
28
|
+
3. **If key is missing:** Return the `emptyState` value directly.
|
|
29
|
+
4. **If key exists:** Safe to call `doc.getCounter()` (or others) to get the actual CRDT value.
|
|
30
|
+
|
|
31
|
+
This ensures `doc.value` remains a pure, non-destructive view that respects the "virtual" empty state.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { Shape } from "../shape.js"
|
|
3
|
+
import { createTypedDoc } from "../typed-doc.js"
|
|
4
|
+
|
|
5
|
+
describe("Counter Draft Node", () => {
|
|
6
|
+
it("should return placeholder value without materializing the container", () => {
|
|
7
|
+
const schema = Shape.doc({
|
|
8
|
+
counter: Shape.counter().placeholder(10),
|
|
9
|
+
})
|
|
10
|
+
const doc = createTypedDoc(schema)
|
|
11
|
+
|
|
12
|
+
// Accessing the value should return placeholder
|
|
13
|
+
expect(doc.value.counter).toBe(10)
|
|
14
|
+
|
|
15
|
+
// Verify it is NOT materialized in the underlying doc
|
|
16
|
+
const shallow = doc.loroDoc.getShallowValue()
|
|
17
|
+
expect(shallow.counter).toBeUndefined()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("should materialize the container after modification", () => {
|
|
21
|
+
const schema = Shape.doc({
|
|
22
|
+
counter: Shape.counter().placeholder(10),
|
|
23
|
+
})
|
|
24
|
+
const doc = createTypedDoc(schema)
|
|
25
|
+
|
|
26
|
+
doc.change(draft => {
|
|
27
|
+
draft.counter.increment(5)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// Value should be updated
|
|
31
|
+
// Note: placeholder is NOT applied to the CRDT. It is only a read-time overlay.
|
|
32
|
+
// When we modify the counter, we are modifying the underlying CRDT counter which starts at 0.
|
|
33
|
+
// So 0 + 5 = 5. The placeholder (10) is lost once the container exists.
|
|
34
|
+
expect(doc.value.counter).toBe(5)
|
|
35
|
+
|
|
36
|
+
// Verify it IS materialized in the underlying doc
|
|
37
|
+
const shallow = doc.loroDoc.getShallowValue()
|
|
38
|
+
expect(shallow.counter).toBeDefined()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("should respect placeholder even if accessed multiple times", () => {
|
|
42
|
+
const schema = Shape.doc({
|
|
43
|
+
counter: Shape.counter().placeholder(10),
|
|
44
|
+
})
|
|
45
|
+
const doc = createTypedDoc(schema)
|
|
46
|
+
|
|
47
|
+
expect(doc.value.counter).toBe(10)
|
|
48
|
+
expect(doc.value.counter).toBe(10)
|
|
49
|
+
|
|
50
|
+
// Still not materialized
|
|
51
|
+
expect(doc.loroDoc.getShallowValue().counter).toBeUndefined()
|
|
52
|
+
})
|
|
53
|
+
})
|
package/src/draft-nodes/doc.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LoroDoc } from "loro-crdt"
|
|
2
|
-
import type {
|
|
2
|
+
import type { Infer } from "../index.js"
|
|
3
3
|
import type { ContainerShape, DocShape } from "../shape.js"
|
|
4
4
|
import { DraftNode, type DraftNodeParams } from "./base.js"
|
|
5
5
|
import { createContainerDraftNode } from "./utils.js"
|
|
@@ -18,7 +18,7 @@ const containerGetter = {
|
|
|
18
18
|
export class DraftDoc<Shape extends DocShape> extends DraftNode<Shape> {
|
|
19
19
|
private doc: LoroDoc
|
|
20
20
|
private propertyCache = new Map<string, DraftNode<ContainerShape>>()
|
|
21
|
-
private
|
|
21
|
+
private requiredPlaceholder!: Infer<Shape>
|
|
22
22
|
|
|
23
23
|
constructor(
|
|
24
24
|
_params: Omit<DraftNodeParams<Shape>, "getContainer"> & { doc: LoroDoc },
|
|
@@ -29,9 +29,9 @@ export class DraftDoc<Shape extends DocShape> extends DraftNode<Shape> {
|
|
|
29
29
|
throw new Error("can't get container on DraftDoc")
|
|
30
30
|
},
|
|
31
31
|
})
|
|
32
|
-
if (!_params.
|
|
32
|
+
if (!_params.placeholder) throw new Error("placeholder required")
|
|
33
33
|
this.doc = _params.doc
|
|
34
|
-
this.
|
|
34
|
+
this.requiredPlaceholder = _params.placeholder
|
|
35
35
|
this.createLazyProperties()
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -43,15 +43,27 @@ export class DraftDoc<Shape extends DocShape> extends DraftNode<Shape> {
|
|
|
43
43
|
|
|
44
44
|
return {
|
|
45
45
|
shape,
|
|
46
|
-
|
|
46
|
+
placeholder: this.requiredPlaceholder[key],
|
|
47
47
|
getContainer: () => getter(key),
|
|
48
|
+
readonly: this.readonly,
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
getOrCreateDraftNode(
|
|
52
53
|
key: string,
|
|
53
54
|
shape: ContainerShape,
|
|
54
|
-
): DraftNode<ContainerShape> {
|
|
55
|
+
): DraftNode<ContainerShape> | number | string {
|
|
56
|
+
if (
|
|
57
|
+
this.readonly &&
|
|
58
|
+
(shape._type === "counter" || shape._type === "text")
|
|
59
|
+
) {
|
|
60
|
+
// Check if the container exists in the doc without creating it
|
|
61
|
+
const shallow = this.doc.getShallowValue()
|
|
62
|
+
if (!shallow[key]) {
|
|
63
|
+
return this.requiredPlaceholder[key] as any
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
let node = this.propertyCache.get(key)
|
|
56
68
|
|
|
57
69
|
if (!node) {
|
|
@@ -59,6 +71,15 @@ export class DraftDoc<Shape extends DocShape> extends DraftNode<Shape> {
|
|
|
59
71
|
this.propertyCache.set(key, node)
|
|
60
72
|
}
|
|
61
73
|
|
|
74
|
+
if (this.readonly) {
|
|
75
|
+
if (shape._type === "counter") {
|
|
76
|
+
return (node as any).value
|
|
77
|
+
}
|
|
78
|
+
if (shape._type === "text") {
|
|
79
|
+
return (node as any).toString()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
62
83
|
return node
|
|
63
84
|
}
|
|
64
85
|
|
|
@@ -84,7 +84,7 @@ export abstract class ListDraftNodeBase<
|
|
|
84
84
|
): DraftNodeParams<ContainerShape> {
|
|
85
85
|
return {
|
|
86
86
|
shape,
|
|
87
|
-
|
|
87
|
+
placeholder: undefined, // List items don't have placeholder
|
|
88
88
|
getContainer: () => {
|
|
89
89
|
const containerItem = this.container.get(index)
|
|
90
90
|
if (!containerItem || !isContainer(containerItem)) {
|
|
@@ -92,6 +92,7 @@ export abstract class ListDraftNodeBase<
|
|
|
92
92
|
}
|
|
93
93
|
return containerItem
|
|
94
94
|
},
|
|
95
|
+
readonly: this.readonly,
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
|
|
@@ -142,7 +143,7 @@ export abstract class ListDraftNodeBase<
|
|
|
142
143
|
}
|
|
143
144
|
|
|
144
145
|
// Get item for return values - returns DraftItem that can be mutated
|
|
145
|
-
protected getDraftItem(index: number):
|
|
146
|
+
protected getDraftItem(index: number): any {
|
|
146
147
|
// Check if we already have a cached item for this index
|
|
147
148
|
let cachedItem = this.itemCache.get(index)
|
|
148
149
|
if (cachedItem) {
|
|
@@ -167,14 +168,29 @@ export abstract class ListDraftNodeBase<
|
|
|
167
168
|
// For primitives, just use the value directly
|
|
168
169
|
cachedItem = containerItem
|
|
169
170
|
}
|
|
170
|
-
|
|
171
|
+
// Only cache primitive values if NOT readonly
|
|
172
|
+
if (!this.readonly) {
|
|
173
|
+
this.itemCache.set(index, cachedItem)
|
|
174
|
+
}
|
|
171
175
|
return cachedItem as DraftItem
|
|
172
176
|
} else {
|
|
173
177
|
// For container shapes, create a proper draft node using the new pattern
|
|
174
178
|
cachedItem = createContainerDraftNode(
|
|
175
179
|
this.getDraftNodeParams(index, this.shape.shape as ContainerShape),
|
|
176
180
|
)
|
|
181
|
+
// Cache container nodes
|
|
177
182
|
this.itemCache.set(index, cachedItem)
|
|
183
|
+
|
|
184
|
+
if (this.readonly) {
|
|
185
|
+
const shape = this.shape.shape as ContainerShape
|
|
186
|
+
if (shape._type === "counter") {
|
|
187
|
+
return (cachedItem as any).value
|
|
188
|
+
}
|
|
189
|
+
if (shape._type === "text") {
|
|
190
|
+
return (cachedItem as any).toString()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
178
194
|
return cachedItem as DraftItem
|
|
179
195
|
}
|
|
180
196
|
}
|
|
@@ -254,26 +270,31 @@ export abstract class ListDraftNodeBase<
|
|
|
254
270
|
}
|
|
255
271
|
|
|
256
272
|
insert(index: number, item: Item): void {
|
|
273
|
+
if (this.readonly) throw new Error("Cannot modify readonly doc")
|
|
257
274
|
// Update cache indices before performing the insert operation
|
|
258
275
|
this.updateCacheForInsert(index)
|
|
259
276
|
this.insertWithConversion(index, item)
|
|
260
277
|
}
|
|
261
278
|
|
|
262
279
|
delete(index: number, len: number): void {
|
|
280
|
+
if (this.readonly) throw new Error("Cannot modify readonly doc")
|
|
263
281
|
// Update cache indices before performing the delete operation
|
|
264
282
|
this.updateCacheForDelete(index, len)
|
|
265
283
|
this.container.delete(index, len)
|
|
266
284
|
}
|
|
267
285
|
|
|
268
286
|
push(item: Item): void {
|
|
287
|
+
if (this.readonly) throw new Error("Cannot modify readonly doc")
|
|
269
288
|
this.pushWithConversion(item)
|
|
270
289
|
}
|
|
271
290
|
|
|
272
291
|
pushContainer(container: Container): Container {
|
|
292
|
+
if (this.readonly) throw new Error("Cannot modify readonly doc")
|
|
273
293
|
return this.container.pushContainer(container)
|
|
274
294
|
}
|
|
275
295
|
|
|
276
296
|
insertContainer(index: number, container: Container): Container {
|
|
297
|
+
if (this.readonly) throw new Error("Cannot modify readonly doc")
|
|
277
298
|
return this.container.insertContainer(index, container)
|
|
278
299
|
}
|
|
279
300
|
|