@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/README.md +71 -0
- package/dist/index.d.ts +91 -22
- package/dist/index.js +102 -19
- 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 +169 -0
- package/src/draft-nodes/map.ts +3 -2
- package/src/index.ts +5 -0
- package/src/overlay.ts +49 -1
- package/src/record.test.ts +2 -1
- package/src/shape.ts +133 -19
- package/src/types.ts +12 -2
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
|
|
41
|
+
export interface TextContainerShape
|
|
42
|
+
extends Shape<string, TextDraftNode, string> {
|
|
40
43
|
readonly _type: "text"
|
|
41
44
|
}
|
|
42
|
-
export interface CounterContainerShape
|
|
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<
|
|
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
|
|
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
|
|
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<
|
|
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<
|
|
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>>> =
|