@loro-extended/change 0.5.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
@@ -105,28 +118,30 @@ export type ContainerType = ContainerShape["_type"]
105
118
 
106
119
  // LoroValue shape types - a shape for each of Loro's Value types
107
120
  export interface StringValueShape<T extends string = string>
108
- extends Shape<T, T> {
121
+ extends Shape<T, T, T> {
109
122
  readonly _type: "value"
110
123
  readonly valueType: "string"
111
124
  readonly options?: T[]
112
125
  }
113
- export interface NumberValueShape extends Shape<number, number> {
126
+ export interface NumberValueShape extends Shape<number, number, number> {
114
127
  readonly _type: "value"
115
128
  readonly valueType: "number"
116
129
  }
117
- export interface BooleanValueShape extends Shape<boolean, boolean> {
130
+ export interface BooleanValueShape extends Shape<boolean, boolean, boolean> {
118
131
  readonly _type: "value"
119
132
  readonly valueType: "boolean"
120
133
  }
121
- export interface NullValueShape extends Shape<null, null> {
134
+ export interface NullValueShape extends Shape<null, null, null> {
122
135
  readonly _type: "value"
123
136
  readonly valueType: "null"
124
137
  }
125
- export interface UndefinedValueShape extends Shape<undefined, undefined> {
138
+ export interface UndefinedValueShape
139
+ extends Shape<undefined, undefined, undefined> {
126
140
  readonly _type: "value"
127
141
  readonly valueType: "undefined"
128
142
  }
129
- export interface Uint8ArrayValueShape extends Shape<Uint8Array, Uint8Array> {
143
+ export interface Uint8ArrayValueShape
144
+ extends Shape<Uint8Array, Uint8Array, Uint8Array> {
130
145
  readonly _type: "value"
131
146
  readonly valueType: "uint8array"
132
147
  }
@@ -135,34 +150,72 @@ export interface ObjectValueShape<
135
150
  T extends Record<string, ValueShape> = Record<string, ValueShape>,
136
151
  > extends Shape<
137
152
  { [K in keyof T]: T[K]["_plain"] },
138
- { [K in keyof T]: T[K]["_draft"] }
153
+ { [K in keyof T]: T[K]["_draft"] },
154
+ { [K in keyof T]: T[K]["_emptyState"] }
139
155
  > {
140
156
  readonly _type: "value"
141
157
  readonly valueType: "object"
142
158
  readonly shape: T
143
159
  }
144
160
 
161
+ // NOTE: RecordValueShape and ArrayValueShape use Record<string, never> and never[]
162
+ // for EmptyState to enforce that only empty values ({} and []) are valid.
145
163
  export interface RecordValueShape<T extends ValueShape = ValueShape>
146
- 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
+ > {
147
169
  readonly _type: "value"
148
170
  readonly valueType: "record"
149
171
  readonly shape: T
150
172
  }
151
173
 
152
174
  export interface ArrayValueShape<T extends ValueShape = ValueShape>
153
- extends Shape<T["_plain"][], T["_draft"][]> {
175
+ extends Shape<T["_plain"][], T["_draft"][], never[]> {
154
176
  readonly _type: "value"
155
177
  readonly valueType: "array"
156
178
  readonly shape: T
157
179
  }
158
180
 
159
181
  export interface UnionValueShape<T extends ValueShape[] = ValueShape[]>
160
- extends Shape<T[number]["_plain"], T[number]["_draft"]> {
182
+ extends Shape<
183
+ T[number]["_plain"],
184
+ T[number]["_draft"],
185
+ T[number]["_emptyState"]
186
+ > {
161
187
  readonly _type: "value"
162
188
  readonly valueType: "union"
163
189
  readonly shapes: T
164
190
  }
165
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
+
166
219
  // Union of all ValueShapes - these can only contain other ValueShapes, not ContainerShapes
167
220
  export type ValueShape =
168
221
  | StringValueShape
@@ -175,6 +228,7 @@ export type ValueShape =
175
228
  | RecordValueShape
176
229
  | ArrayValueShape
177
230
  | UnionValueShape
231
+ | DiscriminatedUnionValueShape
178
232
 
179
233
  export type ContainerOrValueShape = ContainerShape | ValueShape
180
234
 
@@ -191,6 +245,7 @@ export const Shape = {
191
245
  shapes: shape,
192
246
  _plain: {} as any,
193
247
  _draft: {} as any,
248
+ _emptyState: {} as any,
194
249
  }),
195
250
 
196
251
  // CRDTs are represented by Loro Containers--they converge on state using Loro's
@@ -199,6 +254,7 @@ export const Shape = {
199
254
  _type: "counter" as const,
200
255
  _plain: 0,
201
256
  _draft: {} as CounterDraftNode,
257
+ _emptyState: 0,
202
258
  }),
203
259
 
204
260
  list: <T extends ContainerOrValueShape>(shape: T): ListContainerShape<T> => ({
@@ -206,6 +262,7 @@ export const Shape = {
206
262
  shape,
207
263
  _plain: [] as any,
208
264
  _draft: {} as any,
265
+ _emptyState: [] as never[],
209
266
  }),
210
267
 
211
268
  map: <T extends Record<string, ContainerOrValueShape>>(
@@ -215,6 +272,7 @@ export const Shape = {
215
272
  shapes: shape,
216
273
  _plain: {} as any,
217
274
  _draft: {} as any,
275
+ _emptyState: {} as any,
218
276
  }),
219
277
 
220
278
  record: <T extends ContainerOrValueShape>(
@@ -224,6 +282,7 @@ export const Shape = {
224
282
  shape,
225
283
  _plain: {} as any,
226
284
  _draft: {} as any,
285
+ _emptyState: {} as Record<string, never>,
227
286
  }),
228
287
 
229
288
  movableList: <T extends ContainerOrValueShape>(
@@ -233,12 +292,14 @@ export const Shape = {
233
292
  shape,
234
293
  _plain: [] as any,
235
294
  _draft: {} as any,
295
+ _emptyState: [] as never[],
236
296
  }),
237
297
 
238
298
  text: (): TextContainerShape => ({
239
299
  _type: "text" as const,
240
300
  _plain: "",
241
301
  _draft: {} as TextDraftNode,
302
+ _emptyState: "",
242
303
  }),
243
304
 
244
305
  tree: <T extends MapContainerShape>(shape: T): TreeContainerShape => ({
@@ -246,6 +307,7 @@ export const Shape = {
246
307
  shape,
247
308
  _plain: {} as any,
248
309
  _draft: {} as any,
310
+ _emptyState: [] as never[],
249
311
  }),
250
312
 
251
313
  // Values are represented as plain JS objects, with the limitation that they MUST be
@@ -260,6 +322,7 @@ export const Shape = {
260
322
  valueType: "string" as const,
261
323
  _plain: (options[0] ?? "") as T,
262
324
  _draft: (options[0] ?? "") as T,
325
+ _emptyState: (options[0] ?? "") as T,
263
326
  options: options.length > 0 ? options : undefined,
264
327
  }),
265
328
 
@@ -268,6 +331,7 @@ export const Shape = {
268
331
  valueType: "number" as const,
269
332
  _plain: 0,
270
333
  _draft: 0,
334
+ _emptyState: 0,
271
335
  }),
272
336
 
273
337
  boolean: (): BooleanValueShape => ({
@@ -275,6 +339,7 @@ export const Shape = {
275
339
  valueType: "boolean" as const,
276
340
  _plain: false,
277
341
  _draft: false,
342
+ _emptyState: false,
278
343
  }),
279
344
 
280
345
  null: (): NullValueShape => ({
@@ -282,6 +347,7 @@ export const Shape = {
282
347
  valueType: "null" as const,
283
348
  _plain: null,
284
349
  _draft: null,
350
+ _emptyState: null,
285
351
  }),
286
352
 
287
353
  undefined: (): UndefinedValueShape => ({
@@ -289,6 +355,7 @@ export const Shape = {
289
355
  valueType: "undefined" as const,
290
356
  _plain: undefined,
291
357
  _draft: undefined,
358
+ _emptyState: undefined,
292
359
  }),
293
360
 
294
361
  uint8Array: (): Uint8ArrayValueShape => ({
@@ -296,6 +363,7 @@ export const Shape = {
296
363
  valueType: "uint8array" as const,
297
364
  _plain: new Uint8Array(),
298
365
  _draft: new Uint8Array(),
366
+ _emptyState: new Uint8Array(),
299
367
  }),
300
368
 
301
369
  object: <T extends Record<string, ValueShape>>(
@@ -306,6 +374,7 @@ export const Shape = {
306
374
  shape,
307
375
  _plain: {} as any,
308
376
  _draft: {} as any,
377
+ _emptyState: {} as any,
309
378
  }),
310
379
 
311
380
  record: <T extends ValueShape>(shape: T): RecordValueShape<T> => ({
@@ -314,6 +383,7 @@ export const Shape = {
314
383
  shape,
315
384
  _plain: {} as any,
316
385
  _draft: {} as any,
386
+ _emptyState: {} as Record<string, never>,
317
387
  }),
318
388
 
319
389
  array: <T extends ValueShape>(shape: T): ArrayValueShape<T> => ({
@@ -322,6 +392,7 @@ export const Shape = {
322
392
  shape,
323
393
  _plain: [] as any,
324
394
  _draft: [] as any,
395
+ _emptyState: [] as never[],
325
396
  }),
326
397
 
327
398
  // Special value type that helps make things like `string | null` representable
@@ -332,6 +403,49 @@ export const Shape = {
332
403
  shapes,
333
404
  _plain: {} as any,
334
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,
335
449
  }),
336
450
  },
337
451
  }
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>>> =