@loro-extended/change 0.5.0 → 0.7.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 +71 -0
- package/dist/index.d.ts +121 -22
- package/dist/index.js +130 -20
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/change.test.ts +38 -6
- package/src/change.ts +14 -5
- package/src/discriminated-union.test.ts +246 -0
- package/src/draft-nodes/map.ts +3 -2
- package/src/index.ts +7 -1
- package/src/overlay.ts +55 -1
- package/src/record.test.ts +2 -1
- package/src/shape.ts +136 -19
- package/src/types.test.ts +169 -0
- package/src/types.ts +44 -2
- package/src/utils/type-guards.ts +1 -0
- package/src/validation.ts +33 -0
package/src/change.test.ts
CHANGED
|
@@ -801,7 +801,7 @@ describe("TypedLoroDoc", () => {
|
|
|
801
801
|
const emptyState = {
|
|
802
802
|
title: "Default Title",
|
|
803
803
|
count: 0,
|
|
804
|
-
items: [
|
|
804
|
+
items: [],
|
|
805
805
|
}
|
|
806
806
|
|
|
807
807
|
const typedDoc = createTypedDoc(schema, emptyState)
|
|
@@ -809,7 +809,7 @@ describe("TypedLoroDoc", () => {
|
|
|
809
809
|
expect(typedDoc.value).toEqual({
|
|
810
810
|
title: "Default Title",
|
|
811
811
|
count: 0,
|
|
812
|
-
items: [
|
|
812
|
+
items: [],
|
|
813
813
|
})
|
|
814
814
|
})
|
|
815
815
|
|
|
@@ -823,7 +823,7 @@ describe("TypedLoroDoc", () => {
|
|
|
823
823
|
const emptyState = {
|
|
824
824
|
title: "Default Title",
|
|
825
825
|
count: 0,
|
|
826
|
-
items: [
|
|
826
|
+
items: [],
|
|
827
827
|
}
|
|
828
828
|
|
|
829
829
|
const typedDoc = createTypedDoc(schema, emptyState)
|
|
@@ -835,7 +835,7 @@ describe("TypedLoroDoc", () => {
|
|
|
835
835
|
|
|
836
836
|
expect(result.title).toBe("Hello World")
|
|
837
837
|
expect(result.count).toBe(5)
|
|
838
|
-
expect(result.items).toEqual([
|
|
838
|
+
expect(result.items).toEqual([]) // Empty state preserved
|
|
839
839
|
})
|
|
840
840
|
|
|
841
841
|
it("should handle nested empty state structures", () => {
|
|
@@ -855,7 +855,7 @@ describe("TypedLoroDoc", () => {
|
|
|
855
855
|
title: "Default Title",
|
|
856
856
|
metadata: {
|
|
857
857
|
views: 0,
|
|
858
|
-
tags: [
|
|
858
|
+
tags: [],
|
|
859
859
|
author: "Anonymous",
|
|
860
860
|
},
|
|
861
861
|
},
|
|
@@ -873,7 +873,7 @@ describe("TypedLoroDoc", () => {
|
|
|
873
873
|
|
|
874
874
|
expect(result.article.title).toBe("New Title")
|
|
875
875
|
expect(result.article.metadata.views).toBe(10)
|
|
876
|
-
expect(result.article.metadata.tags).toEqual([
|
|
876
|
+
expect(result.article.metadata.tags).toEqual([]) // Preserved
|
|
877
877
|
expect(result.article.metadata.author).toBe("John Doe")
|
|
878
878
|
})
|
|
879
879
|
|
|
@@ -973,6 +973,38 @@ describe("TypedLoroDoc", () => {
|
|
|
973
973
|
createTypedDoc(schema, invalidEmptyState as any)
|
|
974
974
|
}).toThrow()
|
|
975
975
|
})
|
|
976
|
+
it("should handle null values in empty state correctly", () => {
|
|
977
|
+
const schema = Shape.doc({
|
|
978
|
+
interjection: Shape.map({
|
|
979
|
+
currentPrediction: Shape.plain.union([
|
|
980
|
+
Shape.plain.string(),
|
|
981
|
+
Shape.plain.null(),
|
|
982
|
+
]),
|
|
983
|
+
}),
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
const emptyState = {
|
|
987
|
+
interjection: {
|
|
988
|
+
currentPrediction: null,
|
|
989
|
+
},
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
993
|
+
|
|
994
|
+
// This should not throw "empty state required"
|
|
995
|
+
expect(() => {
|
|
996
|
+
typedDoc.change(draft => {
|
|
997
|
+
// Accessing the property triggers getOrCreateNode
|
|
998
|
+
const current = draft.interjection.currentPrediction
|
|
999
|
+
expect(current).toBeNull()
|
|
1000
|
+
|
|
1001
|
+
// Verify we can update it
|
|
1002
|
+
draft.interjection.currentPrediction = "new value"
|
|
1003
|
+
})
|
|
1004
|
+
}).not.toThrow()
|
|
1005
|
+
|
|
1006
|
+
expect(typedDoc.value.interjection.currentPrediction).toBe("new value")
|
|
1007
|
+
})
|
|
976
1008
|
})
|
|
977
1009
|
|
|
978
1010
|
describe("Multiple Changes", () => {
|
package/src/change.ts
CHANGED
|
@@ -10,14 +10,23 @@ import {
|
|
|
10
10
|
} from "./json-patch.js"
|
|
11
11
|
import { overlayEmptyState } from "./overlay.js"
|
|
12
12
|
import type { DocShape } from "./shape.js"
|
|
13
|
-
import type { Draft, InferPlainType } from "./types.js"
|
|
13
|
+
import type { Draft, InferEmptyStateType, InferPlainType } from "./types.js"
|
|
14
14
|
import { validateEmptyState } from "./validation.js"
|
|
15
15
|
|
|
16
16
|
// Core TypedDoc abstraction around LoroDoc
|
|
17
17
|
export class TypedDoc<Shape extends DocShape> {
|
|
18
|
+
/**
|
|
19
|
+
* Creates a new TypedDoc with the given schema and empty state.
|
|
20
|
+
*
|
|
21
|
+
* @param shape - The document schema
|
|
22
|
+
* @param emptyState - Default values for the document. For dynamic containers
|
|
23
|
+
* (list, record, etc.), only empty values ([] or {}) are allowed. Use
|
|
24
|
+
* `.change()` to add initial data after construction.
|
|
25
|
+
* @param doc - Optional existing LoroDoc to wrap
|
|
26
|
+
*/
|
|
18
27
|
constructor(
|
|
19
28
|
private shape: Shape,
|
|
20
|
-
private emptyState:
|
|
29
|
+
private emptyState: InferEmptyStateType<Shape>,
|
|
21
30
|
private doc: LoroDoc = new LoroDoc(),
|
|
22
31
|
) {
|
|
23
32
|
validateEmptyState(emptyState, shape)
|
|
@@ -28,7 +37,7 @@ export class TypedDoc<Shape extends DocShape> {
|
|
|
28
37
|
return overlayEmptyState(
|
|
29
38
|
this.shape,
|
|
30
39
|
crdtValue,
|
|
31
|
-
this.emptyState,
|
|
40
|
+
this.emptyState as any,
|
|
32
41
|
) as InferPlainType<Shape>
|
|
33
42
|
}
|
|
34
43
|
|
|
@@ -36,7 +45,7 @@ export class TypedDoc<Shape extends DocShape> {
|
|
|
36
45
|
// Reuse existing DocumentDraft system with empty state integration
|
|
37
46
|
const draft = new DraftDoc({
|
|
38
47
|
shape: this.shape,
|
|
39
|
-
emptyState: this.emptyState,
|
|
48
|
+
emptyState: this.emptyState as any,
|
|
40
49
|
doc: this.doc,
|
|
41
50
|
})
|
|
42
51
|
fn(draft as unknown as Draft<Shape>)
|
|
@@ -98,7 +107,7 @@ export class TypedDoc<Shape extends DocShape> {
|
|
|
98
107
|
// Factory function for TypedLoroDoc
|
|
99
108
|
export function createTypedDoc<Shape extends DocShape>(
|
|
100
109
|
shape: Shape,
|
|
101
|
-
emptyState:
|
|
110
|
+
emptyState: InferEmptyStateType<Shape>,
|
|
102
111
|
existingDoc?: LoroDoc,
|
|
103
112
|
): TypedDoc<Shape> {
|
|
104
113
|
return new TypedDoc<Shape>(shape, emptyState, existingDoc || new LoroDoc())
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { TypedDoc } from "./change.js"
|
|
3
|
+
import { mergeValue } from "./overlay.js"
|
|
4
|
+
import { Shape } from "./shape.js"
|
|
5
|
+
import { validateValue } from "./validation.js"
|
|
6
|
+
|
|
7
|
+
describe("discriminatedUnion", () => {
|
|
8
|
+
// Define variant shapes
|
|
9
|
+
const ClientPresenceShape = Shape.plain.object({
|
|
10
|
+
type: Shape.plain.string("client"),
|
|
11
|
+
name: Shape.plain.string(),
|
|
12
|
+
input: Shape.plain.object({
|
|
13
|
+
force: Shape.plain.number(),
|
|
14
|
+
angle: Shape.plain.number(),
|
|
15
|
+
}),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const ServerPresenceShape = Shape.plain.object({
|
|
19
|
+
type: Shape.plain.string("server"),
|
|
20
|
+
cars: Shape.plain.record(
|
|
21
|
+
Shape.plain.object({
|
|
22
|
+
x: Shape.plain.number(),
|
|
23
|
+
y: Shape.plain.number(),
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
26
|
+
tick: Shape.plain.number(),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const GamePresenceSchema = Shape.plain.discriminatedUnion("type", {
|
|
30
|
+
client: ClientPresenceShape,
|
|
31
|
+
server: ServerPresenceShape,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const EmptyClientPresence = {
|
|
35
|
+
type: "client" as const,
|
|
36
|
+
name: "",
|
|
37
|
+
input: { force: 0, angle: 0 },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const EmptyServerPresence = {
|
|
41
|
+
type: "server" as const,
|
|
42
|
+
cars: {},
|
|
43
|
+
tick: 0,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
it("should create a discriminated union shape", () => {
|
|
47
|
+
expect(GamePresenceSchema._type).toBe("value")
|
|
48
|
+
expect(GamePresenceSchema.valueType).toBe("discriminatedUnion")
|
|
49
|
+
expect(GamePresenceSchema.discriminantKey).toBe("type")
|
|
50
|
+
expect(GamePresenceSchema.variants).toHaveProperty("client")
|
|
51
|
+
expect(GamePresenceSchema.variants).toHaveProperty("server")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("should merge client variant with defaults", () => {
|
|
55
|
+
const crdtValue = {
|
|
56
|
+
type: "client",
|
|
57
|
+
name: "Alice",
|
|
58
|
+
// input is missing - should use defaults
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const result = mergeValue(
|
|
62
|
+
GamePresenceSchema,
|
|
63
|
+
crdtValue,
|
|
64
|
+
EmptyClientPresence,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
expect(result).toEqual({
|
|
68
|
+
type: "client",
|
|
69
|
+
name: "Alice",
|
|
70
|
+
input: { force: 0, angle: 0 },
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("should merge server variant with defaults", () => {
|
|
75
|
+
const crdtValue = {
|
|
76
|
+
type: "server",
|
|
77
|
+
cars: {
|
|
78
|
+
"peer-1": { x: 100, y: 200 },
|
|
79
|
+
},
|
|
80
|
+
// tick is missing - should use defaults
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const result = mergeValue(
|
|
84
|
+
GamePresenceSchema,
|
|
85
|
+
crdtValue,
|
|
86
|
+
EmptyServerPresence,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
expect(result).toEqual({
|
|
90
|
+
type: "server",
|
|
91
|
+
cars: {
|
|
92
|
+
"peer-1": { x: 100, y: 200 },
|
|
93
|
+
},
|
|
94
|
+
tick: 0,
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("should use empty state discriminant when CRDT has no discriminant", () => {
|
|
99
|
+
const crdtValue = {
|
|
100
|
+
// No type field
|
|
101
|
+
name: "Bob",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const result = mergeValue(
|
|
105
|
+
GamePresenceSchema,
|
|
106
|
+
crdtValue,
|
|
107
|
+
EmptyClientPresence,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
// Should use client variant based on emptyState's type
|
|
111
|
+
expect(result).toEqual({
|
|
112
|
+
type: "client",
|
|
113
|
+
name: "Bob",
|
|
114
|
+
input: { force: 0, angle: 0 },
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("should return empty state when no discriminant is available", () => {
|
|
119
|
+
const crdtValue = undefined
|
|
120
|
+
const emptyValue = EmptyClientPresence
|
|
121
|
+
|
|
122
|
+
const result = mergeValue(GamePresenceSchema, crdtValue, emptyValue)
|
|
123
|
+
|
|
124
|
+
expect(result).toEqual(EmptyClientPresence)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("should handle nested object merging within variants", () => {
|
|
128
|
+
const crdtValue = {
|
|
129
|
+
type: "client",
|
|
130
|
+
name: "Charlie",
|
|
131
|
+
input: {
|
|
132
|
+
force: 0.5,
|
|
133
|
+
// angle is missing
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const result = mergeValue(
|
|
138
|
+
GamePresenceSchema,
|
|
139
|
+
crdtValue,
|
|
140
|
+
EmptyClientPresence,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
expect(result).toEqual({
|
|
144
|
+
type: "client",
|
|
145
|
+
name: "Charlie",
|
|
146
|
+
input: {
|
|
147
|
+
force: 0.5,
|
|
148
|
+
angle: 0, // Default from empty state
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("should preserve full CRDT values when all fields are present", () => {
|
|
154
|
+
const crdtValue = {
|
|
155
|
+
type: "server",
|
|
156
|
+
cars: {
|
|
157
|
+
"peer-1": { x: 50, y: 75 },
|
|
158
|
+
"peer-2": { x: 200, y: 300 },
|
|
159
|
+
},
|
|
160
|
+
tick: 42,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const result = mergeValue(
|
|
164
|
+
GamePresenceSchema,
|
|
165
|
+
crdtValue,
|
|
166
|
+
EmptyServerPresence,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
expect(result).toEqual(crdtValue)
|
|
170
|
+
})
|
|
171
|
+
describe("validation", () => {
|
|
172
|
+
it("should validate a correct value for client variant", () => {
|
|
173
|
+
const value = {
|
|
174
|
+
type: "client",
|
|
175
|
+
name: "Alice",
|
|
176
|
+
input: { force: 1, angle: 90 },
|
|
177
|
+
}
|
|
178
|
+
expect(validateValue(value, GamePresenceSchema)).toEqual(value)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("should validate a correct value for server variant", () => {
|
|
182
|
+
const value = {
|
|
183
|
+
type: "server",
|
|
184
|
+
cars: { p1: { x: 10, y: 20 } },
|
|
185
|
+
tick: 100,
|
|
186
|
+
}
|
|
187
|
+
expect(validateValue(value, GamePresenceSchema)).toEqual(value)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it("should throw an error for an invalid discriminant value", () => {
|
|
191
|
+
const value = { type: "unknown", name: "Bob" }
|
|
192
|
+
expect(() => validateValue(value, GamePresenceSchema)).toThrow(
|
|
193
|
+
'Invalid discriminant value "unknown" at path root. Expected one of: client, server',
|
|
194
|
+
)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("should throw an error for a missing discriminant key", () => {
|
|
198
|
+
const value = { name: "Bob" }
|
|
199
|
+
expect(() => validateValue(value, GamePresenceSchema)).toThrow(
|
|
200
|
+
'Expected string for discriminant key "type" at path root, got undefined',
|
|
201
|
+
)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it("should throw an error if the value does not match the variant schema", () => {
|
|
205
|
+
const value = {
|
|
206
|
+
type: "client",
|
|
207
|
+
name: "Alice",
|
|
208
|
+
input: { force: "invalid", angle: 90 }, // force should be number
|
|
209
|
+
}
|
|
210
|
+
expect(() => validateValue(value, GamePresenceSchema)).toThrow(
|
|
211
|
+
"Expected number at path root.input.force, got string",
|
|
212
|
+
)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
describe("TypedDoc integration", () => {
|
|
216
|
+
it("should allow setting a discriminated union property in a MapDraftNode", () => {
|
|
217
|
+
const DocSchema = Shape.doc({
|
|
218
|
+
state: Shape.map({
|
|
219
|
+
presence: GamePresenceSchema,
|
|
220
|
+
}),
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
const doc = new TypedDoc(DocSchema, {
|
|
224
|
+
state: {
|
|
225
|
+
presence: EmptyClientPresence,
|
|
226
|
+
},
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
doc.change(draft => {
|
|
230
|
+
// This should work now that MapDraftNode recognizes discriminatedUnion as a value shape
|
|
231
|
+
draft.state.presence = {
|
|
232
|
+
type: "server",
|
|
233
|
+
cars: { p1: { x: 10, y: 20 } },
|
|
234
|
+
tick: 100,
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
expect(doc.value.state.presence).toEqual({
|
|
239
|
+
type: "server",
|
|
240
|
+
cars: { p1: { x: 10, y: 20 } },
|
|
241
|
+
tick: 100,
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
})
|
package/src/draft-nodes/map.ts
CHANGED
|
@@ -92,13 +92,13 @@ export class MapDraftNode<
|
|
|
92
92
|
} else {
|
|
93
93
|
// Only fall back to empty state if the container doesn't have the value
|
|
94
94
|
const emptyState = (this.emptyState as any)?.[key]
|
|
95
|
-
if (
|
|
95
|
+
if (emptyState === undefined) {
|
|
96
96
|
throw new Error("empty state required")
|
|
97
97
|
}
|
|
98
98
|
node = emptyState as Value
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
-
if (
|
|
101
|
+
if (node === undefined) throw new Error("no container made")
|
|
102
102
|
this.propertyCache.set(key, node)
|
|
103
103
|
}
|
|
104
104
|
|
|
@@ -114,6 +114,7 @@ export class MapDraftNode<
|
|
|
114
114
|
? value => {
|
|
115
115
|
// console.log("set value", value)
|
|
116
116
|
this.container.set(key, value)
|
|
117
|
+
this.propertyCache.set(key, value)
|
|
117
118
|
}
|
|
118
119
|
: undefined,
|
|
119
120
|
})
|
package/src/index.ts
CHANGED
|
@@ -8,15 +8,19 @@ export type {
|
|
|
8
8
|
ContainerType as RootContainerType,
|
|
9
9
|
// Container shapes
|
|
10
10
|
CounterContainerShape,
|
|
11
|
+
// Discriminated union for tagged unions
|
|
12
|
+
DiscriminatedUnionValueShape,
|
|
11
13
|
// Schema node types
|
|
12
14
|
DocShape,
|
|
13
15
|
ListContainerShape,
|
|
14
16
|
MapContainerShape,
|
|
15
17
|
MovableListContainerShape,
|
|
18
|
+
ObjectValueShape,
|
|
16
19
|
RecordContainerShape,
|
|
17
20
|
RecordValueShape,
|
|
18
21
|
TextContainerShape,
|
|
19
22
|
TreeContainerShape,
|
|
23
|
+
UnionValueShape,
|
|
20
24
|
// Value shapes
|
|
21
25
|
ValueShape,
|
|
22
26
|
// ...
|
|
@@ -25,8 +29,10 @@ export type {
|
|
|
25
29
|
export { Shape } from "./shape.js"
|
|
26
30
|
export type {
|
|
27
31
|
Draft,
|
|
32
|
+
// Type inference - Infer<T> is the recommended unified helper
|
|
33
|
+
Infer,
|
|
28
34
|
InferDraftType,
|
|
29
|
-
|
|
35
|
+
InferEmptyStateType,
|
|
30
36
|
InferPlainType,
|
|
31
37
|
} from "./types.js"
|
|
32
38
|
// Utility exports
|
package/src/overlay.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Value } from "loro-crdt"
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
ContainerShape,
|
|
4
|
+
DiscriminatedUnionValueShape,
|
|
5
|
+
DocShape,
|
|
6
|
+
ValueShape,
|
|
7
|
+
} from "./shape.js"
|
|
3
8
|
import { isObjectValue } from "./utils/type-guards.js"
|
|
4
9
|
|
|
5
10
|
/**
|
|
@@ -101,6 +106,55 @@ export function mergeValue<Shape extends ContainerShape | ValueShape>(
|
|
|
101
106
|
}
|
|
102
107
|
return result
|
|
103
108
|
}
|
|
109
|
+
|
|
110
|
+
// Handle discriminated unions
|
|
111
|
+
if (shape._type === "value" && shape.valueType === "discriminatedUnion") {
|
|
112
|
+
return mergeDiscriminatedUnion(
|
|
113
|
+
shape as DiscriminatedUnionValueShape,
|
|
114
|
+
crdtValue,
|
|
115
|
+
emptyValue,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
104
119
|
return crdtValue ?? emptyValue
|
|
105
120
|
}
|
|
106
121
|
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Merges a discriminated union value by determining the variant from the discriminant key.
|
|
125
|
+
* Uses the emptyValue's discriminant to determine the default variant when the discriminant is missing.
|
|
126
|
+
*/
|
|
127
|
+
function mergeDiscriminatedUnion(
|
|
128
|
+
shape: DiscriminatedUnionValueShape,
|
|
129
|
+
crdtValue: Value,
|
|
130
|
+
emptyValue: Value,
|
|
131
|
+
): Value {
|
|
132
|
+
const crdtObj = (crdtValue as Record<string, Value>) ?? {}
|
|
133
|
+
const emptyObj = (emptyValue as Record<string, Value>) ?? {}
|
|
134
|
+
|
|
135
|
+
// Get the discriminant value from CRDT, falling back to empty state
|
|
136
|
+
const discriminantValue =
|
|
137
|
+
crdtObj[shape.discriminantKey] ?? emptyObj[shape.discriminantKey]
|
|
138
|
+
|
|
139
|
+
if (typeof discriminantValue !== "string") {
|
|
140
|
+
// If no valid discriminant, return the empty state
|
|
141
|
+
return emptyValue
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Find the variant shape for this discriminant value
|
|
145
|
+
const variantShape = shape.variants[discriminantValue]
|
|
146
|
+
|
|
147
|
+
if (!variantShape) {
|
|
148
|
+
// Unknown variant - return CRDT value or empty
|
|
149
|
+
return crdtValue ?? emptyValue
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Merge using the variant's object shape
|
|
153
|
+
// If the empty state's discriminant doesn't match the current discriminant,
|
|
154
|
+
// we shouldn't use the empty state for merging as it belongs to a different variant.
|
|
155
|
+
const emptyDiscriminant = emptyObj[shape.discriminantKey]
|
|
156
|
+
const effectiveEmptyValue =
|
|
157
|
+
emptyDiscriminant === discriminantValue ? emptyValue : undefined
|
|
158
|
+
|
|
159
|
+
return mergeValue(variantShape, crdtValue, effectiveEmptyValue as Value)
|
|
160
|
+
}
|
package/src/record.test.ts
CHANGED
|
@@ -108,7 +108,8 @@ describe("Record Types", () => {
|
|
|
108
108
|
}),
|
|
109
109
|
})
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
// Empty state must use empty record - add initial data via change()
|
|
112
|
+
const doc = new TypedDoc(schema, { wrapper: { stats: {} } })
|
|
112
113
|
|
|
113
114
|
doc.change(draft => {
|
|
114
115
|
draft.wrapper.stats.visits = 100
|