@loro-extended/change 0.9.1 → 1.0.1
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 +201 -93
- package/dist/index.d.ts +361 -169
- package/dist/index.js +516 -235
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/change.test.ts +180 -175
- package/src/conversion.test.ts +19 -19
- package/src/conversion.ts +7 -7
- 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 +23 -22
- package/src/overlay.ts +9 -9
- 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 +23 -1
- package/src/typed-refs/counter.test.ts +44 -13
- package/src/typed-refs/counter.ts +40 -3
- package/src/typed-refs/doc.ts +12 -6
- package/src/typed-refs/json-compatibility.test.ts +37 -32
- package/src/typed-refs/list-base.ts +26 -22
- 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 +4 -1
- package/src/typed-refs/proxy-handlers.ts +14 -1
- package/src/typed-refs/record.test.ts +107 -42
- package/src/typed-refs/record.ts +37 -19
- package/src/typed-refs/{map.ts → struct.ts} +31 -16
- package/src/typed-refs/text.ts +42 -1
- package/src/typed-refs/utils.ts +28 -6
- 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.test.ts
CHANGED
|
@@ -298,7 +298,7 @@ describe("Conversion Functions", () => {
|
|
|
298
298
|
|
|
299
299
|
describe("convertInputToNode - Map Conversion", () => {
|
|
300
300
|
it("should convert object to LoroMap with value properties", () => {
|
|
301
|
-
const shape = Shape.
|
|
301
|
+
const shape = Shape.struct({
|
|
302
302
|
name: Shape.plain.string(),
|
|
303
303
|
age: Shape.plain.number(),
|
|
304
304
|
active: Shape.plain.boolean(),
|
|
@@ -322,7 +322,7 @@ describe("Conversion Functions", () => {
|
|
|
322
322
|
})
|
|
323
323
|
|
|
324
324
|
it("should convert object to LoroMap with container properties", () => {
|
|
325
|
-
const shape = Shape.
|
|
325
|
+
const shape = Shape.struct({
|
|
326
326
|
title: Shape.text(),
|
|
327
327
|
count: Shape.counter(),
|
|
328
328
|
})
|
|
@@ -343,7 +343,7 @@ describe("Conversion Functions", () => {
|
|
|
343
343
|
})
|
|
344
344
|
|
|
345
345
|
it("should handle empty object", () => {
|
|
346
|
-
const shape = Shape.
|
|
346
|
+
const shape = Shape.struct({})
|
|
347
347
|
const result = convertInputToRef({}, shape)
|
|
348
348
|
|
|
349
349
|
expect(isLoroMap(result as any)).toBe(true)
|
|
@@ -352,7 +352,7 @@ describe("Conversion Functions", () => {
|
|
|
352
352
|
})
|
|
353
353
|
|
|
354
354
|
it("should handle object with extra properties not in schema", () => {
|
|
355
|
-
const shape = Shape.
|
|
355
|
+
const shape = Shape.struct({
|
|
356
356
|
name: Shape.plain.string(),
|
|
357
357
|
})
|
|
358
358
|
|
|
@@ -371,10 +371,10 @@ describe("Conversion Functions", () => {
|
|
|
371
371
|
})
|
|
372
372
|
|
|
373
373
|
it("should handle nested map structures", () => {
|
|
374
|
-
const shape = Shape.
|
|
375
|
-
user: Shape.
|
|
374
|
+
const shape = Shape.struct({
|
|
375
|
+
user: Shape.struct({
|
|
376
376
|
name: Shape.plain.string(),
|
|
377
|
-
profile: Shape.
|
|
377
|
+
profile: Shape.struct({
|
|
378
378
|
bio: Shape.text(),
|
|
379
379
|
}),
|
|
380
380
|
}),
|
|
@@ -398,7 +398,7 @@ describe("Conversion Functions", () => {
|
|
|
398
398
|
})
|
|
399
399
|
|
|
400
400
|
it("should return plain object for value shape", () => {
|
|
401
|
-
const shape = Shape.plain.
|
|
401
|
+
const shape = Shape.plain.struct({
|
|
402
402
|
name: Shape.plain.string(),
|
|
403
403
|
age: Shape.plain.number(),
|
|
404
404
|
})
|
|
@@ -412,7 +412,7 @@ describe("Conversion Functions", () => {
|
|
|
412
412
|
})
|
|
413
413
|
|
|
414
414
|
it("should throw error for non-object input", () => {
|
|
415
|
-
const shape = Shape.
|
|
415
|
+
const shape = Shape.struct({
|
|
416
416
|
name: Shape.plain.string(),
|
|
417
417
|
})
|
|
418
418
|
|
|
@@ -446,7 +446,7 @@ describe("Conversion Functions", () => {
|
|
|
446
446
|
|
|
447
447
|
it("should handle complex value shapes", () => {
|
|
448
448
|
const arrayShape = Shape.plain.array(Shape.plain.string())
|
|
449
|
-
const objectShape = Shape.plain.
|
|
449
|
+
const objectShape = Shape.plain.struct({
|
|
450
450
|
name: Shape.plain.string(),
|
|
451
451
|
count: Shape.plain.number(),
|
|
452
452
|
})
|
|
@@ -470,7 +470,7 @@ describe("Conversion Functions", () => {
|
|
|
470
470
|
|
|
471
471
|
describe("convertInputToNode - Error Cases", () => {
|
|
472
472
|
it("should throw error for tree type (unimplemented)", () => {
|
|
473
|
-
const shape = Shape.tree(Shape.
|
|
473
|
+
const shape = Shape.tree(Shape.struct({}))
|
|
474
474
|
|
|
475
475
|
expect(() => convertInputToRef({}, shape)).toThrow(
|
|
476
476
|
"tree type unimplemented",
|
|
@@ -489,9 +489,9 @@ describe("Conversion Functions", () => {
|
|
|
489
489
|
describe("convertInputToNode - Complex Nested Structures", () => {
|
|
490
490
|
it("should handle deeply nested container structures", () => {
|
|
491
491
|
const shape = Shape.list(
|
|
492
|
-
Shape.
|
|
492
|
+
Shape.struct({
|
|
493
493
|
title: Shape.text(),
|
|
494
|
-
metadata: Shape.
|
|
494
|
+
metadata: Shape.struct({
|
|
495
495
|
views: Shape.counter(),
|
|
496
496
|
tags: Shape.list(Shape.plain.string()),
|
|
497
497
|
}),
|
|
@@ -523,12 +523,12 @@ describe("Conversion Functions", () => {
|
|
|
523
523
|
})
|
|
524
524
|
|
|
525
525
|
it("should handle mixed container and value types", () => {
|
|
526
|
-
const shape = Shape.
|
|
526
|
+
const shape = Shape.struct({
|
|
527
527
|
plainString: Shape.plain.string(),
|
|
528
528
|
plainArray: Shape.plain.array(Shape.plain.number()),
|
|
529
529
|
loroText: Shape.text(),
|
|
530
530
|
loroList: Shape.list(Shape.plain.string()),
|
|
531
|
-
nestedMap: Shape.
|
|
531
|
+
nestedMap: Shape.struct({
|
|
532
532
|
counter: Shape.counter(),
|
|
533
533
|
plainBool: Shape.plain.boolean(),
|
|
534
534
|
}),
|
|
@@ -573,7 +573,7 @@ describe("Conversion Functions", () => {
|
|
|
573
573
|
|
|
574
574
|
it("should handle movable lists with complex items", () => {
|
|
575
575
|
const shape = Shape.movableList(
|
|
576
|
-
Shape.
|
|
576
|
+
Shape.struct({
|
|
577
577
|
id: Shape.plain.string(),
|
|
578
578
|
title: Shape.text(),
|
|
579
579
|
completed: Shape.plain.boolean(),
|
|
@@ -604,7 +604,7 @@ describe("Conversion Functions", () => {
|
|
|
604
604
|
|
|
605
605
|
it("should handle empty containers", () => {
|
|
606
606
|
const emptyListShape = Shape.list(Shape.plain.string())
|
|
607
|
-
const emptyMapShape = Shape.
|
|
607
|
+
const emptyMapShape = Shape.struct({})
|
|
608
608
|
const emptyMovableListShape = Shape.movableList(Shape.plain.number())
|
|
609
609
|
|
|
610
610
|
const emptyList = convertInputToRef([], emptyListShape)
|
|
@@ -662,7 +662,7 @@ describe("Conversion Functions", () => {
|
|
|
662
662
|
input[`prop${i}`] = `value${i}`
|
|
663
663
|
}
|
|
664
664
|
|
|
665
|
-
const shape = Shape.
|
|
665
|
+
const shape = Shape.struct(shapes)
|
|
666
666
|
const result = convertInputToRef(input, shape)
|
|
667
667
|
|
|
668
668
|
expect(isLoroMap(result as any)).toBe(true)
|
|
@@ -708,7 +708,7 @@ describe("Conversion Functions", () => {
|
|
|
708
708
|
|
|
709
709
|
it("should handle recursive structures without infinite loops", () => {
|
|
710
710
|
// Test that the conversion doesn't get stuck in infinite recursion
|
|
711
|
-
const shape = Shape.
|
|
711
|
+
const shape = Shape.struct({
|
|
712
712
|
name: Shape.plain.string(),
|
|
713
713
|
children: Shape.list(Shape.plain.string()), // Not recursive, but nested
|
|
714
714
|
})
|
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,
|
|
@@ -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
|
|
@@ -186,12 +186,12 @@ export function convertInputToRef<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
|
+
})
|