@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/README.md +91 -15
- package/dist/index.d.ts +93 -23
- package/dist/index.js +128 -23
- 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 +65 -1
- package/src/record.test.ts +2 -1
- package/src/shape.ts +141 -22
- package/src/string-literal.test.ts +42 -0
- package/src/types.ts +12 -2
- package/src/validation.ts +9 -1
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
|
|
@@ -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
|
|
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
|
|
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
|
|
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<
|
|
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<
|
|
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:
|
|
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") {
|