@loro-extended/change 0.6.0 → 0.8.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,11 +16,16 @@ 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, EmptyState = Plain> {
19
+ export interface Shape<Plain, Draft, Placeholder = Plain> {
20
20
  readonly _type: string
21
21
  readonly _plain: Plain
22
22
  readonly _draft: Draft
23
- readonly _emptyState: EmptyState
23
+ readonly _placeholder: Placeholder
24
+ }
25
+
26
+ // Type for shapes that support placeholder customization
27
+ export type WithPlaceholder<S extends Shape<any, any, any>> = S & {
28
+ placeholder(value: S["_placeholder"]): S
24
29
  }
25
30
 
26
31
  export interface DocShape<
@@ -31,7 +36,7 @@ export interface DocShape<
31
36
  > extends Shape<
32
37
  { [K in keyof NestedShapes]: NestedShapes[K]["_plain"] },
33
38
  { [K in keyof NestedShapes]: NestedShapes[K]["_draft"] },
34
- { [K in keyof NestedShapes]: NestedShapes[K]["_emptyState"] }
39
+ { [K in keyof NestedShapes]: NestedShapes[K]["_placeholder"] }
35
40
  > {
36
41
  readonly _type: "doc"
37
42
  // A doc's root containers each separately has its own shape, hence 'shapes'
@@ -54,8 +59,8 @@ export interface TreeContainerShape<NestedShape = ContainerOrValueShape>
54
59
  }
55
60
 
56
61
  // 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.
62
+ // NOTE: List and Record use never[] and Record<string, never> for Placeholder
63
+ // to enforce that only empty values ([] and {}) are valid in placeholder state.
59
64
  // This prevents users from expecting per-entry merging behavior.
60
65
  export interface ListContainerShape<
61
66
  NestedShape extends ContainerOrValueShape = ContainerOrValueShape,
@@ -87,7 +92,7 @@ export interface MapContainerShape<
87
92
  MapDraftNode<NestedShapes> & {
88
93
  [K in keyof NestedShapes]: NestedShapes[K]["_draft"]
89
94
  },
90
- { [K in keyof NestedShapes]: NestedShapes[K]["_emptyState"] }
95
+ { [K in keyof NestedShapes]: NestedShapes[K]["_placeholder"] }
91
96
  > {
92
97
  readonly _type: "map"
93
98
  // Each map property has its own shape, hence 'shapes'
@@ -151,7 +156,7 @@ export interface ObjectValueShape<
151
156
  > extends Shape<
152
157
  { [K in keyof T]: T[K]["_plain"] },
153
158
  { [K in keyof T]: T[K]["_draft"] },
154
- { [K in keyof T]: T[K]["_emptyState"] }
159
+ { [K in keyof T]: T[K]["_placeholder"] }
155
160
  > {
156
161
  readonly _type: "value"
157
162
  readonly valueType: "object"
@@ -159,7 +164,7 @@ export interface ObjectValueShape<
159
164
  }
160
165
 
161
166
  // NOTE: RecordValueShape and ArrayValueShape use Record<string, never> and never[]
162
- // for EmptyState to enforce that only empty values ({} and []) are valid.
167
+ // for Placeholder to enforce that only empty values ({} and []) are valid.
163
168
  export interface RecordValueShape<T extends ValueShape = ValueShape>
164
169
  extends Shape<
165
170
  Record<string, T["_plain"]>,
@@ -182,7 +187,7 @@ export interface UnionValueShape<T extends ValueShape[] = ValueShape[]>
182
187
  extends Shape<
183
188
  T[number]["_plain"],
184
189
  T[number]["_draft"],
185
- T[number]["_emptyState"]
190
+ T[number]["_placeholder"]
186
191
  > {
187
192
  readonly _type: "value"
188
193
  readonly valueType: "union"
@@ -208,7 +213,7 @@ export interface DiscriminatedUnionValueShape<
208
213
  > extends Shape<
209
214
  T[keyof T]["_plain"],
210
215
  T[keyof T]["_draft"],
211
- T[keyof T]["_emptyState"]
216
+ T[keyof T]["_placeholder"]
212
217
  > {
213
218
  readonly _type: "value"
214
219
  readonly valueType: "discriminatedUnion"
@@ -245,24 +250,31 @@ export const Shape = {
245
250
  shapes: shape,
246
251
  _plain: {} as any,
247
252
  _draft: {} as any,
248
- _emptyState: {} as any,
253
+ _placeholder: {} as any,
249
254
  }),
250
255
 
251
256
  // CRDTs are represented by Loro Containers--they converge on state using Loro's
252
257
  // various CRDT algorithms
253
- counter: (): CounterContainerShape => ({
254
- _type: "counter" as const,
255
- _plain: 0,
256
- _draft: {} as CounterDraftNode,
257
- _emptyState: 0,
258
- }),
258
+ counter: (): WithPlaceholder<CounterContainerShape> => {
259
+ const base: CounterContainerShape = {
260
+ _type: "counter" as const,
261
+ _plain: 0,
262
+ _draft: {} as CounterDraftNode,
263
+ _placeholder: 0,
264
+ }
265
+ return Object.assign(base, {
266
+ placeholder(value: number): CounterContainerShape {
267
+ return { ...base, _placeholder: value }
268
+ },
269
+ })
270
+ },
259
271
 
260
272
  list: <T extends ContainerOrValueShape>(shape: T): ListContainerShape<T> => ({
261
273
  _type: "list" as const,
262
274
  shape,
263
275
  _plain: [] as any,
264
276
  _draft: {} as any,
265
- _emptyState: [] as never[],
277
+ _placeholder: [] as never[],
266
278
  }),
267
279
 
268
280
  map: <T extends Record<string, ContainerOrValueShape>>(
@@ -272,7 +284,7 @@ export const Shape = {
272
284
  shapes: shape,
273
285
  _plain: {} as any,
274
286
  _draft: {} as any,
275
- _emptyState: {} as any,
287
+ _placeholder: {} as any,
276
288
  }),
277
289
 
278
290
  record: <T extends ContainerOrValueShape>(
@@ -282,7 +294,7 @@ export const Shape = {
282
294
  shape,
283
295
  _plain: {} as any,
284
296
  _draft: {} as any,
285
- _emptyState: {} as Record<string, never>,
297
+ _placeholder: {} as Record<string, never>,
286
298
  }),
287
299
 
288
300
  movableList: <T extends ContainerOrValueShape>(
@@ -292,22 +304,29 @@ export const Shape = {
292
304
  shape,
293
305
  _plain: [] as any,
294
306
  _draft: {} as any,
295
- _emptyState: [] as never[],
307
+ _placeholder: [] as never[],
296
308
  }),
297
309
 
298
- text: (): TextContainerShape => ({
299
- _type: "text" as const,
300
- _plain: "",
301
- _draft: {} as TextDraftNode,
302
- _emptyState: "",
303
- }),
310
+ text: (): WithPlaceholder<TextContainerShape> => {
311
+ const base: TextContainerShape = {
312
+ _type: "text" as const,
313
+ _plain: "",
314
+ _draft: {} as TextDraftNode,
315
+ _placeholder: "",
316
+ }
317
+ return Object.assign(base, {
318
+ placeholder(value: string): TextContainerShape {
319
+ return { ...base, _placeholder: value }
320
+ },
321
+ })
322
+ },
304
323
 
305
324
  tree: <T extends MapContainerShape>(shape: T): TreeContainerShape => ({
306
325
  _type: "tree" as const,
307
326
  shape,
308
327
  _plain: {} as any,
309
328
  _draft: {} as any,
310
- _emptyState: [] as never[],
329
+ _placeholder: [] as never[],
311
330
  }),
312
331
 
313
332
  // Values are represented as plain JS objects, with the limitation that they MUST be
@@ -317,37 +336,58 @@ export const Shape = {
317
336
  plain: {
318
337
  string: <T extends string = string>(
319
338
  ...options: T[]
320
- ): StringValueShape<T> => ({
321
- _type: "value" as const,
322
- valueType: "string" as const,
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,
327
- }),
339
+ ): WithPlaceholder<StringValueShape<T>> => {
340
+ const base: StringValueShape<T> = {
341
+ _type: "value" as const,
342
+ valueType: "string" as const,
343
+ _plain: (options[0] ?? "") as T,
344
+ _draft: (options[0] ?? "") as T,
345
+ _placeholder: (options[0] ?? "") as T,
346
+ options: options.length > 0 ? options : undefined,
347
+ }
348
+ return Object.assign(base, {
349
+ placeholder(value: T): StringValueShape<T> {
350
+ return { ...base, _placeholder: value }
351
+ },
352
+ })
353
+ },
328
354
 
329
- number: (): NumberValueShape => ({
330
- _type: "value" as const,
331
- valueType: "number" as const,
332
- _plain: 0,
333
- _draft: 0,
334
- _emptyState: 0,
335
- }),
355
+ number: (): WithPlaceholder<NumberValueShape> => {
356
+ const base: NumberValueShape = {
357
+ _type: "value" as const,
358
+ valueType: "number" as const,
359
+ _plain: 0,
360
+ _draft: 0,
361
+ _placeholder: 0,
362
+ }
363
+ return Object.assign(base, {
364
+ placeholder(value: number): NumberValueShape {
365
+ return { ...base, _placeholder: value }
366
+ },
367
+ })
368
+ },
336
369
 
337
- boolean: (): BooleanValueShape => ({
338
- _type: "value" as const,
339
- valueType: "boolean" as const,
340
- _plain: false,
341
- _draft: false,
342
- _emptyState: false,
343
- }),
370
+ boolean: (): WithPlaceholder<BooleanValueShape> => {
371
+ const base: BooleanValueShape = {
372
+ _type: "value" as const,
373
+ valueType: "boolean" as const,
374
+ _plain: false,
375
+ _draft: false,
376
+ _placeholder: false,
377
+ }
378
+ return Object.assign(base, {
379
+ placeholder(value: boolean): BooleanValueShape {
380
+ return { ...base, _placeholder: value }
381
+ },
382
+ })
383
+ },
344
384
 
345
385
  null: (): NullValueShape => ({
346
386
  _type: "value" as const,
347
387
  valueType: "null" as const,
348
388
  _plain: null,
349
389
  _draft: null,
350
- _emptyState: null,
390
+ _placeholder: null,
351
391
  }),
352
392
 
353
393
  undefined: (): UndefinedValueShape => ({
@@ -355,7 +395,7 @@ export const Shape = {
355
395
  valueType: "undefined" as const,
356
396
  _plain: undefined,
357
397
  _draft: undefined,
358
- _emptyState: undefined,
398
+ _placeholder: undefined,
359
399
  }),
360
400
 
361
401
  uint8Array: (): Uint8ArrayValueShape => ({
@@ -363,7 +403,7 @@ export const Shape = {
363
403
  valueType: "uint8array" as const,
364
404
  _plain: new Uint8Array(),
365
405
  _draft: new Uint8Array(),
366
- _emptyState: new Uint8Array(),
406
+ _placeholder: new Uint8Array(),
367
407
  }),
368
408
 
369
409
  object: <T extends Record<string, ValueShape>>(
@@ -374,7 +414,7 @@ export const Shape = {
374
414
  shape,
375
415
  _plain: {} as any,
376
416
  _draft: {} as any,
377
- _emptyState: {} as any,
417
+ _placeholder: {} as any,
378
418
  }),
379
419
 
380
420
  record: <T extends ValueShape>(shape: T): RecordValueShape<T> => ({
@@ -383,7 +423,7 @@ export const Shape = {
383
423
  shape,
384
424
  _plain: {} as any,
385
425
  _draft: {} as any,
386
- _emptyState: {} as Record<string, never>,
426
+ _placeholder: {} as Record<string, never>,
387
427
  }),
388
428
 
389
429
  array: <T extends ValueShape>(shape: T): ArrayValueShape<T> => ({
@@ -392,19 +432,28 @@ export const Shape = {
392
432
  shape,
393
433
  _plain: [] as any,
394
434
  _draft: [] as any,
395
- _emptyState: [] as never[],
435
+ _placeholder: [] as never[],
396
436
  }),
397
437
 
398
438
  // Special value type that helps make things like `string | null` representable
399
439
  // TODO(duane): should this be a more general type for containers too?
400
- union: <T extends ValueShape[]>(shapes: T): UnionValueShape<T> => ({
401
- _type: "value" as const,
402
- valueType: "union" as const,
403
- shapes,
404
- _plain: {} as any,
405
- _draft: {} as any,
406
- _emptyState: {} as any,
407
- }),
440
+ union: <T extends ValueShape[]>(
441
+ shapes: T,
442
+ ): WithPlaceholder<UnionValueShape<T>> => {
443
+ const base: UnionValueShape<T> = {
444
+ _type: "value" as const,
445
+ valueType: "union" as const,
446
+ shapes,
447
+ _plain: {} as any,
448
+ _draft: {} as any,
449
+ _placeholder: {} as any,
450
+ }
451
+ return Object.assign(base, {
452
+ placeholder(value: T[number]["_placeholder"]): UnionValueShape<T> {
453
+ return { ...base, _placeholder: value }
454
+ },
455
+ })
456
+ },
408
457
 
409
458
  /**
410
459
  * Creates a discriminated union shape for type-safe tagged unions.
@@ -438,15 +487,24 @@ export const Shape = {
438
487
  >(
439
488
  discriminantKey: K,
440
489
  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,
449
- }),
490
+ ): WithPlaceholder<DiscriminatedUnionValueShape<K, T>> => {
491
+ const base: DiscriminatedUnionValueShape<K, T> = {
492
+ _type: "value" as const,
493
+ valueType: "discriminatedUnion" as const,
494
+ discriminantKey,
495
+ variants,
496
+ _plain: {} as any,
497
+ _draft: {} as any,
498
+ _placeholder: {} as any,
499
+ }
500
+ return Object.assign(base, {
501
+ placeholder(
502
+ value: T[keyof T]["_placeholder"],
503
+ ): DiscriminatedUnionValueShape<K, T> {
504
+ return { ...base, _placeholder: value }
505
+ },
506
+ })
507
+ },
450
508
  },
451
509
  }
452
510
 
@@ -1,6 +1,7 @@
1
1
  /** biome-ignore-all lint/suspicious/noExplicitAny: fix later */
2
2
 
3
3
  import { LoroDoc } from "loro-crdt"
4
+ import { derivePlaceholder } from "./derive-placeholder.js"
4
5
  import { DraftDoc } from "./draft-nodes/doc.js"
5
6
  import {
6
7
  type JsonPatch,
@@ -8,50 +9,75 @@ import {
8
9
  type JsonPatchOperation,
9
10
  normalizePath,
10
11
  } from "./json-patch.js"
11
- import { overlayEmptyState } from "./overlay.js"
12
+ import { overlayPlaceholder } from "./overlay.js"
12
13
  import type { DocShape } from "./shape.js"
13
- import type { Draft, InferEmptyStateType, InferPlainType } from "./types.js"
14
- import { validateEmptyState } from "./validation.js"
14
+ import type {
15
+ DeepReadonly,
16
+ Draft,
17
+ Infer,
18
+ InferPlaceholderType,
19
+ } from "./types.js"
20
+ import { validatePlaceholder } from "./validation.js"
15
21
 
16
22
  // Core TypedDoc abstraction around LoroDoc
17
23
  export class TypedDoc<Shape extends DocShape> {
24
+ private shape: Shape
25
+ private placeholder: InferPlaceholderType<Shape>
26
+ private doc: LoroDoc
27
+
18
28
  /**
19
- * Creates a new TypedDoc with the given schema and empty state.
29
+ * Creates a new TypedDoc with the given schema.
30
+ * Placeholder state is automatically derived from the schema's placeholder values.
20
31
  *
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.
32
+ * @param shape - The document schema (with optional .placeholder() values)
25
33
  * @param doc - Optional existing LoroDoc to wrap
26
34
  */
27
- constructor(
28
- private shape: Shape,
29
- private emptyState: InferEmptyStateType<Shape>,
30
- private doc: LoroDoc = new LoroDoc(),
31
- ) {
32
- validateEmptyState(emptyState, shape)
35
+ constructor(shape: Shape, doc: LoroDoc = new LoroDoc()) {
36
+ this.shape = shape
37
+ this.placeholder = derivePlaceholder(shape)
38
+ this.doc = doc
39
+
40
+ validatePlaceholder(this.placeholder, this.shape)
41
+ }
42
+
43
+ /**
44
+ * Returns a read-only, live view of the document.
45
+ * Accessing properties on this object will read directly from the underlying CRDT.
46
+ * This is efficient (O(1) per access) and always up-to-date.
47
+ */
48
+ get value(): DeepReadonly<Infer<Shape>> {
49
+ return new DraftDoc({
50
+ shape: this.shape,
51
+ placeholder: this.placeholder as any,
52
+ doc: this.doc,
53
+ readonly: true,
54
+ }) as unknown as DeepReadonly<Infer<Shape>>
33
55
  }
34
56
 
35
- get value(): InferPlainType<Shape> {
57
+ /**
58
+ * Returns the full plain JavaScript object representation of the document.
59
+ * This is an expensive O(N) operation that serializes the entire document.
60
+ */
61
+ toJSON(): Infer<Shape> {
36
62
  const crdtValue = this.doc.toJSON()
37
- return overlayEmptyState(
63
+ return overlayPlaceholder(
38
64
  this.shape,
39
65
  crdtValue,
40
- this.emptyState as any,
41
- ) as InferPlainType<Shape>
66
+ this.placeholder as any,
67
+ ) as Infer<Shape>
42
68
  }
43
69
 
44
- change(fn: (draft: Draft<Shape>) => void): InferPlainType<Shape> {
45
- // Reuse existing DocumentDraft system with empty state integration
70
+ change(fn: (draft: Draft<Shape>) => void): Infer<Shape> {
71
+ // Reuse existing DocumentDraft system with placeholder integration
46
72
  const draft = new DraftDoc({
47
73
  shape: this.shape,
48
- emptyState: this.emptyState as any,
74
+ placeholder: this.placeholder as any,
49
75
  doc: this.doc,
50
76
  })
51
77
  fn(draft as unknown as Draft<Shape>)
52
78
  draft.absorbPlainValues()
53
79
  this.doc.commit()
54
- return this.value
80
+ return this.toJSON()
55
81
  }
56
82
 
57
83
  /**
@@ -69,10 +95,7 @@ export class TypedDoc<Shape extends DocShape> {
69
95
  * ])
70
96
  * ```
71
97
  */
72
- applyPatch(
73
- patch: JsonPatch,
74
- pathPrefix?: (string | number)[],
75
- ): InferPlainType<Shape> {
98
+ applyPatch(patch: JsonPatch, pathPrefix?: (string | number)[]): Infer<Shape> {
76
99
  return this.change(draft => {
77
100
  const applicator = new JsonPatchApplicator(draft)
78
101
 
@@ -107,8 +130,7 @@ export class TypedDoc<Shape extends DocShape> {
107
130
  // Factory function for TypedLoroDoc
108
131
  export function createTypedDoc<Shape extends DocShape>(
109
132
  shape: Shape,
110
- emptyState: InferEmptyStateType<Shape>,
111
133
  existingDoc?: LoroDoc,
112
134
  ): TypedDoc<Shape> {
113
- return new TypedDoc<Shape>(shape, emptyState, existingDoc || new LoroDoc())
135
+ return new TypedDoc<Shape>(shape, existingDoc || new LoroDoc())
114
136
  }
@@ -0,0 +1,188 @@
1
+ import { describe, expectTypeOf, it } from "vitest"
2
+ import type { ContainerShape, ValueShape } from "./shape.js"
3
+ import { Shape } from "./shape.js"
4
+ import type { Infer } from "./types.js"
5
+
6
+ describe("Infer type helper", () => {
7
+ it("infers DocShape plain type", () => {
8
+ const schema = Shape.doc({
9
+ title: Shape.text(),
10
+ count: Shape.counter(),
11
+ })
12
+
13
+ type Result = Infer<typeof schema>
14
+ expectTypeOf<Result>().toEqualTypeOf<{ title: string; count: number }>()
15
+ })
16
+
17
+ it("infers ContainerShape plain type (list)", () => {
18
+ const schema = Shape.list(Shape.plain.string())
19
+
20
+ type Result = Infer<typeof schema>
21
+ expectTypeOf<Result>().toEqualTypeOf<string[]>()
22
+ })
23
+
24
+ it("infers ValueShape plain type (object)", () => {
25
+ const schema = Shape.plain.object({
26
+ name: Shape.plain.string(),
27
+ age: Shape.plain.number(),
28
+ })
29
+
30
+ type Result = Infer<typeof schema>
31
+ expectTypeOf<Result>().toEqualTypeOf<{ name: string; age: number }>()
32
+ })
33
+
34
+ it("infers nested shapes", () => {
35
+ const schema = Shape.doc({
36
+ users: Shape.list(
37
+ Shape.map({
38
+ id: Shape.plain.string(),
39
+ profile: Shape.record(
40
+ Shape.plain.object({
41
+ bio: Shape.plain.string(),
42
+ }),
43
+ ),
44
+ }),
45
+ ),
46
+ })
47
+
48
+ type Result = Infer<typeof schema>
49
+ expectTypeOf<Result>().toEqualTypeOf<{
50
+ users: {
51
+ id: string
52
+ profile: Record<string, { bio: string }>
53
+ }[]
54
+ }>()
55
+ })
56
+
57
+ it("infers discriminated union plain type", () => {
58
+ const SessionStatusSchema = Shape.plain.discriminatedUnion("status", {
59
+ not_started: Shape.plain.object({
60
+ status: Shape.plain.string("not_started"),
61
+ }),
62
+ lobby: Shape.plain.object({
63
+ status: Shape.plain.string("lobby"),
64
+ lobbyPhase: Shape.plain.string("preparing", "typing"),
65
+ }),
66
+ active: Shape.plain.object({
67
+ status: Shape.plain.string("active"),
68
+ mode: Shape.plain.string("solo", "group"),
69
+ }),
70
+ paused: Shape.plain.object({
71
+ status: Shape.plain.string("paused"),
72
+ previousStatus: Shape.plain.string("lobby", "active"),
73
+ previousMode: Shape.plain.string("solo", "group"),
74
+ reason: Shape.plain.string(
75
+ "no_students",
76
+ "teacher_paused",
77
+ "assignment_empty",
78
+ ),
79
+ }),
80
+ ended: Shape.plain.object({
81
+ status: Shape.plain.string<"ended">("ended"),
82
+ }),
83
+ })
84
+
85
+ type Result = Infer<typeof SessionStatusSchema>
86
+
87
+ // The result should be a union of all variant types
88
+ type Expected =
89
+ | { status: "not_started" }
90
+ | { status: "lobby"; lobbyPhase: "preparing" | "typing" }
91
+ | { status: "active"; mode: "solo" | "group" }
92
+ | {
93
+ status: "paused"
94
+ previousStatus: "lobby" | "active"
95
+ previousMode: "solo" | "group"
96
+ reason: "no_students" | "teacher_paused" | "assignment_empty"
97
+ }
98
+ | { status: "ended" }
99
+
100
+ expectTypeOf<Result>().toEqualTypeOf<Expected>()
101
+ })
102
+
103
+ it("infers discriminated union inside a map container", () => {
104
+ const SessionStatusSchema = Shape.plain.discriminatedUnion("status", {
105
+ not_started: Shape.plain.object({
106
+ status: Shape.plain.string("not_started"),
107
+ }),
108
+ active: Shape.plain.object({
109
+ status: Shape.plain.string("active"),
110
+ mode: Shape.plain.string("solo", "group"),
111
+ }),
112
+ })
113
+
114
+ const SessionMetadataSchema = Shape.map({
115
+ sessionStartedAt: Shape.plain.number(),
116
+ sessionStatus: SessionStatusSchema,
117
+ })
118
+
119
+ type Result = Infer<typeof SessionMetadataSchema>
120
+
121
+ type Expected = {
122
+ sessionStartedAt: number
123
+ sessionStatus:
124
+ | { status: "not_started" }
125
+ | { status: "active"; mode: "solo" | "group" }
126
+ }
127
+
128
+ expectTypeOf<Result>().toEqualTypeOf<Expected>()
129
+ })
130
+
131
+ it("infers discriminated union inside a doc", () => {
132
+ const SessionStatusSchema = Shape.plain.discriminatedUnion("status", {
133
+ not_started: Shape.plain.object({
134
+ status: Shape.plain.string("not_started"),
135
+ }),
136
+ active: Shape.plain.object({
137
+ status: Shape.plain.string("active"),
138
+ }),
139
+ })
140
+
141
+ const DocSchema = Shape.doc({
142
+ metadata: Shape.map({
143
+ sessionStartedAt: Shape.plain.number(),
144
+ sessionStatus: SessionStatusSchema,
145
+ }),
146
+ })
147
+
148
+ type Result = Infer<typeof DocSchema>
149
+
150
+ type Expected = {
151
+ metadata: {
152
+ sessionStartedAt: number
153
+ sessionStatus: { status: "not_started" } | { status: "active" }
154
+ }
155
+ }
156
+
157
+ expectTypeOf<Result>().toEqualTypeOf<Expected>()
158
+ })
159
+
160
+ it("infers discriminated union type correctly when used with generic constraint", () => {
161
+ // This test verifies the fix for usePresence type inference
162
+ // The issue was that DiscriminatedUnionValueShape<any, any> in the ValueShape union
163
+ // caused type information to be lost when inferring through generic constraints
164
+ const ClientPresenceShape = Shape.plain.object({
165
+ type: Shape.plain.string("client"),
166
+ name: Shape.plain.string(),
167
+ })
168
+ const ServerPresenceShape = Shape.plain.object({
169
+ type: Shape.plain.string("server"),
170
+ tick: Shape.plain.number(),
171
+ })
172
+ const GamePresenceSchema = Shape.plain.discriminatedUnion("type", {
173
+ client: ClientPresenceShape,
174
+ server: ServerPresenceShape,
175
+ })
176
+
177
+ // Simulate the constraint used in usePresence: <S extends ContainerShape | ValueShape>
178
+ type TestInfer<S extends ContainerShape | ValueShape> = Infer<S>
179
+
180
+ type Result = TestInfer<typeof GamePresenceSchema>
181
+
182
+ type Expected =
183
+ | { type: "client"; name: string }
184
+ | { type: "server"; tick: number }
185
+
186
+ expectTypeOf<Result>().toEqualTypeOf<Expected>()
187
+ })
188
+ })