@loro-extended/change 1.1.0 → 3.0.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.
@@ -0,0 +1,322 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { createPathBuilder } from "./path-builder.js"
3
+ import { compileToJsonPath, hasWildcard } from "./path-compiler.js"
4
+ import { evaluatePathOnValue } from "./path-evaluator.js"
5
+ import { Shape } from "./shape.js"
6
+
7
+ describe("Path Selector DSL", () => {
8
+ const docShape = Shape.doc({
9
+ books: Shape.list(
10
+ Shape.struct({
11
+ title: Shape.text(),
12
+ price: Shape.plain.number(),
13
+ author: Shape.struct({
14
+ name: Shape.plain.string(),
15
+ }),
16
+ }),
17
+ ),
18
+ config: Shape.struct({
19
+ theme: Shape.plain.string(),
20
+ }),
21
+ users: Shape.record(
22
+ Shape.struct({
23
+ name: Shape.plain.string(),
24
+ score: Shape.counter(),
25
+ }),
26
+ ),
27
+ })
28
+
29
+ describe("createPathBuilder", () => {
30
+ it("should create a path builder for a doc shape", () => {
31
+ const builder = createPathBuilder(docShape)
32
+ expect(builder).toBeDefined()
33
+ expect(builder.books).toBeDefined()
34
+ expect(builder.config).toBeDefined()
35
+ expect(builder.users).toBeDefined()
36
+ })
37
+
38
+ it("should create path segments for simple property access", () => {
39
+ const builder = createPathBuilder(docShape)
40
+ const selector = builder.config.theme
41
+ expect(selector.__segments).toEqual([
42
+ { type: "property", key: "config" },
43
+ { type: "property", key: "theme" },
44
+ ])
45
+ })
46
+
47
+ it("should create path segments for $each on lists", () => {
48
+ const builder = createPathBuilder(docShape)
49
+ const selector = builder.books.$each.title
50
+ expect(selector.__segments).toEqual([
51
+ { type: "property", key: "books" },
52
+ { type: "each" },
53
+ { type: "property", key: "title" },
54
+ ])
55
+ })
56
+
57
+ it("should create path segments for $at on lists", () => {
58
+ const builder = createPathBuilder(docShape)
59
+ const selector = builder.books.$at(0).title
60
+ expect(selector.__segments).toEqual([
61
+ { type: "property", key: "books" },
62
+ { type: "index", index: 0 },
63
+ { type: "property", key: "title" },
64
+ ])
65
+ })
66
+
67
+ it("should create path segments for $first and $last", () => {
68
+ const builder = createPathBuilder(docShape)
69
+
70
+ const firstSelector = builder.books.$first.title
71
+ expect(firstSelector.__segments).toEqual([
72
+ { type: "property", key: "books" },
73
+ { type: "index", index: 0 },
74
+ { type: "property", key: "title" },
75
+ ])
76
+
77
+ const lastSelector = builder.books.$last.title
78
+ expect(lastSelector.__segments).toEqual([
79
+ { type: "property", key: "books" },
80
+ { type: "index", index: -1 },
81
+ { type: "property", key: "title" },
82
+ ])
83
+ })
84
+
85
+ it("should create path segments for $each on records", () => {
86
+ const builder = createPathBuilder(docShape)
87
+ const selector = builder.users.$each.name
88
+ expect(selector.__segments).toEqual([
89
+ { type: "property", key: "users" },
90
+ { type: "each" },
91
+ { type: "property", key: "name" },
92
+ ])
93
+ })
94
+
95
+ it("should create path segments for $key on records", () => {
96
+ const builder = createPathBuilder(docShape)
97
+ const selector = builder.users.$key("alice").name
98
+ expect(selector.__segments).toEqual([
99
+ { type: "property", key: "users" },
100
+ { type: "key", key: "alice" },
101
+ { type: "property", key: "name" },
102
+ ])
103
+ })
104
+
105
+ it("should support nested struct access", () => {
106
+ const builder = createPathBuilder(docShape)
107
+ const selector = builder.books.$each.author.name
108
+ expect(selector.__segments).toEqual([
109
+ { type: "property", key: "books" },
110
+ { type: "each" },
111
+ { type: "property", key: "author" },
112
+ { type: "property", key: "name" },
113
+ ])
114
+ })
115
+ })
116
+
117
+ describe("compileToJsonPath", () => {
118
+ it("should compile simple property path", () => {
119
+ const segments = [
120
+ { type: "property" as const, key: "config" },
121
+ { type: "property" as const, key: "theme" },
122
+ ]
123
+ expect(compileToJsonPath(segments)).toBe("$.config.theme")
124
+ })
125
+
126
+ it("should compile path with wildcard", () => {
127
+ const segments = [
128
+ { type: "property" as const, key: "books" },
129
+ { type: "each" as const },
130
+ { type: "property" as const, key: "title" },
131
+ ]
132
+ expect(compileToJsonPath(segments)).toBe("$.books[*].title")
133
+ })
134
+
135
+ it("should compile path with index", () => {
136
+ const segments = [
137
+ { type: "property" as const, key: "books" },
138
+ { type: "index" as const, index: 0 },
139
+ { type: "property" as const, key: "title" },
140
+ ]
141
+ expect(compileToJsonPath(segments)).toBe("$.books[0].title")
142
+ })
143
+
144
+ it("should compile path with negative index", () => {
145
+ const segments = [
146
+ { type: "property" as const, key: "books" },
147
+ { type: "index" as const, index: -1 },
148
+ { type: "property" as const, key: "title" },
149
+ ]
150
+ expect(compileToJsonPath(segments)).toBe("$.books[-1].title")
151
+ })
152
+
153
+ it("should compile path with key", () => {
154
+ const segments = [
155
+ { type: "property" as const, key: "users" },
156
+ { type: "key" as const, key: "alice" },
157
+ { type: "property" as const, key: "name" },
158
+ ]
159
+ expect(compileToJsonPath(segments)).toBe('$.users["alice"].name')
160
+ })
161
+
162
+ it("should use bracket notation for special characters", () => {
163
+ const segments = [{ type: "property" as const, key: "my-key" }]
164
+ expect(compileToJsonPath(segments)).toBe('$["my-key"]')
165
+ })
166
+ })
167
+
168
+ describe("hasWildcard", () => {
169
+ it("should return true for paths with $each", () => {
170
+ const segments = [
171
+ { type: "property" as const, key: "books" },
172
+ { type: "each" as const },
173
+ { type: "property" as const, key: "title" },
174
+ ]
175
+ expect(hasWildcard(segments)).toBe(true)
176
+ })
177
+
178
+ it("should return false for paths without $each", () => {
179
+ const segments = [
180
+ { type: "property" as const, key: "config" },
181
+ { type: "property" as const, key: "theme" },
182
+ ]
183
+ expect(hasWildcard(segments)).toBe(false)
184
+ })
185
+
186
+ it("should return false for paths with index", () => {
187
+ const segments = [
188
+ { type: "property" as const, key: "books" },
189
+ { type: "index" as const, index: 0 },
190
+ { type: "property" as const, key: "title" },
191
+ ]
192
+ expect(hasWildcard(segments)).toBe(false)
193
+ })
194
+ })
195
+
196
+ describe("evaluatePathOnValue", () => {
197
+ const testData = {
198
+ books: [
199
+ { title: "Book 1", price: 10, author: { name: "Author 1" } },
200
+ { title: "Book 2", price: 20, author: { name: "Author 2" } },
201
+ { title: "Book 3", price: 30, author: { name: "Author 3" } },
202
+ ],
203
+ config: { theme: "dark" },
204
+ users: {
205
+ alice: { name: "Alice", score: 100 },
206
+ bob: { name: "Bob", score: 200 },
207
+ },
208
+ }
209
+
210
+ it("should evaluate simple property path", () => {
211
+ const segments = [
212
+ { type: "property" as const, key: "config" },
213
+ { type: "property" as const, key: "theme" },
214
+ ]
215
+ expect(evaluatePathOnValue(testData, segments)).toBe("dark")
216
+ })
217
+
218
+ it("should evaluate path with wildcard on array", () => {
219
+ const segments = [
220
+ { type: "property" as const, key: "books" },
221
+ { type: "each" as const },
222
+ { type: "property" as const, key: "title" },
223
+ ]
224
+ expect(evaluatePathOnValue(testData, segments)).toEqual([
225
+ "Book 1",
226
+ "Book 2",
227
+ "Book 3",
228
+ ])
229
+ })
230
+
231
+ it("should evaluate path with positive index", () => {
232
+ const segments = [
233
+ { type: "property" as const, key: "books" },
234
+ { type: "index" as const, index: 1 },
235
+ { type: "property" as const, key: "title" },
236
+ ]
237
+ expect(evaluatePathOnValue(testData, segments)).toBe("Book 2")
238
+ })
239
+
240
+ it("should evaluate path with negative index", () => {
241
+ const segments = [
242
+ { type: "property" as const, key: "books" },
243
+ { type: "index" as const, index: -1 },
244
+ { type: "property" as const, key: "title" },
245
+ ]
246
+ expect(evaluatePathOnValue(testData, segments)).toBe("Book 3")
247
+
248
+ const segments2 = [
249
+ { type: "property" as const, key: "books" },
250
+ { type: "index" as const, index: -2 },
251
+ { type: "property" as const, key: "title" },
252
+ ]
253
+ expect(evaluatePathOnValue(testData, segments2)).toBe("Book 2")
254
+ })
255
+
256
+ it("should evaluate path with key on record", () => {
257
+ const segments = [
258
+ { type: "property" as const, key: "users" },
259
+ { type: "key" as const, key: "alice" },
260
+ { type: "property" as const, key: "name" },
261
+ ]
262
+ expect(evaluatePathOnValue(testData, segments)).toBe("Alice")
263
+ })
264
+
265
+ it("should evaluate path with wildcard on record", () => {
266
+ const segments = [
267
+ { type: "property" as const, key: "users" },
268
+ { type: "each" as const },
269
+ { type: "property" as const, key: "name" },
270
+ ]
271
+ const result = evaluatePathOnValue(testData, segments) as string[]
272
+ expect(result).toContain("Alice")
273
+ expect(result).toContain("Bob")
274
+ })
275
+
276
+ it("should evaluate nested path through wildcard", () => {
277
+ const segments = [
278
+ { type: "property" as const, key: "books" },
279
+ { type: "each" as const },
280
+ { type: "property" as const, key: "author" },
281
+ { type: "property" as const, key: "name" },
282
+ ]
283
+ expect(evaluatePathOnValue(testData, segments)).toEqual([
284
+ "Author 1",
285
+ "Author 2",
286
+ "Author 3",
287
+ ])
288
+ })
289
+
290
+ it("should return undefined for missing property", () => {
291
+ const segments = [{ type: "property" as const, key: "nonexistent" }]
292
+ expect(evaluatePathOnValue(testData, segments)).toBeUndefined()
293
+ })
294
+
295
+ it("should return undefined for out-of-bounds index", () => {
296
+ const segments = [
297
+ { type: "property" as const, key: "books" },
298
+ { type: "index" as const, index: 10 },
299
+ { type: "property" as const, key: "title" },
300
+ ]
301
+ expect(evaluatePathOnValue(testData, segments)).toBeUndefined()
302
+ })
303
+
304
+ it("should return undefined for out-of-bounds negative index", () => {
305
+ const segments = [
306
+ { type: "property" as const, key: "books" },
307
+ { type: "index" as const, index: -10 },
308
+ { type: "property" as const, key: "title" },
309
+ ]
310
+ expect(evaluatePathOnValue(testData, segments)).toBeUndefined()
311
+ })
312
+
313
+ it("should return empty array for wildcard on empty array", () => {
314
+ const segments = [
315
+ { type: "property" as const, key: "books" },
316
+ { type: "each" as const },
317
+ { type: "property" as const, key: "title" },
318
+ ]
319
+ expect(evaluatePathOnValue({ books: [] }, segments)).toEqual([])
320
+ })
321
+ })
322
+ })
@@ -0,0 +1,131 @@
1
+ // ============================================================================
2
+ // Type-Safe Path Selector DSL
3
+ // ============================================================================
4
+ //
5
+ // This module provides type definitions for a type-safe path selector DSL
6
+ // that compiles to JSONPath strings for WASM-side filtering.
7
+ //
8
+ // See plans/typed-path-selector-dsl.md for full design documentation.
9
+
10
+ import type {
11
+ ContainerOrValueShape,
12
+ CounterContainerShape,
13
+ DocShape,
14
+ ListContainerShape,
15
+ MovableListContainerShape,
16
+ RecordContainerShape,
17
+ StructContainerShape,
18
+ TextContainerShape,
19
+ ValueShape,
20
+ } from "./shape.js"
21
+ import type { Infer } from "./types.js"
22
+
23
+ // ============================================================================
24
+ // Path Segment Types
25
+ // ============================================================================
26
+
27
+ export type PathSegment =
28
+ | { type: "property"; key: string }
29
+ | { type: "each" } // Wildcard for arrays/records
30
+ | { type: "index"; index: number } // Specific array index (supports negative)
31
+ | { type: "key"; key: string } // Specific record key
32
+
33
+ // ============================================================================
34
+ // Path Selector (carries type and segments)
35
+ // ============================================================================
36
+
37
+ export interface PathSelector<T> {
38
+ readonly __resultType: T // Phantom type for inference
39
+ readonly __segments: PathSegment[] // Runtime path data
40
+ }
41
+
42
+ // ============================================================================
43
+ // Path Node Types (for each container type)
44
+ // ============================================================================
45
+
46
+ // List path node - InArray tracks if we're inside a wildcard
47
+ interface ListPathNode<
48
+ Item extends ContainerOrValueShape,
49
+ InArray extends boolean,
50
+ > extends PathSelector<WrapType<Infer<Item>[], InArray>> {
51
+ /** Select all items (wildcard) - sets InArray to true for children */
52
+ readonly $each: PathNode<Item, true>
53
+ /** Select item at specific index (supports negative indices: -1 = last, -2 = second-to-last, etc.) */
54
+ $at(index: number): PathNode<Item, InArray>
55
+ /** Select first item (alias for $at(0)) */
56
+ readonly $first: PathNode<Item, InArray>
57
+ /** Select last item (alias for $at(-1)) */
58
+ readonly $last: PathNode<Item, InArray>
59
+ }
60
+
61
+ // Struct path node (fixed keys) - propagates InArray to children
62
+ type StructPathNode<
63
+ Shapes extends Record<string, ContainerOrValueShape>,
64
+ InArray extends boolean,
65
+ > = PathSelector<
66
+ WrapType<{ [K in keyof Shapes]: Infer<Shapes[K]> }, InArray>
67
+ > & {
68
+ readonly [K in keyof Shapes]: PathNode<Shapes[K], InArray>
69
+ }
70
+
71
+ // Record path node (dynamic keys) - propagates InArray to children
72
+ interface RecordPathNode<
73
+ Item extends ContainerOrValueShape,
74
+ InArray extends boolean,
75
+ > extends PathSelector<WrapType<Record<string, Infer<Item>>, InArray>> {
76
+ /** Select all values (wildcard) - sets InArray to true for children */
77
+ readonly $each: PathNode<Item, true>
78
+ /** Select value at specific key */
79
+ $key(key: string): PathNode<Item, InArray>
80
+ }
81
+
82
+ // Text path node (terminal)
83
+ type TextPathNode<InArray extends boolean> = PathSelector<
84
+ WrapType<string, InArray>
85
+ >
86
+
87
+ // Counter path node (terminal)
88
+ type CounterPathNode<InArray extends boolean> = PathSelector<
89
+ WrapType<number, InArray>
90
+ >
91
+
92
+ // Terminal node for primitive values
93
+ type TerminalPathNode<T, InArray extends boolean> = PathSelector<
94
+ WrapType<T, InArray>
95
+ >
96
+
97
+ // ============================================================================
98
+ // PathNode Type Mapping
99
+ // ============================================================================
100
+
101
+ // Helper: wrap type in array if InArray is true
102
+ type WrapType<T, InArray extends boolean> = InArray extends true ? T[] : T
103
+
104
+ // InArray tracks whether we've passed through a wildcard ($each)
105
+ // This affects the result type: T vs T[]
106
+ export type PathNode<
107
+ S extends ContainerOrValueShape,
108
+ InArray extends boolean,
109
+ > = S extends ListContainerShape<infer Item>
110
+ ? ListPathNode<Item, InArray>
111
+ : S extends MovableListContainerShape<infer Item>
112
+ ? ListPathNode<Item, InArray>
113
+ : S extends StructContainerShape<infer Shapes>
114
+ ? StructPathNode<Shapes, InArray>
115
+ : S extends RecordContainerShape<infer Item>
116
+ ? RecordPathNode<Item, InArray>
117
+ : S extends TextContainerShape
118
+ ? TextPathNode<InArray>
119
+ : S extends CounterContainerShape
120
+ ? CounterPathNode<InArray>
121
+ : S extends ValueShape
122
+ ? TerminalPathNode<Infer<S>, InArray>
123
+ : never
124
+
125
+ // ============================================================================
126
+ // Path Builder (entry point)
127
+ // ============================================================================
128
+
129
+ export type PathBuilder<D extends DocShape> = {
130
+ readonly [K in keyof D["shapes"]]: PathNode<D["shapes"][K], false>
131
+ }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
+ import { change } from "./functional-helpers.js"
2
3
  import { Shape } from "./shape.js"
3
4
  import { createTypedDoc } from "./typed-doc.js"
4
5
 
@@ -14,7 +15,7 @@ describe("TypedDoc Mutable Mode", () => {
14
15
  it("should read values correctly", () => {
15
16
  const doc = createTypedDoc(schema)
16
17
 
17
- doc.$.change(d => {
18
+ change(doc, d => {
18
19
  d.meta.count = 1
19
20
  d.meta.title = "updated"
20
21
  d.list.push("item1")
@@ -33,7 +34,7 @@ describe("TypedDoc Mutable Mode", () => {
33
34
 
34
35
  expect(liveMeta.count).toBe(0)
35
36
 
36
- doc.$.change(d => {
37
+ change(doc, d => {
37
38
  d.meta.count = 5
38
39
  })
39
40
 
@@ -58,7 +59,7 @@ describe("TypedDoc Mutable Mode", () => {
58
59
  it("should support change() for grouped mutations", () => {
59
60
  const doc = createTypedDoc(schema)
60
61
 
61
- doc.$.change(d => {
62
+ change(doc, d => {
62
63
  d.meta.count = 1
63
64
  d.meta.title = "batched"
64
65
  d.list.push("a")
@@ -74,7 +75,7 @@ describe("TypedDoc Mutable Mode", () => {
74
75
  it("should support toJSON for full serialization", () => {
75
76
  const doc = createTypedDoc(schema)
76
77
 
77
- doc.$.change(d => {
78
+ change(doc, d => {
78
79
  d.meta.count = 1
79
80
  d.meta.title = "json"
80
81
  d.list.push("a")
package/src/shape.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  LoroMovableList,
8
8
  LoroText,
9
9
  LoroTree,
10
+ Value,
10
11
  } from "loro-crdt"
11
12
 
12
13
  import type { CounterRef } from "./typed-refs/counter.js"
@@ -128,7 +129,24 @@ export interface RecordContainerShape<
128
129
  readonly shape: NestedShape
129
130
  }
130
131
 
132
+ /**
133
+ * Container escape hatch - represents "any LoroContainer".
134
+ * Use this when integrating with external libraries that manage their own document structure.
135
+ *
136
+ * @example
137
+ * ```typescript
138
+ * // loro-prosemirror manages its own structure
139
+ * const ProseMirrorDocShape = Shape.doc({
140
+ * doc: Shape.any(), // opt out of typing for this container
141
+ * })
142
+ * ```
143
+ */
144
+ export interface AnyContainerShape extends Shape<unknown, unknown, undefined> {
145
+ readonly _type: "any"
146
+ }
147
+
131
148
  export type ContainerShape =
149
+ | AnyContainerShape
132
150
  | CounterContainerShape
133
151
  | ListContainerShape
134
152
  | MovableListContainerShape
@@ -250,8 +268,25 @@ export interface DiscriminatedUnionValueShape<
250
268
  readonly variants: T
251
269
  }
252
270
 
271
+ /**
272
+ * Value escape hatch - represents "any Loro Value".
273
+ * Use this when you need to accept any valid Loro value type.
274
+ *
275
+ * @example
276
+ * ```typescript
277
+ * const FlexiblePresenceShape = Shape.plain.struct({
278
+ * cursor: Shape.plain.any(), // accept any value type
279
+ * })
280
+ * ```
281
+ */
282
+ export interface AnyValueShape extends Shape<Value, Value, undefined> {
283
+ readonly _type: "value"
284
+ readonly valueType: "any"
285
+ }
286
+
253
287
  // Union of all ValueShapes - these can only contain other ValueShapes, not ContainerShapes
254
288
  export type ValueShape =
289
+ | AnyValueShape
255
290
  | StringValueShape
256
291
  | NumberValueShape
257
292
  | BooleanValueShape
@@ -317,6 +352,28 @@ export const Shape = {
317
352
  _placeholder: {} as any,
318
353
  }),
319
354
 
355
+ /**
356
+ * Creates an "any" container shape - an escape hatch for untyped containers.
357
+ * Use this when integrating with external libraries that manage their own document structure.
358
+ *
359
+ * @example
360
+ * ```typescript
361
+ * // loro-prosemirror manages its own structure
362
+ * const ProseMirrorDocShape = Shape.doc({
363
+ * doc: Shape.any(), // opt out of typing for this container
364
+ * })
365
+ *
366
+ * const handle = repo.get(docId, ProseMirrorDocShape, CursorPresenceShape)
367
+ * // handle.doc.doc is typed as `unknown` - you're on your own
368
+ * ```
369
+ */
370
+ any: (): AnyContainerShape => ({
371
+ _type: "any" as const,
372
+ _plain: undefined as unknown,
373
+ _mutable: undefined as unknown,
374
+ _placeholder: undefined,
375
+ }),
376
+
320
377
  // CRDTs are represented by Loro Containers--they converge on state using Loro's
321
378
  // various CRDT algorithms
322
379
  counter: (): WithPlaceholder<CounterContainerShape> => {
@@ -509,12 +566,70 @@ export const Shape = {
509
566
  _placeholder: undefined,
510
567
  }),
511
568
 
512
- uint8Array: (): Uint8ArrayValueShape => ({
569
+ uint8Array: (): Uint8ArrayValueShape &
570
+ WithNullable<Uint8ArrayValueShape> => {
571
+ const base: Uint8ArrayValueShape = {
572
+ _type: "value" as const,
573
+ valueType: "uint8array" as const,
574
+ _plain: new Uint8Array(),
575
+ _mutable: new Uint8Array(),
576
+ _placeholder: new Uint8Array(),
577
+ }
578
+ return Object.assign(base, {
579
+ nullable(): WithPlaceholder<
580
+ UnionValueShape<[NullValueShape, Uint8ArrayValueShape]>
581
+ > {
582
+ return makeNullable(base)
583
+ },
584
+ })
585
+ },
586
+
587
+ /**
588
+ * Alias for `uint8Array()` - creates a shape for binary data.
589
+ * Use this for better discoverability when working with binary data like cursor positions.
590
+ *
591
+ * @example
592
+ * ```typescript
593
+ * const CursorPresenceShape = Shape.plain.struct({
594
+ * anchor: Shape.plain.bytes().nullable(),
595
+ * focus: Shape.plain.bytes().nullable(),
596
+ * })
597
+ * ```
598
+ */
599
+ bytes: (): Uint8ArrayValueShape & WithNullable<Uint8ArrayValueShape> => {
600
+ const base: Uint8ArrayValueShape = {
601
+ _type: "value" as const,
602
+ valueType: "uint8array" as const,
603
+ _plain: new Uint8Array(),
604
+ _mutable: new Uint8Array(),
605
+ _placeholder: new Uint8Array(),
606
+ }
607
+ return Object.assign(base, {
608
+ nullable(): WithPlaceholder<
609
+ UnionValueShape<[NullValueShape, Uint8ArrayValueShape]>
610
+ > {
611
+ return makeNullable(base)
612
+ },
613
+ })
614
+ },
615
+
616
+ /**
617
+ * Creates an "any" value shape - an escape hatch for untyped values.
618
+ * Use this when you need to accept any valid Loro value type.
619
+ *
620
+ * @example
621
+ * ```typescript
622
+ * const FlexiblePresenceShape = Shape.plain.struct({
623
+ * metadata: Shape.plain.any(), // accept any value type
624
+ * })
625
+ * ```
626
+ */
627
+ any: (): AnyValueShape => ({
513
628
  _type: "value" as const,
514
- valueType: "uint8array" as const,
515
- _plain: new Uint8Array(),
516
- _mutable: new Uint8Array(),
517
- _placeholder: new Uint8Array(),
629
+ valueType: "any" as const,
630
+ _plain: undefined as unknown as Value,
631
+ _mutable: undefined as unknown as Value,
632
+ _placeholder: undefined,
518
633
  }),
519
634
 
520
635
  /**
@@ -19,6 +19,12 @@ export abstract class TypedRef<Shape extends DocShape | ContainerShape> {
19
19
 
20
20
  abstract absorbPlainValues(): void
21
21
 
22
+ /**
23
+ * Serializes the ref to a plain JSON-compatible value.
24
+ * Returns the plain type inferred from the shape.
25
+ */
26
+ abstract toJSON(): Infer<Shape>
27
+
22
28
  protected get shape(): Shape {
23
29
  return this._params.shape
24
30
  }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
+ import { change } from "../functional-helpers.js"
2
3
  import { Shape } from "../shape.js"
3
4
  import { createTypedDoc } from "../typed-doc.js"
4
5
 
@@ -57,7 +58,7 @@ describe("Counter Ref", () => {
57
58
  })
58
59
  const doc = createTypedDoc(schema)
59
60
 
60
- doc.$.change(draft => {
61
+ change(doc, draft => {
61
62
  draft.counter.increment(5)
62
63
  })
63
64