@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.
@@ -801,7 +801,7 @@ describe("TypedLoroDoc", () => {
801
801
  const emptyState = {
802
802
  title: "Default Title",
803
803
  count: 0,
804
- items: ["default"],
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: ["default"],
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: ["default"],
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(["default"]) // Empty state preserved
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: ["default-tag"],
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(["default-tag"]) // Preserved
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: InferPlainType<Shape>,
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: InferPlainType<Shape>,
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
+ })
@@ -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 (!emptyState) {
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 (!node) throw new Error("no container made")
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
- // Type inference
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 { ContainerShape, DocShape, ValueShape } from "./shape.js"
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
+ }
@@ -108,7 +108,8 @@ describe("Record Types", () => {
108
108
  }),
109
109
  })
110
110
 
111
- const doc = new TypedDoc(schema, { wrapper: { stats: { visits: 0 } } })
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