@loro-extended/change 0.9.0 → 1.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 +179 -69
- package/dist/index.d.ts +369 -172
- package/dist/index.js +691 -382
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/change.test.ts +180 -175
- package/src/conversion.test.ts +91 -91
- package/src/conversion.ts +12 -12
- package/src/derive-placeholder.test.ts +14 -14
- package/src/derive-placeholder.ts +3 -3
- package/src/discriminated-union-assignability.test.ts +7 -7
- package/src/discriminated-union-tojson.test.ts +13 -24
- package/src/discriminated-union.test.ts +9 -8
- package/src/equality.test.ts +10 -2
- package/src/functional-helpers.test.ts +149 -0
- package/src/functional-helpers.ts +61 -0
- package/src/grand-unified-api.test.ts +423 -0
- package/src/index.ts +8 -6
- package/src/json-patch.test.ts +64 -56
- package/src/overlay-recursion.test.ts +326 -0
- package/src/overlay.ts +54 -17
- package/src/readonly.test.ts +27 -26
- package/src/shape.ts +103 -21
- package/src/typed-doc.ts +227 -58
- package/src/typed-refs/base.ts +33 -1
- package/src/typed-refs/counter.test.ts +44 -13
- package/src/typed-refs/counter.ts +42 -5
- package/src/typed-refs/doc.ts +29 -30
- package/src/typed-refs/json-compatibility.test.ts +37 -32
- package/src/typed-refs/list-base.ts +49 -21
- package/src/typed-refs/list.test.ts +4 -3
- package/src/typed-refs/movable-list.test.ts +3 -2
- package/src/typed-refs/movable-list.ts +6 -3
- package/src/typed-refs/proxy-handlers.ts +14 -1
- package/src/typed-refs/record.test.ts +116 -51
- package/src/typed-refs/record.ts +86 -81
- package/src/typed-refs/{map.ts → struct.ts} +66 -78
- package/src/typed-refs/text.ts +48 -7
- package/src/typed-refs/tree.ts +3 -3
- package/src/typed-refs/utils.ts +120 -13
- package/src/types.test.ts +34 -39
- package/src/types.ts +5 -40
- package/src/utils/type-guards.ts +11 -6
- package/src/validation.ts +10 -10
package/src/conversion.ts
CHANGED
|
@@ -11,11 +11,11 @@ import type {
|
|
|
11
11
|
ArrayValueShape,
|
|
12
12
|
ContainerOrValueShape,
|
|
13
13
|
ListContainerShape,
|
|
14
|
-
MapContainerShape,
|
|
15
14
|
MovableListContainerShape,
|
|
16
|
-
ObjectValueShape,
|
|
17
15
|
RecordContainerShape,
|
|
18
16
|
RecordValueShape,
|
|
17
|
+
StructContainerShape,
|
|
18
|
+
StructValueShape,
|
|
19
19
|
} from "./shape.js"
|
|
20
20
|
import {
|
|
21
21
|
isContainer,
|
|
@@ -59,7 +59,7 @@ function convertListInput(
|
|
|
59
59
|
const list = new LoroList()
|
|
60
60
|
|
|
61
61
|
for (const item of value) {
|
|
62
|
-
const convertedItem =
|
|
62
|
+
const convertedItem = convertInputToRef(item, shape.shape)
|
|
63
63
|
if (isContainer(convertedItem)) {
|
|
64
64
|
list.pushContainer(convertedItem)
|
|
65
65
|
} else {
|
|
@@ -85,7 +85,7 @@ function convertMovableListInput(
|
|
|
85
85
|
const list = new LoroMovableList()
|
|
86
86
|
|
|
87
87
|
for (const item of value) {
|
|
88
|
-
const convertedItem =
|
|
88
|
+
const convertedItem = convertInputToRef(item, shape.shape)
|
|
89
89
|
if (isContainer(convertedItem)) {
|
|
90
90
|
list.pushContainer(convertedItem)
|
|
91
91
|
} else {
|
|
@@ -97,11 +97,11 @@ function convertMovableListInput(
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
|
-
* Converts object input to LoroMap container
|
|
100
|
+
* Converts object input to LoroMap container (Struct)
|
|
101
101
|
*/
|
|
102
|
-
function
|
|
102
|
+
function convertStructInput(
|
|
103
103
|
value: { [key: string]: Value },
|
|
104
|
-
shape:
|
|
104
|
+
shape: StructContainerShape | StructValueShape,
|
|
105
105
|
): LoroMap | { [key: string]: Value } {
|
|
106
106
|
if (!isContainerShape(shape)) {
|
|
107
107
|
return value
|
|
@@ -111,7 +111,7 @@ function convertMapInput(
|
|
|
111
111
|
for (const [k, v] of Object.entries(value)) {
|
|
112
112
|
const nestedSchema = shape.shapes[k]
|
|
113
113
|
if (nestedSchema) {
|
|
114
|
-
const convertedValue =
|
|
114
|
+
const convertedValue = convertInputToRef(v, nestedSchema)
|
|
115
115
|
if (isContainer(convertedValue)) {
|
|
116
116
|
map.setContainer(k, convertedValue)
|
|
117
117
|
} else {
|
|
@@ -138,7 +138,7 @@ function convertRecordInput(
|
|
|
138
138
|
|
|
139
139
|
const map = new LoroMap()
|
|
140
140
|
for (const [k, v] of Object.entries(value)) {
|
|
141
|
-
const convertedValue =
|
|
141
|
+
const convertedValue = convertInputToRef(v, shape.shape)
|
|
142
142
|
if (isContainer(convertedValue)) {
|
|
143
143
|
map.setContainer(k, convertedValue)
|
|
144
144
|
} else {
|
|
@@ -153,7 +153,7 @@ function convertRecordInput(
|
|
|
153
153
|
* Main conversion function that transforms input values to appropriate CRDT containers
|
|
154
154
|
* based on schema definitions
|
|
155
155
|
*/
|
|
156
|
-
export function
|
|
156
|
+
export function convertInputToRef<Shape extends ContainerOrValueShape>(
|
|
157
157
|
value: Value,
|
|
158
158
|
shape: Shape,
|
|
159
159
|
): Container | Value {
|
|
@@ -186,12 +186,12 @@ export function convertInputToNode<Shape extends ContainerOrValueShape>(
|
|
|
186
186
|
|
|
187
187
|
return convertMovableListInput(value, shape)
|
|
188
188
|
}
|
|
189
|
-
case "
|
|
189
|
+
case "struct": {
|
|
190
190
|
if (!isObjectValue(value)) {
|
|
191
191
|
throw new Error("object expected")
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
return
|
|
194
|
+
return convertStructInput(value, shape)
|
|
195
195
|
}
|
|
196
196
|
case "record": {
|
|
197
197
|
if (!isObjectValue(value)) {
|
|
@@ -29,7 +29,7 @@ describe("derivePlaceholder", () => {
|
|
|
29
29
|
|
|
30
30
|
it("composes nested map placeholders", () => {
|
|
31
31
|
const schema = Shape.doc({
|
|
32
|
-
settings: Shape.
|
|
32
|
+
settings: Shape.struct({
|
|
33
33
|
theme: Shape.plain.string().placeholder("dark"),
|
|
34
34
|
fontSize: Shape.plain.number().placeholder(14),
|
|
35
35
|
}),
|
|
@@ -65,7 +65,7 @@ describe("derivePlaceholder", () => {
|
|
|
65
65
|
|
|
66
66
|
it("handles plain value shapes with defaults", () => {
|
|
67
67
|
const schema = Shape.doc({
|
|
68
|
-
config: Shape.
|
|
68
|
+
config: Shape.struct({
|
|
69
69
|
name: Shape.plain.string(),
|
|
70
70
|
count: Shape.plain.number(),
|
|
71
71
|
enabled: Shape.plain.boolean(),
|
|
@@ -85,7 +85,7 @@ describe("derivePlaceholder", () => {
|
|
|
85
85
|
|
|
86
86
|
it("handles plain value shapes with custom placeholders", () => {
|
|
87
87
|
const schema = Shape.doc({
|
|
88
|
-
config: Shape.
|
|
88
|
+
config: Shape.struct({
|
|
89
89
|
name: Shape.plain.string().placeholder("default-name"),
|
|
90
90
|
count: Shape.plain.number().placeholder(42),
|
|
91
91
|
enabled: Shape.plain.boolean().placeholder(true),
|
|
@@ -103,8 +103,8 @@ describe("derivePlaceholder", () => {
|
|
|
103
103
|
|
|
104
104
|
it("handles nested plain objects", () => {
|
|
105
105
|
const schema = Shape.doc({
|
|
106
|
-
user: Shape.
|
|
107
|
-
profile: Shape.plain.
|
|
106
|
+
user: Shape.struct({
|
|
107
|
+
profile: Shape.plain.struct({
|
|
108
108
|
name: Shape.plain.string().placeholder("Anonymous"),
|
|
109
109
|
age: Shape.plain.number().placeholder(0),
|
|
110
110
|
}),
|
|
@@ -123,7 +123,7 @@ describe("derivePlaceholder", () => {
|
|
|
123
123
|
|
|
124
124
|
it("handles plain arrays as empty", () => {
|
|
125
125
|
const schema = Shape.doc({
|
|
126
|
-
tags: Shape.
|
|
126
|
+
tags: Shape.struct({
|
|
127
127
|
items: Shape.plain.array(Shape.plain.string()),
|
|
128
128
|
}),
|
|
129
129
|
})
|
|
@@ -137,7 +137,7 @@ describe("derivePlaceholder", () => {
|
|
|
137
137
|
|
|
138
138
|
it("handles plain records as empty", () => {
|
|
139
139
|
const schema = Shape.doc({
|
|
140
|
-
metadata: Shape.
|
|
140
|
+
metadata: Shape.struct({
|
|
141
141
|
values: Shape.plain.record(Shape.plain.number()),
|
|
142
142
|
}),
|
|
143
143
|
})
|
|
@@ -151,7 +151,7 @@ describe("derivePlaceholder", () => {
|
|
|
151
151
|
|
|
152
152
|
it("handles union types by deriving from first variant", () => {
|
|
153
153
|
const schema = Shape.doc({
|
|
154
|
-
value: Shape.
|
|
154
|
+
value: Shape.struct({
|
|
155
155
|
data: Shape.plain.union([Shape.plain.string(), Shape.plain.null()]),
|
|
156
156
|
}),
|
|
157
157
|
})
|
|
@@ -165,7 +165,7 @@ describe("derivePlaceholder", () => {
|
|
|
165
165
|
|
|
166
166
|
it("handles union types with explicit placeholder", () => {
|
|
167
167
|
const schema = Shape.doc({
|
|
168
|
-
value: Shape.
|
|
168
|
+
value: Shape.struct({
|
|
169
169
|
data: Shape.plain
|
|
170
170
|
.union([Shape.plain.string(), Shape.plain.null()])
|
|
171
171
|
.placeholder(null),
|
|
@@ -191,7 +191,7 @@ describe("derivePlaceholder", () => {
|
|
|
191
191
|
|
|
192
192
|
it("handles tree containers as empty arrays", () => {
|
|
193
193
|
const schema = Shape.doc({
|
|
194
|
-
hierarchy: Shape.tree(Shape.
|
|
194
|
+
hierarchy: Shape.tree(Shape.struct({ name: Shape.text() })),
|
|
195
195
|
})
|
|
196
196
|
|
|
197
197
|
expect(derivePlaceholder(schema)).toEqual({
|
|
@@ -201,11 +201,11 @@ describe("derivePlaceholder", () => {
|
|
|
201
201
|
|
|
202
202
|
it("handles complex nested structures", () => {
|
|
203
203
|
const schema = Shape.doc({
|
|
204
|
-
article: Shape.
|
|
204
|
+
article: Shape.struct({
|
|
205
205
|
title: Shape.text().placeholder("Untitled Article"),
|
|
206
|
-
metadata: Shape.
|
|
206
|
+
metadata: Shape.struct({
|
|
207
207
|
views: Shape.counter().placeholder(0),
|
|
208
|
-
author: Shape.plain.
|
|
208
|
+
author: Shape.plain.struct({
|
|
209
209
|
name: Shape.plain.string().placeholder("Anonymous"),
|
|
210
210
|
email: Shape.plain.string(),
|
|
211
211
|
}),
|
|
@@ -231,7 +231,7 @@ describe("derivePlaceholder", () => {
|
|
|
231
231
|
|
|
232
232
|
it("handles string literal options", () => {
|
|
233
233
|
const schema = Shape.doc({
|
|
234
|
-
status: Shape.
|
|
234
|
+
status: Shape.struct({
|
|
235
235
|
value: Shape.plain.string("active", "inactive", "pending"),
|
|
236
236
|
}),
|
|
237
237
|
})
|
|
@@ -42,7 +42,7 @@ export function deriveShapePlaceholder(shape: ContainerOrValueShape): unknown {
|
|
|
42
42
|
return {}
|
|
43
43
|
|
|
44
44
|
// Structured container - recurse into nested shapes
|
|
45
|
-
case "
|
|
45
|
+
case "struct": {
|
|
46
46
|
const result: Record<string, unknown> = {}
|
|
47
47
|
for (const [key, nestedShape] of Object.entries(shape.shapes)) {
|
|
48
48
|
result[key] = deriveShapePlaceholder(nestedShape)
|
|
@@ -74,8 +74,8 @@ function deriveValueShapePlaceholder(shape: ValueShape): unknown {
|
|
|
74
74
|
case "uint8array":
|
|
75
75
|
return shape._placeholder
|
|
76
76
|
|
|
77
|
-
// Structured value - recurse into nested shapes (like
|
|
78
|
-
case "
|
|
77
|
+
// Structured value - recurse into nested shapes (like struct)
|
|
78
|
+
case "struct": {
|
|
79
79
|
const result: Record<string, unknown> = {}
|
|
80
80
|
for (const [key, nestedShape] of Object.entries(shape.shape)) {
|
|
81
81
|
result[key] = deriveValueShapePlaceholder(nestedShape)
|
|
@@ -8,35 +8,35 @@ describe("Discriminated Union Placeholder Issue", () => {
|
|
|
8
8
|
|
|
9
9
|
const SessionPhaseSchema = Shape.plain
|
|
10
10
|
.discriminatedUnion("phase", {
|
|
11
|
-
"not-started": Shape.plain.
|
|
11
|
+
"not-started": Shape.plain.struct({
|
|
12
12
|
phase: Shape.plain.string("not-started"),
|
|
13
13
|
}),
|
|
14
14
|
|
|
15
|
-
lobby: Shape.plain.
|
|
15
|
+
lobby: Shape.plain.struct({
|
|
16
16
|
phase: Shape.plain.string("lobby"),
|
|
17
17
|
}),
|
|
18
|
-
"lobby-paused": Shape.plain.
|
|
18
|
+
"lobby-paused": Shape.plain.struct({
|
|
19
19
|
phase: Shape.plain.string("lobby-paused"),
|
|
20
20
|
reason: PauseReasonSchema,
|
|
21
21
|
}),
|
|
22
22
|
|
|
23
|
-
active: Shape.plain.
|
|
23
|
+
active: Shape.plain.struct({
|
|
24
24
|
phase: Shape.plain.string("active"),
|
|
25
25
|
mode: ActiveModeSchema,
|
|
26
26
|
}),
|
|
27
|
-
"active-paused": Shape.plain.
|
|
27
|
+
"active-paused": Shape.plain.struct({
|
|
28
28
|
phase: Shape.plain.string("active-paused"),
|
|
29
29
|
mode: ActiveModeSchema,
|
|
30
30
|
reason: PauseReasonSchema,
|
|
31
31
|
}),
|
|
32
32
|
|
|
33
|
-
ended: Shape.plain.
|
|
33
|
+
ended: Shape.plain.struct({
|
|
34
34
|
phase: Shape.plain.string("ended"),
|
|
35
35
|
}),
|
|
36
36
|
})
|
|
37
37
|
.placeholder({ phase: "not-started" })
|
|
38
38
|
|
|
39
|
-
const PhaseTransitionSchema = Shape.
|
|
39
|
+
const PhaseTransitionSchema = Shape.struct({
|
|
40
40
|
phase: SessionPhaseSchema,
|
|
41
41
|
})
|
|
42
42
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { LoroDoc, LoroMap } from "loro-crdt"
|
|
2
2
|
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { change } from "./functional-helpers.js"
|
|
3
4
|
import { Shape } from "./shape.js"
|
|
4
|
-
import { createTypedDoc
|
|
5
|
+
import { createTypedDoc } from "./typed-doc.js"
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* This test file reproduces the "placeholder required" error reported by users
|
|
@@ -14,16 +15,16 @@ import { createTypedDoc, TypedDoc } from "./typed-doc.js"
|
|
|
14
15
|
*/
|
|
15
16
|
describe("Record with Map entries - placeholder required bug", () => {
|
|
16
17
|
// Reproduce the user's schema structure
|
|
17
|
-
const StudentTomStateSchema = Shape.
|
|
18
|
+
const StudentTomStateSchema = Shape.struct({
|
|
18
19
|
peerId: Shape.plain.string(),
|
|
19
20
|
authorName: Shape.plain.string(),
|
|
20
21
|
authorColor: Shape.plain.string(),
|
|
21
22
|
intentionHistory: Shape.list(
|
|
22
|
-
Shape.
|
|
23
|
+
Shape.struct({
|
|
23
24
|
observedAt: Shape.plain.number(),
|
|
24
25
|
messageTimestamp: Shape.plain.number(),
|
|
25
26
|
predictions: Shape.list(
|
|
26
|
-
Shape.
|
|
27
|
+
Shape.struct({
|
|
27
28
|
horizon: Shape.plain.string("now", "soon", "future"),
|
|
28
29
|
value: Shape.plain.string(),
|
|
29
30
|
}),
|
|
@@ -31,11 +32,11 @@ describe("Record with Map entries - placeholder required bug", () => {
|
|
|
31
32
|
}),
|
|
32
33
|
),
|
|
33
34
|
emotionHistory: Shape.list(
|
|
34
|
-
Shape.
|
|
35
|
+
Shape.struct({
|
|
35
36
|
observedAt: Shape.plain.number(),
|
|
36
37
|
messageTimestamp: Shape.plain.number(),
|
|
37
38
|
predictions: Shape.list(
|
|
38
|
-
Shape.
|
|
39
|
+
Shape.struct({
|
|
39
40
|
horizon: Shape.plain.string("now", "soon", "future"),
|
|
40
41
|
value: Shape.plain.string(),
|
|
41
42
|
}),
|
|
@@ -63,16 +64,12 @@ describe("Record with Map entries - placeholder required bug", () => {
|
|
|
63
64
|
// Note: authorColor is NOT set - this should fall back to placeholder
|
|
64
65
|
|
|
65
66
|
// Now wrap it with TypedDoc
|
|
66
|
-
const typedDoc =
|
|
67
|
-
|
|
68
|
-
// Log the raw CRDT value
|
|
69
|
-
console.log("Raw CRDT value:", JSON.stringify(loroDoc.toJSON(), null, 2))
|
|
67
|
+
const typedDoc = createTypedDoc(AiStateSchema, loroDoc)
|
|
70
68
|
|
|
71
69
|
// This should not throw "placeholder required"
|
|
72
70
|
// BUG: Currently throws because the nested MapRef has placeholder: undefined
|
|
73
71
|
expect(() => {
|
|
74
|
-
|
|
75
|
-
console.log("toJSON result:", JSON.stringify(json, null, 2))
|
|
72
|
+
typedDoc.toJSON()
|
|
76
73
|
}).not.toThrow()
|
|
77
74
|
})
|
|
78
75
|
|
|
@@ -80,7 +77,7 @@ describe("Record with Map entries - placeholder required bug", () => {
|
|
|
80
77
|
const typedDoc = createTypedDoc(AiStateSchema)
|
|
81
78
|
|
|
82
79
|
// Add an entry via the typed API
|
|
83
|
-
|
|
80
|
+
change(typedDoc, draft => {
|
|
84
81
|
draft.tomState.set("peer-123", {
|
|
85
82
|
peerId: "peer-123",
|
|
86
83
|
authorName: "Alice",
|
|
@@ -92,8 +89,7 @@ describe("Record with Map entries - placeholder required bug", () => {
|
|
|
92
89
|
|
|
93
90
|
// This should work because all values were set
|
|
94
91
|
expect(() => {
|
|
95
|
-
|
|
96
|
-
console.log("toJSON result (via change):", JSON.stringify(json, null, 2))
|
|
92
|
+
typedDoc.toJSON()
|
|
97
93
|
}).not.toThrow()
|
|
98
94
|
})
|
|
99
95
|
|
|
@@ -111,18 +107,11 @@ describe("Record with Map entries - placeholder required bug", () => {
|
|
|
111
107
|
// Only set peerId - other fields are missing
|
|
112
108
|
studentMap.set("peerId", "peer-456")
|
|
113
109
|
|
|
114
|
-
const typedDoc =
|
|
115
|
-
|
|
116
|
-
// Log what we have
|
|
117
|
-
console.log(
|
|
118
|
-
"Raw CRDT (partial):",
|
|
119
|
-
JSON.stringify(loroDoc.toJSON(), null, 2),
|
|
120
|
-
)
|
|
110
|
+
const typedDoc = createTypedDoc(AiStateSchema, loroDoc)
|
|
121
111
|
|
|
122
112
|
// This should not throw - missing fields should use placeholder defaults
|
|
123
113
|
expect(() => {
|
|
124
|
-
|
|
125
|
-
console.log("toJSON (partial):", JSON.stringify(json, null, 2))
|
|
114
|
+
typedDoc.toJSON()
|
|
126
115
|
}).not.toThrow()
|
|
127
116
|
})
|
|
128
117
|
})
|
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { change } from "./functional-helpers.js"
|
|
2
3
|
import { mergeValue } from "./overlay.js"
|
|
3
4
|
import { Shape } from "./shape.js"
|
|
4
|
-
import {
|
|
5
|
+
import { createTypedDoc } from "./typed-doc.js"
|
|
5
6
|
import { validateValue } from "./validation.js"
|
|
6
7
|
|
|
7
8
|
describe("discriminatedUnion", () => {
|
|
8
9
|
// Define variant shapes
|
|
9
|
-
const ClientPresenceShape = Shape.plain.
|
|
10
|
+
const ClientPresenceShape = Shape.plain.struct({
|
|
10
11
|
type: Shape.plain.string("client"),
|
|
11
12
|
name: Shape.plain.string(),
|
|
12
|
-
input: Shape.plain.
|
|
13
|
+
input: Shape.plain.struct({
|
|
13
14
|
force: Shape.plain.number(),
|
|
14
15
|
angle: Shape.plain.number(),
|
|
15
16
|
}),
|
|
16
17
|
})
|
|
17
18
|
|
|
18
|
-
const ServerPresenceShape = Shape.plain.
|
|
19
|
+
const ServerPresenceShape = Shape.plain.struct({
|
|
19
20
|
type: Shape.plain.string("server"),
|
|
20
21
|
cars: Shape.plain.record(
|
|
21
|
-
Shape.plain.
|
|
22
|
+
Shape.plain.struct({
|
|
22
23
|
x: Shape.plain.number(),
|
|
23
24
|
y: Shape.plain.number(),
|
|
24
25
|
}),
|
|
@@ -215,14 +216,14 @@ describe("discriminatedUnion", () => {
|
|
|
215
216
|
describe("TypedDoc integration", () => {
|
|
216
217
|
it("should allow setting a discriminated union property in a MapDraftNode", () => {
|
|
217
218
|
const DocSchema = Shape.doc({
|
|
218
|
-
state: Shape.
|
|
219
|
+
state: Shape.struct({
|
|
219
220
|
presence: GamePresenceSchema.placeholder(EmptyClientPresence),
|
|
220
221
|
}),
|
|
221
222
|
})
|
|
222
223
|
|
|
223
|
-
const doc =
|
|
224
|
+
const doc = createTypedDoc(DocSchema)
|
|
224
225
|
|
|
225
|
-
|
|
226
|
+
change(doc, draft => {
|
|
226
227
|
// This should work now that MapDraftNode recognizes discriminatedUnion as a value shape
|
|
227
228
|
draft.state.presence = {
|
|
228
229
|
type: "server",
|
package/src/equality.test.ts
CHANGED
|
@@ -7,13 +7,21 @@ describe("Equality Check", () => {
|
|
|
7
7
|
counter: Shape.counter().placeholder(1),
|
|
8
8
|
})
|
|
9
9
|
|
|
10
|
-
it("should compare
|
|
10
|
+
it("should compare CounterRef.value to plain number", () => {
|
|
11
11
|
const doc = createTypedDoc(schema)
|
|
12
|
-
|
|
12
|
+
// doc.counter returns a CounterRef, use .value to get the number
|
|
13
|
+
expect(doc.counter.value).toEqual(1)
|
|
13
14
|
})
|
|
14
15
|
|
|
15
16
|
it("should compare equal using toJSON", () => {
|
|
16
17
|
const doc = createTypedDoc(schema)
|
|
17
18
|
expect(doc.toJSON()).toEqual({ counter: 1 })
|
|
18
19
|
})
|
|
20
|
+
|
|
21
|
+
it("should support valueOf for loose comparisons", () => {
|
|
22
|
+
const doc = createTypedDoc(schema)
|
|
23
|
+
// CounterRef has valueOf() so it can be used in arithmetic
|
|
24
|
+
expect(doc.counter.valueOf()).toBe(1)
|
|
25
|
+
expect(+doc.counter).toBe(1)
|
|
26
|
+
})
|
|
19
27
|
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { change, getLoroDoc } from "./functional-helpers.js"
|
|
3
|
+
import { Shape } from "./shape.js"
|
|
4
|
+
import { createTypedDoc } from "./typed-doc.js"
|
|
5
|
+
|
|
6
|
+
const schema = Shape.doc({
|
|
7
|
+
title: Shape.text(),
|
|
8
|
+
count: Shape.counter(),
|
|
9
|
+
users: Shape.record(
|
|
10
|
+
Shape.plain.struct({
|
|
11
|
+
name: Shape.plain.string(),
|
|
12
|
+
}),
|
|
13
|
+
),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe("functional helpers", () => {
|
|
17
|
+
describe("change()", () => {
|
|
18
|
+
it("should batch multiple mutations into a single transaction", () => {
|
|
19
|
+
const doc = createTypedDoc(schema)
|
|
20
|
+
|
|
21
|
+
change(doc, draft => {
|
|
22
|
+
draft.title.insert(0, "Hello")
|
|
23
|
+
draft.count.increment(5)
|
|
24
|
+
draft.users.set("alice", { name: "Alice" })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
expect(doc.toJSON().title).toBe("Hello")
|
|
28
|
+
expect(doc.toJSON().count).toBe(5)
|
|
29
|
+
expect(doc.toJSON().users.alice).toEqual({ name: "Alice" })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("should return the doc for chaining", () => {
|
|
33
|
+
const doc = createTypedDoc(schema)
|
|
34
|
+
|
|
35
|
+
const result = change(doc, draft => {
|
|
36
|
+
draft.title.insert(0, "Test")
|
|
37
|
+
draft.count.increment(10)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// change() returns the doc for chaining
|
|
41
|
+
expect(result).toBe(doc)
|
|
42
|
+
expect(result.toJSON().title).toBe("Test")
|
|
43
|
+
expect(result.toJSON().count).toBe(10)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("should support chaining mutations", () => {
|
|
47
|
+
const doc = createTypedDoc(schema)
|
|
48
|
+
|
|
49
|
+
// Chain mutations after batch
|
|
50
|
+
change(doc, draft => {
|
|
51
|
+
draft.count.increment(5)
|
|
52
|
+
}).count.increment(3)
|
|
53
|
+
|
|
54
|
+
expect(doc.toJSON().count).toBe(8)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("should support fluent API with toJSON at the end", () => {
|
|
58
|
+
const doc = createTypedDoc(schema)
|
|
59
|
+
|
|
60
|
+
// Fluent API: change -> mutate -> toJSON
|
|
61
|
+
const json = change(doc, draft => {
|
|
62
|
+
draft.title.insert(0, "Hello")
|
|
63
|
+
}).toJSON()
|
|
64
|
+
|
|
65
|
+
expect(json.title).toBe("Hello")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("should commit all changes as one transaction", () => {
|
|
69
|
+
const doc = createTypedDoc(schema)
|
|
70
|
+
const loroDoc = getLoroDoc(doc)
|
|
71
|
+
|
|
72
|
+
const versionBefore = loroDoc.version()
|
|
73
|
+
|
|
74
|
+
change(doc, draft => {
|
|
75
|
+
draft.count.increment(1)
|
|
76
|
+
draft.count.increment(2)
|
|
77
|
+
draft.count.increment(3)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const versionAfter = loroDoc.version()
|
|
81
|
+
|
|
82
|
+
// Version should have changed (one commit)
|
|
83
|
+
expect(versionAfter).not.toEqual(versionBefore)
|
|
84
|
+
expect(doc.toJSON().count).toBe(6)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe("getLoroDoc()", () => {
|
|
89
|
+
it("should return the underlying LoroDoc", () => {
|
|
90
|
+
const doc = createTypedDoc(schema)
|
|
91
|
+
const loroDoc = getLoroDoc(doc)
|
|
92
|
+
|
|
93
|
+
expect(loroDoc).toBeDefined()
|
|
94
|
+
expect(typeof loroDoc.version).toBe("function")
|
|
95
|
+
expect(typeof loroDoc.subscribe).toBe("function")
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("should return the same LoroDoc as doc.$.loroDoc", () => {
|
|
99
|
+
const doc = createTypedDoc(schema)
|
|
100
|
+
|
|
101
|
+
expect(getLoroDoc(doc)).toBe(doc.$.loroDoc)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe("doc.toJSON()", () => {
|
|
106
|
+
it("should work directly on the doc", () => {
|
|
107
|
+
const doc = createTypedDoc(schema)
|
|
108
|
+
|
|
109
|
+
doc.title.insert(0, "Hello")
|
|
110
|
+
doc.count.increment(5)
|
|
111
|
+
|
|
112
|
+
const json = doc.toJSON()
|
|
113
|
+
|
|
114
|
+
expect(json.title).toBe("Hello")
|
|
115
|
+
expect(json.count).toBe(5)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("should work on refs", () => {
|
|
119
|
+
const doc = createTypedDoc(schema)
|
|
120
|
+
|
|
121
|
+
doc.users.set("alice", { name: "Alice" })
|
|
122
|
+
doc.users.set("bob", { name: "Bob" })
|
|
123
|
+
|
|
124
|
+
// toJSON on the record ref
|
|
125
|
+
const usersJson = doc.users.toJSON()
|
|
126
|
+
expect(usersJson).toEqual({
|
|
127
|
+
alice: { name: "Alice" },
|
|
128
|
+
bob: { name: "Bob" },
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// toJSON on counter ref
|
|
132
|
+
doc.count.increment(10)
|
|
133
|
+
expect(doc.count.toJSON()).toBe(10)
|
|
134
|
+
|
|
135
|
+
// toJSON on text ref
|
|
136
|
+
doc.title.insert(0, "Test")
|
|
137
|
+
expect(doc.title.toJSON()).toBe("Test")
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("should be equivalent to doc.toJSON()", () => {
|
|
141
|
+
const doc = createTypedDoc(schema)
|
|
142
|
+
|
|
143
|
+
doc.title.insert(0, "Hello")
|
|
144
|
+
doc.count.increment(5)
|
|
145
|
+
|
|
146
|
+
expect(doc.toJSON()).toEqual(doc.toJSON())
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { LoroDoc } from "loro-crdt"
|
|
2
|
+
import type { DocShape } from "./shape.js"
|
|
3
|
+
import type { TypedDoc } from "./typed-doc.js"
|
|
4
|
+
import type { Mutable } from "./types.js"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The primary method of mutating typed documents.
|
|
8
|
+
* Batches multiple mutations into a single transaction.
|
|
9
|
+
* All changes commit together at the end.
|
|
10
|
+
*
|
|
11
|
+
* Use this for:
|
|
12
|
+
* - Find-and-mutate operations (required due to JS limitations)
|
|
13
|
+
* - Performance (fewer commits)
|
|
14
|
+
* - Atomic undo (all changes = one undo step)
|
|
15
|
+
*
|
|
16
|
+
* Returns the doc for chaining.
|
|
17
|
+
*
|
|
18
|
+
* @param doc - The TypedDoc to mutate
|
|
19
|
+
* @param fn - Function that performs mutations on the draft
|
|
20
|
+
* @returns The same TypedDoc for chaining
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* import { change } from "@loro-extended/change"
|
|
25
|
+
*
|
|
26
|
+
* // Chainable API
|
|
27
|
+
* change(doc, draft => {
|
|
28
|
+
* draft.count.increment(10)
|
|
29
|
+
* draft.title.update("Hello")
|
|
30
|
+
* })
|
|
31
|
+
* .count.increment(5) // Optional: continue mutating
|
|
32
|
+
* .toJSON() // Optional: get last item snapshot when needed
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function change<Shape extends DocShape>(
|
|
36
|
+
doc: TypedDoc<Shape>,
|
|
37
|
+
fn: (draft: Mutable<Shape>) => void,
|
|
38
|
+
): TypedDoc<Shape> {
|
|
39
|
+
return doc.$.change(fn)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Access the underlying LoroDoc for advanced operations.
|
|
44
|
+
*
|
|
45
|
+
* @param doc - The TypedDoc to unwrap
|
|
46
|
+
* @returns The underlying LoroDoc instance
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```typescript
|
|
50
|
+
* import { getLoroDoc } from "@loro-extended/change"
|
|
51
|
+
*
|
|
52
|
+
* const loroDoc = getLoroDoc(doc)
|
|
53
|
+
* const version = loroDoc.version()
|
|
54
|
+
* loroDoc.subscribe(() => console.log("changed"))
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export function getLoroDoc<Shape extends DocShape>(
|
|
58
|
+
doc: TypedDoc<Shape>,
|
|
59
|
+
): LoroDoc {
|
|
60
|
+
return doc.$.loroDoc
|
|
61
|
+
}
|