@loro-extended/change 0.4.0 → 0.6.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/src/shape.ts CHANGED
@@ -16,10 +16,11 @@ import type { MovableListDraftNode } from "./draft-nodes/movable-list.js"
16
16
  import type { RecordDraftNode } from "./draft-nodes/record.js"
17
17
  import type { TextDraftNode } from "./draft-nodes/text.js"
18
18
 
19
- export interface Shape<Plain, Draft> {
19
+ export interface Shape<Plain, Draft, EmptyState = Plain> {
20
20
  readonly _type: string
21
21
  readonly _plain: Plain
22
22
  readonly _draft: Draft
23
+ readonly _emptyState: EmptyState
23
24
  }
24
25
 
25
26
  export interface DocShape<
@@ -29,30 +30,36 @@ export interface DocShape<
29
30
  >,
30
31
  > extends Shape<
31
32
  { [K in keyof NestedShapes]: NestedShapes[K]["_plain"] },
32
- { [K in keyof NestedShapes]: NestedShapes[K]["_draft"] }
33
+ { [K in keyof NestedShapes]: NestedShapes[K]["_draft"] },
34
+ { [K in keyof NestedShapes]: NestedShapes[K]["_emptyState"] }
33
35
  > {
34
36
  readonly _type: "doc"
35
37
  // A doc's root containers each separately has its own shape, hence 'shapes'
36
38
  readonly shapes: NestedShapes
37
39
  }
38
40
 
39
- export interface TextContainerShape extends Shape<string, TextDraftNode> {
41
+ export interface TextContainerShape
42
+ extends Shape<string, TextDraftNode, string> {
40
43
  readonly _type: "text"
41
44
  }
42
- export interface CounterContainerShape extends Shape<number, CounterDraftNode> {
45
+ export interface CounterContainerShape
46
+ extends Shape<number, CounterDraftNode, number> {
43
47
  readonly _type: "counter"
44
48
  }
45
49
  export interface TreeContainerShape<NestedShape = ContainerOrValueShape>
46
- extends Shape<any, any> {
50
+ extends Shape<any, any, never[]> {
47
51
  readonly _type: "tree"
48
52
  // TODO(duane): What does a tree contain? One type, or many?
49
53
  readonly shape: NestedShape
50
54
  }
51
55
 
52
56
  // Container schemas using interfaces for recursive references
57
+ // NOTE: List and Record use never[] and Record<string, never> for EmptyState
58
+ // to enforce that only empty values ([] and {}) are valid in empty state.
59
+ // This prevents users from expecting per-entry merging behavior.
53
60
  export interface ListContainerShape<
54
61
  NestedShape extends ContainerOrValueShape = ContainerOrValueShape,
55
- > extends Shape<NestedShape["_plain"][], ListDraftNode<NestedShape>> {
62
+ > extends Shape<NestedShape["_plain"][], ListDraftNode<NestedShape>, never[]> {
56
63
  readonly _type: "list"
57
64
  // A list contains many elements, all of the same 'shape'
58
65
  readonly shape: NestedShape
@@ -60,7 +67,11 @@ export interface ListContainerShape<
60
67
 
61
68
  export interface MovableListContainerShape<
62
69
  NestedShape extends ContainerOrValueShape = ContainerOrValueShape,
63
- > extends Shape<NestedShape["_plain"][], MovableListDraftNode<NestedShape>> {
70
+ > extends Shape<
71
+ NestedShape["_plain"][],
72
+ MovableListDraftNode<NestedShape>,
73
+ never[]
74
+ > {
64
75
  readonly _type: "movableList"
65
76
  // A list contains many elements, all of the same 'shape'
66
77
  readonly shape: NestedShape
@@ -75,7 +86,8 @@ export interface MapContainerShape<
75
86
  { [K in keyof NestedShapes]: NestedShapes[K]["_plain"] },
76
87
  MapDraftNode<NestedShapes> & {
77
88
  [K in keyof NestedShapes]: NestedShapes[K]["_draft"]
78
- }
89
+ },
90
+ { [K in keyof NestedShapes]: NestedShapes[K]["_emptyState"] }
79
91
  > {
80
92
  readonly _type: "map"
81
93
  // Each map property has its own shape, hence 'shapes'
@@ -86,7 +98,8 @@ export interface RecordContainerShape<
86
98
  NestedShape extends ContainerOrValueShape = ContainerOrValueShape,
87
99
  > extends Shape<
88
100
  Record<string, NestedShape["_plain"]>,
89
- RecordDraftNode<NestedShape>
101
+ RecordDraftNode<NestedShape>,
102
+ Record<string, never>
90
103
  > {
91
104
  readonly _type: "record"
92
105
  readonly shape: NestedShape
@@ -104,27 +117,31 @@ export type ContainerShape =
104
117
  export type ContainerType = ContainerShape["_type"]
105
118
 
106
119
  // LoroValue shape types - a shape for each of Loro's Value types
107
- export interface StringValueShape extends Shape<string, string> {
120
+ export interface StringValueShape<T extends string = string>
121
+ extends Shape<T, T, T> {
108
122
  readonly _type: "value"
109
123
  readonly valueType: "string"
124
+ readonly options?: T[]
110
125
  }
111
- export interface NumberValueShape extends Shape<number, number> {
126
+ export interface NumberValueShape extends Shape<number, number, number> {
112
127
  readonly _type: "value"
113
128
  readonly valueType: "number"
114
129
  }
115
- export interface BooleanValueShape extends Shape<boolean, boolean> {
130
+ export interface BooleanValueShape extends Shape<boolean, boolean, boolean> {
116
131
  readonly _type: "value"
117
132
  readonly valueType: "boolean"
118
133
  }
119
- export interface NullValueShape extends Shape<null, null> {
134
+ export interface NullValueShape extends Shape<null, null, null> {
120
135
  readonly _type: "value"
121
136
  readonly valueType: "null"
122
137
  }
123
- export interface UndefinedValueShape extends Shape<undefined, undefined> {
138
+ export interface UndefinedValueShape
139
+ extends Shape<undefined, undefined, undefined> {
124
140
  readonly _type: "value"
125
141
  readonly valueType: "undefined"
126
142
  }
127
- export interface Uint8ArrayValueShape extends Shape<Uint8Array, Uint8Array> {
143
+ export interface Uint8ArrayValueShape
144
+ extends Shape<Uint8Array, Uint8Array, Uint8Array> {
128
145
  readonly _type: "value"
129
146
  readonly valueType: "uint8array"
130
147
  }
@@ -133,34 +150,72 @@ export interface ObjectValueShape<
133
150
  T extends Record<string, ValueShape> = Record<string, ValueShape>,
134
151
  > extends Shape<
135
152
  { [K in keyof T]: T[K]["_plain"] },
136
- { [K in keyof T]: T[K]["_draft"] }
153
+ { [K in keyof T]: T[K]["_draft"] },
154
+ { [K in keyof T]: T[K]["_emptyState"] }
137
155
  > {
138
156
  readonly _type: "value"
139
157
  readonly valueType: "object"
140
158
  readonly shape: T
141
159
  }
142
160
 
161
+ // NOTE: RecordValueShape and ArrayValueShape use Record<string, never> and never[]
162
+ // for EmptyState to enforce that only empty values ({} and []) are valid.
143
163
  export interface RecordValueShape<T extends ValueShape = ValueShape>
144
- extends Shape<Record<string, T["_plain"]>, Record<string, T["_draft"]>> {
164
+ extends Shape<
165
+ Record<string, T["_plain"]>,
166
+ Record<string, T["_draft"]>,
167
+ Record<string, never>
168
+ > {
145
169
  readonly _type: "value"
146
170
  readonly valueType: "record"
147
171
  readonly shape: T
148
172
  }
149
173
 
150
174
  export interface ArrayValueShape<T extends ValueShape = ValueShape>
151
- extends Shape<T["_plain"][], T["_draft"][]> {
175
+ extends Shape<T["_plain"][], T["_draft"][], never[]> {
152
176
  readonly _type: "value"
153
177
  readonly valueType: "array"
154
178
  readonly shape: T
155
179
  }
156
180
 
157
181
  export interface UnionValueShape<T extends ValueShape[] = ValueShape[]>
158
- extends Shape<T[number]["_plain"], T[number]["_draft"]> {
182
+ extends Shape<
183
+ T[number]["_plain"],
184
+ T[number]["_draft"],
185
+ T[number]["_emptyState"]
186
+ > {
159
187
  readonly _type: "value"
160
188
  readonly valueType: "union"
161
189
  readonly shapes: T
162
190
  }
163
191
 
192
+ /**
193
+ * A discriminated union shape that uses a discriminant key to determine which variant to use.
194
+ * This enables type-safe handling of tagged unions like:
195
+ *
196
+ * ```typescript
197
+ * type GamePresence =
198
+ * | { type: "client"; name: string; input: { force: number; angle: number } }
199
+ * | { type: "server"; cars: Record<string, CarState>; tick: number }
200
+ * ```
201
+ *
202
+ * @typeParam K - The discriminant key (e.g., "type")
203
+ * @typeParam T - A record mapping discriminant values to their object shapes
204
+ */
205
+ export interface DiscriminatedUnionValueShape<
206
+ K extends string = string,
207
+ T extends Record<string, ObjectValueShape> = Record<string, ObjectValueShape>,
208
+ > extends Shape<
209
+ T[keyof T]["_plain"],
210
+ T[keyof T]["_draft"],
211
+ T[keyof T]["_emptyState"]
212
+ > {
213
+ readonly _type: "value"
214
+ readonly valueType: "discriminatedUnion"
215
+ readonly discriminantKey: K
216
+ readonly variants: T
217
+ }
218
+
164
219
  // Union of all ValueShapes - these can only contain other ValueShapes, not ContainerShapes
165
220
  export type ValueShape =
166
221
  | StringValueShape
@@ -173,6 +228,7 @@ export type ValueShape =
173
228
  | RecordValueShape
174
229
  | ArrayValueShape
175
230
  | UnionValueShape
231
+ | DiscriminatedUnionValueShape
176
232
 
177
233
  export type ContainerOrValueShape = ContainerShape | ValueShape
178
234
 
@@ -189,6 +245,7 @@ export const Shape = {
189
245
  shapes: shape,
190
246
  _plain: {} as any,
191
247
  _draft: {} as any,
248
+ _emptyState: {} as any,
192
249
  }),
193
250
 
194
251
  // CRDTs are represented by Loro Containers--they converge on state using Loro's
@@ -197,6 +254,7 @@ export const Shape = {
197
254
  _type: "counter" as const,
198
255
  _plain: 0,
199
256
  _draft: {} as CounterDraftNode,
257
+ _emptyState: 0,
200
258
  }),
201
259
 
202
260
  list: <T extends ContainerOrValueShape>(shape: T): ListContainerShape<T> => ({
@@ -204,6 +262,7 @@ export const Shape = {
204
262
  shape,
205
263
  _plain: [] as any,
206
264
  _draft: {} as any,
265
+ _emptyState: [] as never[],
207
266
  }),
208
267
 
209
268
  map: <T extends Record<string, ContainerOrValueShape>>(
@@ -213,6 +272,7 @@ export const Shape = {
213
272
  shapes: shape,
214
273
  _plain: {} as any,
215
274
  _draft: {} as any,
275
+ _emptyState: {} as any,
216
276
  }),
217
277
 
218
278
  record: <T extends ContainerOrValueShape>(
@@ -222,6 +282,7 @@ export const Shape = {
222
282
  shape,
223
283
  _plain: {} as any,
224
284
  _draft: {} as any,
285
+ _emptyState: {} as Record<string, never>,
225
286
  }),
226
287
 
227
288
  movableList: <T extends ContainerOrValueShape>(
@@ -231,12 +292,14 @@ export const Shape = {
231
292
  shape,
232
293
  _plain: [] as any,
233
294
  _draft: {} as any,
295
+ _emptyState: [] as never[],
234
296
  }),
235
297
 
236
298
  text: (): TextContainerShape => ({
237
299
  _type: "text" as const,
238
300
  _plain: "",
239
301
  _draft: {} as TextDraftNode,
302
+ _emptyState: "",
240
303
  }),
241
304
 
242
305
  tree: <T extends MapContainerShape>(shape: T): TreeContainerShape => ({
@@ -244,6 +307,7 @@ export const Shape = {
244
307
  shape,
245
308
  _plain: {} as any,
246
309
  _draft: {} as any,
310
+ _emptyState: [] as never[],
247
311
  }),
248
312
 
249
313
  // Values are represented as plain JS objects, with the limitation that they MUST be
@@ -251,11 +315,15 @@ export const Shape = {
251
315
  // "Last Write Wins", meaning there is no subtle convergent behavior here, just taking
252
316
  // the most recent value based on the current available information.
253
317
  plain: {
254
- string: (): StringValueShape => ({
318
+ string: <T extends string = string>(
319
+ ...options: T[]
320
+ ): StringValueShape<T> => ({
255
321
  _type: "value" as const,
256
322
  valueType: "string" as const,
257
- _plain: "",
258
- _draft: "",
323
+ _plain: (options[0] ?? "") as T,
324
+ _draft: (options[0] ?? "") as T,
325
+ _emptyState: (options[0] ?? "") as T,
326
+ options: options.length > 0 ? options : undefined,
259
327
  }),
260
328
 
261
329
  number: (): NumberValueShape => ({
@@ -263,6 +331,7 @@ export const Shape = {
263
331
  valueType: "number" as const,
264
332
  _plain: 0,
265
333
  _draft: 0,
334
+ _emptyState: 0,
266
335
  }),
267
336
 
268
337
  boolean: (): BooleanValueShape => ({
@@ -270,6 +339,7 @@ export const Shape = {
270
339
  valueType: "boolean" as const,
271
340
  _plain: false,
272
341
  _draft: false,
342
+ _emptyState: false,
273
343
  }),
274
344
 
275
345
  null: (): NullValueShape => ({
@@ -277,6 +347,7 @@ export const Shape = {
277
347
  valueType: "null" as const,
278
348
  _plain: null,
279
349
  _draft: null,
350
+ _emptyState: null,
280
351
  }),
281
352
 
282
353
  undefined: (): UndefinedValueShape => ({
@@ -284,6 +355,7 @@ export const Shape = {
284
355
  valueType: "undefined" as const,
285
356
  _plain: undefined,
286
357
  _draft: undefined,
358
+ _emptyState: undefined,
287
359
  }),
288
360
 
289
361
  uint8Array: (): Uint8ArrayValueShape => ({
@@ -291,6 +363,7 @@ export const Shape = {
291
363
  valueType: "uint8array" as const,
292
364
  _plain: new Uint8Array(),
293
365
  _draft: new Uint8Array(),
366
+ _emptyState: new Uint8Array(),
294
367
  }),
295
368
 
296
369
  object: <T extends Record<string, ValueShape>>(
@@ -301,6 +374,7 @@ export const Shape = {
301
374
  shape,
302
375
  _plain: {} as any,
303
376
  _draft: {} as any,
377
+ _emptyState: {} as any,
304
378
  }),
305
379
 
306
380
  record: <T extends ValueShape>(shape: T): RecordValueShape<T> => ({
@@ -309,6 +383,7 @@ export const Shape = {
309
383
  shape,
310
384
  _plain: {} as any,
311
385
  _draft: {} as any,
386
+ _emptyState: {} as Record<string, never>,
312
387
  }),
313
388
 
314
389
  array: <T extends ValueShape>(shape: T): ArrayValueShape<T> => ({
@@ -317,6 +392,7 @@ export const Shape = {
317
392
  shape,
318
393
  _plain: [] as any,
319
394
  _draft: [] as any,
395
+ _emptyState: [] as never[],
320
396
  }),
321
397
 
322
398
  // Special value type that helps make things like `string | null` representable
@@ -327,6 +403,49 @@ export const Shape = {
327
403
  shapes,
328
404
  _plain: {} as any,
329
405
  _draft: {} as any,
406
+ _emptyState: {} as any,
407
+ }),
408
+
409
+ /**
410
+ * Creates a discriminated union shape for type-safe tagged unions.
411
+ *
412
+ * @example
413
+ * ```typescript
414
+ * const ClientPresenceShape = Shape.plain.object({
415
+ * type: Shape.plain.string("client"),
416
+ * name: Shape.plain.string(),
417
+ * input: Shape.plain.object({ force: Shape.plain.number(), angle: Shape.plain.number() }),
418
+ * })
419
+ *
420
+ * const ServerPresenceShape = Shape.plain.object({
421
+ * type: Shape.plain.string("server"),
422
+ * cars: Shape.plain.record(Shape.plain.object({ x: Shape.plain.number(), y: Shape.plain.number() })),
423
+ * tick: Shape.plain.number(),
424
+ * })
425
+ *
426
+ * const GamePresenceSchema = Shape.plain.discriminatedUnion("type", {
427
+ * client: ClientPresenceShape,
428
+ * server: ServerPresenceShape,
429
+ * })
430
+ * ```
431
+ *
432
+ * @param discriminantKey - The key used to discriminate between variants (e.g., "type")
433
+ * @param variants - A record mapping discriminant values to their object shapes
434
+ */
435
+ discriminatedUnion: <
436
+ K extends string,
437
+ T extends Record<string, ObjectValueShape>,
438
+ >(
439
+ discriminantKey: K,
440
+ variants: T,
441
+ ): DiscriminatedUnionValueShape<K, T> => ({
442
+ _type: "value" as const,
443
+ valueType: "discriminatedUnion" as const,
444
+ discriminantKey,
445
+ variants,
446
+ _plain: {} as any,
447
+ _draft: {} as any,
448
+ _emptyState: {} as any,
330
449
  }),
331
450
  },
332
451
  }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { Shape } from "./shape.js"
3
+ import { validateValue } from "./validation.js"
4
+
5
+ describe("String Literal Shape", () => {
6
+ it("should support type inference for string unions", () => {
7
+ const schema = Shape.plain.string<"user" | "ai">()
8
+
9
+ // This is a type-level check, we can't easily assert it at runtime without options
10
+ // But we can check that it validates strings
11
+ expect(validateValue("user", schema)).toBe("user")
12
+ expect(validateValue("ai", schema)).toBe("ai")
13
+ expect(validateValue("other", schema)).toBe("other") // No runtime validation without options
14
+ })
15
+
16
+ it("should support runtime validation when options are provided", () => {
17
+ const schema = Shape.plain.string("user", "ai")
18
+
19
+ expect(validateValue("user", schema)).toBe("user")
20
+ expect(validateValue("ai", schema)).toBe("ai")
21
+
22
+ expect(() => validateValue("other", schema)).toThrow(
23
+ 'Expected one of [user, ai] at path root, got "other"',
24
+ )
25
+ })
26
+
27
+ it("should work with single option", () => {
28
+ const schema = Shape.plain.string("fixed")
29
+
30
+ expect(validateValue("fixed", schema)).toBe("fixed")
31
+ expect(() => validateValue("other", schema)).toThrow(
32
+ 'Expected one of [fixed] at path root, got "other"',
33
+ )
34
+ })
35
+
36
+ it("should maintain backward compatibility", () => {
37
+ const schema = Shape.plain.string()
38
+
39
+ expect(validateValue("any", schema)).toBe("any")
40
+ expect(validateValue("", schema)).toBe("")
41
+ })
42
+ })
package/src/types.ts CHANGED
@@ -6,9 +6,19 @@
6
6
  import type { ContainerShape, DocShape, Shape } from "./shape.js"
7
7
 
8
8
  // Input type inference - what developers can pass to push/insert methods
9
- export type InferPlainType<T> = T extends Shape<infer P, any> ? P : never
9
+ export type InferPlainType<T> = T extends Shape<infer P, any, any> ? P : never
10
10
 
11
- export type InferDraftType<T> = T extends Shape<any, infer D> ? D : never
11
+ export type InferDraftType<T> = T extends Shape<any, infer D, any> ? D : never
12
+
13
+ /**
14
+ * Extracts the valid empty state type from a shape.
15
+ *
16
+ * For dynamic containers (list, record, etc.), this will be constrained to
17
+ * empty values ([] or {}) to prevent users from expecting per-entry merging.
18
+ */
19
+ export type InferEmptyStateType<T> = T extends Shape<any, any, infer E>
20
+ ? E
21
+ : never
12
22
 
13
23
  // Draft-specific type inference that properly handles the draft context
14
24
  export type Draft<T extends DocShape<Record<string, ContainerShape>>> =
package/src/validation.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  ObjectValueShape,
9
9
  RecordContainerShape,
10
10
  RecordValueShape,
11
+ StringValueShape,
11
12
  UnionValueShape,
12
13
  ValueShape,
13
14
  } from "./shape.js"
@@ -108,13 +109,20 @@ export function validateValue(
108
109
  const valueSchema = schema as ValueShape
109
110
 
110
111
  switch (valueSchema.valueType) {
111
- case "string":
112
+ case "string": {
112
113
  if (typeof value !== "string") {
113
114
  throw new Error(
114
115
  `Expected string at path ${currentPath}, got ${typeof value}`,
115
116
  )
116
117
  }
118
+ const stringSchema = valueSchema as StringValueShape
119
+ if (stringSchema.options && !stringSchema.options.includes(value)) {
120
+ throw new Error(
121
+ `Expected one of [${stringSchema.options.join(", ")}] at path ${currentPath}, got "${value}"`,
122
+ )
123
+ }
117
124
  return value
125
+ }
118
126
 
119
127
  case "number":
120
128
  if (typeof value !== "number") {