@loro-extended/change 0.8.1 → 0.9.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.
Files changed (35) hide show
  1. package/README.md +78 -0
  2. package/dist/index.d.ts +190 -39
  3. package/dist/index.js +480 -295
  4. package/dist/index.js.map +1 -1
  5. package/package.json +1 -1
  6. package/src/change.test.ts +277 -1
  7. package/src/discriminated-union-assignability.test.ts +45 -0
  8. package/src/discriminated-union-tojson.test.ts +128 -0
  9. package/src/index.ts +7 -0
  10. package/src/placeholder-proxy.test.ts +52 -0
  11. package/src/placeholder-proxy.ts +37 -0
  12. package/src/presence-interface.ts +52 -0
  13. package/src/shape.ts +44 -50
  14. package/src/typed-doc.ts +4 -4
  15. package/src/typed-presence.ts +96 -0
  16. package/src/{draft-nodes → typed-refs}/base.ts +4 -4
  17. package/src/{draft-nodes → typed-refs}/counter.test.ts +1 -1
  18. package/src/{draft-nodes → typed-refs}/counter.ts +9 -3
  19. package/src/{draft-nodes → typed-refs}/doc.ts +27 -13
  20. package/src/typed-refs/json-compatibility.test.ts +255 -0
  21. package/src/{draft-nodes → typed-refs}/list-base.ts +79 -30
  22. package/src/{draft-nodes → typed-refs}/list.test.ts +1 -1
  23. package/src/{draft-nodes → typed-refs}/list.ts +4 -4
  24. package/src/{draft-nodes → typed-refs}/map.ts +33 -22
  25. package/src/{draft-nodes → typed-refs}/movable-list.test.ts +1 -1
  26. package/src/{draft-nodes → typed-refs}/movable-list.ts +6 -6
  27. package/src/{draft-nodes → typed-refs}/proxy-handlers.ts +25 -26
  28. package/src/{draft-nodes → typed-refs}/record.test.ts +69 -0
  29. package/src/{draft-nodes → typed-refs}/record.ts +50 -21
  30. package/src/{draft-nodes → typed-refs}/text.ts +13 -3
  31. package/src/{draft-nodes → typed-refs}/tree.ts +6 -3
  32. package/src/{draft-nodes → typed-refs}/utils.ts +23 -27
  33. package/src/types.test.ts +97 -2
  34. package/src/types.ts +62 -5
  35. package/src/draft-nodes/counter.md +0 -31
@@ -1,8 +1,8 @@
1
- import type { ListDraftNode } from "./list.js"
2
- import type { MovableListDraftNode } from "./movable-list.js"
3
- import type { RecordDraftNode } from "./record.js"
1
+ import type { ListRef } from "./list.js"
2
+ import type { MovableListRef } from "./movable-list.js"
3
+ import type { RecordRef } from "./record.js"
4
4
 
5
- export const recordProxyHandler: ProxyHandler<RecordDraftNode<any>> = {
5
+ export const recordProxyHandler: ProxyHandler<RecordRef<any>> = {
6
6
  get: (target, prop) => {
7
7
  if (typeof prop === "string" && !(prop in target)) {
8
8
  return target.get(prop)
@@ -38,7 +38,7 @@ export const recordProxyHandler: ProxyHandler<RecordDraftNode<any>> = {
38
38
  },
39
39
  }
40
40
 
41
- export const listProxyHandler: ProxyHandler<ListDraftNode<any>> = {
41
+ export const listProxyHandler: ProxyHandler<ListRef<any>> = {
42
42
  get: (target, prop) => {
43
43
  if (typeof prop === "string") {
44
44
  const index = Number(prop)
@@ -62,26 +62,25 @@ export const listProxyHandler: ProxyHandler<ListDraftNode<any>> = {
62
62
  },
63
63
  }
64
64
 
65
- export const movableListProxyHandler: ProxyHandler<MovableListDraftNode<any>> =
66
- {
67
- get: (target, prop) => {
68
- if (typeof prop === "string") {
69
- const index = Number(prop)
70
- if (!Number.isNaN(index)) {
71
- return target.get(index)
72
- }
65
+ export const movableListProxyHandler: ProxyHandler<MovableListRef<any>> = {
66
+ get: (target, prop) => {
67
+ if (typeof prop === "string") {
68
+ const index = Number(prop)
69
+ if (!Number.isNaN(index)) {
70
+ return target.get(index)
73
71
  }
74
- return Reflect.get(target, prop)
75
- },
76
- set: (target, prop, value) => {
77
- if (typeof prop === "string") {
78
- const index = Number(prop)
79
- if (!Number.isNaN(index)) {
80
- // MovableList supports set directly
81
- target.set(index, value)
82
- return true
83
- }
72
+ }
73
+ return Reflect.get(target, prop)
74
+ },
75
+ set: (target, prop, value) => {
76
+ if (typeof prop === "string") {
77
+ const index = Number(prop)
78
+ if (!Number.isNaN(index)) {
79
+ // MovableList supports set directly
80
+ target.set(index, value)
81
+ return true
84
82
  }
85
- return Reflect.set(target, prop, value)
86
- },
87
- }
83
+ }
84
+ return Reflect.set(target, prop, value)
85
+ },
86
+ }
@@ -266,4 +266,73 @@ describe("Record Types", () => {
266
266
  expect(doc.toJSON().histories["user1"]).toEqual(["c"])
267
267
  })
268
268
  })
269
+
270
+ describe("Readonly access to non-existent keys", () => {
271
+ it("should not throw 'placeholder required' when accessing nested map values in a record", () => {
272
+ // This schema mirrors a real-world scenario:
273
+ // preferences: Record<string, { showTip: boolean }>
274
+ const schema = Shape.doc({
275
+ preferences: Shape.record(
276
+ Shape.map({
277
+ showTip: Shape.plain.boolean(),
278
+ }),
279
+ ),
280
+ })
281
+
282
+ const doc = new TypedDoc(schema)
283
+
284
+ // First, set a value for a specific peer
285
+ doc.change(d => {
286
+ d.preferences.peer1 = { showTip: true }
287
+ })
288
+
289
+ // This should work - accessing an existing key
290
+ expect(doc.value.preferences.peer1?.showTip).toBe(true)
291
+
292
+ // Accessing a non-existent key should NOT throw "placeholder required"
293
+ // It should return undefined so optional chaining works correctly
294
+ expect(() => {
295
+ const result = doc.value.preferences.nonexistent?.showTip
296
+ return result
297
+ }).not.toThrow()
298
+ })
299
+
300
+ it("should return undefined for non-existent record keys in readonly mode", () => {
301
+ const schema = Shape.doc({
302
+ preferences: Shape.record(
303
+ Shape.map({
304
+ showTip: Shape.plain.boolean(),
305
+ }),
306
+ ),
307
+ })
308
+
309
+ const doc = new TypedDoc(schema)
310
+
311
+ // Access a key that doesn't exist - should return undefined
312
+ const prefs = doc.value.preferences.nonexistent
313
+ expect(prefs).toBeUndefined()
314
+ })
315
+
316
+ it("should work with the exact user scenario pattern", () => {
317
+ // Exact reproduction of a user's schema and access pattern
318
+ const schema = Shape.doc({
319
+ preferences: Shape.record(
320
+ Shape.map({
321
+ showTip: Shape.plain.boolean(),
322
+ }),
323
+ ),
324
+ })
325
+
326
+ const doc = new TypedDoc(schema)
327
+ const myPeerId = "some-peer-id"
328
+
329
+ // This is the exact code pattern from the user's app:
330
+ // doc.preferences[myPeerId]?.showTip !== false
331
+ expect(() => {
332
+ const showTip = doc.value.preferences[myPeerId]?.showTip
333
+ const result = showTip !== false
334
+ return result
335
+ }).not.toThrow()
336
+ })
337
+ })
269
338
  })
@@ -8,6 +8,7 @@ import {
8
8
  LoroTree,
9
9
  type Value,
10
10
  } from "loro-crdt"
11
+ import { deriveShapePlaceholder } from "../derive-placeholder.js"
11
12
  import type {
12
13
  ContainerOrValueShape,
13
14
  ContainerShape,
@@ -15,11 +16,8 @@ import type {
15
16
  } from "../shape.js"
16
17
  import type { Infer, InferDraftType } from "../types.js"
17
18
  import { isContainerShape, isValueShape } from "../utils/type-guards.js"
18
- import { DraftNode, type DraftNodeParams } from "./base.js"
19
- import {
20
- assignPlainValueToDraftNode,
21
- createContainerDraftNode,
22
- } from "./utils.js"
19
+ import { TypedRef, type TypedRefParams } from "./base.js"
20
+ import { assignPlainValueToTypedRef, createContainerTypedRef } from "./utils.js"
23
21
 
24
22
  const containerConstructor = {
25
23
  counter: LoroCounter,
@@ -31,12 +29,12 @@ const containerConstructor = {
31
29
  tree: LoroTree,
32
30
  } as const
33
31
 
34
- // Record draft node
35
- export class RecordDraftNode<
32
+ // Record typed ref
33
+ export class RecordRef<
36
34
  NestedShape extends ContainerOrValueShape,
37
- > extends DraftNode<any> {
35
+ > extends TypedRef<any> {
38
36
  [key: string]: Infer<NestedShape> | any
39
- private nodeCache = new Map<string, DraftNode<ContainerShape> | Value>()
37
+ private nodeCache = new Map<string, TypedRef<ContainerShape> | Value>()
40
38
 
41
39
  protected get shape(): RecordContainerShape<NestedShape> {
42
40
  return super.shape as RecordContainerShape<NestedShape>
@@ -48,8 +46,8 @@ export class RecordDraftNode<
48
46
 
49
47
  absorbPlainValues() {
50
48
  for (const [key, node] of this.nodeCache.entries()) {
51
- if (node instanceof DraftNode) {
52
- // Contains a DraftNode, not a plain Value: keep recursing
49
+ if (node instanceof TypedRef) {
50
+ // Contains a TypedRef, not a plain Value: keep recursing
53
51
  node.absorbPlainValues()
54
52
  continue
55
53
  }
@@ -59,11 +57,19 @@ export class RecordDraftNode<
59
57
  }
60
58
  }
61
59
 
62
- getDraftNodeParams<S extends ContainerShape>(
60
+ getTypedRefParams<S extends ContainerShape>(
63
61
  key: string,
64
62
  shape: S,
65
- ): DraftNodeParams<ContainerShape> {
66
- const placeholder = (this.placeholder as any)?.[key]
63
+ ): TypedRefParams<ContainerShape> {
64
+ // First try to get placeholder from the Record's placeholder (if it has an entry for this key)
65
+ let placeholder = (this.placeholder as any)?.[key]
66
+
67
+ // If no placeholder exists for this key, derive one from the schema's shape
68
+ // This is critical for Records where the placeholder is always {} but nested
69
+ // containers need valid placeholders to fall back to for missing values
70
+ if (placeholder === undefined) {
71
+ placeholder = deriveShapePlaceholder(shape)
72
+ }
67
73
 
68
74
  const LoroContainer = containerConstructor[shape._type]
69
75
 
@@ -77,12 +83,22 @@ export class RecordDraftNode<
77
83
  }
78
84
 
79
85
  getOrCreateNode(key: string): any {
86
+ // For readonly mode with container shapes, check if the key exists first
87
+ // This allows optional chaining (?.) to work correctly for non-existent keys
88
+ // Similar to how ListRefBase.getMutableItem() handles non-existent indices
89
+ if (this.readonly && isContainerShape(this.shape.shape)) {
90
+ const existing = this.container.get(key)
91
+ if (existing === undefined) {
92
+ return undefined
93
+ }
94
+ }
95
+
80
96
  let node = this.nodeCache.get(key)
81
97
  if (!node) {
82
98
  const shape = this.shape.shape
83
99
  if (isContainerShape(shape)) {
84
- node = createContainerDraftNode(
85
- this.getDraftNodeParams(key, shape as ContainerShape),
100
+ node = createContainerTypedRef(
101
+ this.getTypedRefParams(key, shape as ContainerShape),
86
102
  )
87
103
  // Cache container nodes
88
104
  this.nodeCache.set(key, node)
@@ -127,7 +143,7 @@ export class RecordDraftNode<
127
143
  }
128
144
 
129
145
  set(key: string, value: any): void {
130
- if (this.readonly) throw new Error("Cannot modify readonly doc")
146
+ if (this.readonly) throw new Error("Cannot modify readonly ref")
131
147
  if (isValueShape(this.shape.shape)) {
132
148
  this.container.set(key, value)
133
149
  this.nodeCache.set(key, value)
@@ -137,24 +153,24 @@ export class RecordDraftNode<
137
153
  if (value && typeof value === "object") {
138
154
  const node = this.getOrCreateNode(key)
139
155
 
140
- if (assignPlainValueToDraftNode(node, value)) {
156
+ if (assignPlainValueToTypedRef(node, value)) {
141
157
  return
142
158
  }
143
159
  }
144
160
 
145
161
  throw new Error(
146
- "Cannot set container directly, modify the draft node instead",
162
+ "Cannot set container directly, modify the typed ref instead",
147
163
  )
148
164
  }
149
165
  }
150
166
 
151
167
  setContainer<C extends Container>(key: string, container: C): C {
152
- if (this.readonly) throw new Error("Cannot modify readonly doc")
168
+ if (this.readonly) throw new Error("Cannot modify readonly ref")
153
169
  return this.container.setContainer(key, container)
154
170
  }
155
171
 
156
172
  delete(key: string): void {
157
- if (this.readonly) throw new Error("Cannot modify readonly doc")
173
+ if (this.readonly) throw new Error("Cannot modify readonly ref")
158
174
  this.container.delete(key)
159
175
  this.nodeCache.delete(key)
160
176
  }
@@ -174,4 +190,17 @@ export class RecordDraftNode<
174
190
  get size(): number {
175
191
  return this.container.size
176
192
  }
193
+
194
+ toJSON(): Record<string, any> {
195
+ const result: Record<string, any> = {}
196
+ for (const key of this.keys()) {
197
+ const value = this.get(key)
198
+ if (value && typeof value === "object" && "toJSON" in value) {
199
+ result[key] = (value as any).toJSON()
200
+ } else {
201
+ result[key] = value
202
+ }
203
+ }
204
+ return result
205
+ }
177
206
  }
@@ -1,18 +1,20 @@
1
1
  import type { TextContainerShape } from "../shape.js"
2
- import { DraftNode } from "./base.js"
2
+ import { TypedRef } from "./base.js"
3
3
 
4
- // Text draft node
5
- export class TextDraftNode extends DraftNode<TextContainerShape> {
4
+ // Text typed ref
5
+ export class TextRef extends TypedRef<TextContainerShape> {
6
6
  absorbPlainValues() {
7
7
  // no plain values contained within
8
8
  }
9
9
 
10
10
  // Text methods
11
11
  insert(index: number, content: string): void {
12
+ if (this.readonly) throw new Error("Cannot modify readonly ref")
12
13
  this.container.insert(index, content)
13
14
  }
14
15
 
15
16
  delete(index: number, len: number): void {
17
+ if (this.readonly) throw new Error("Cannot modify readonly ref")
16
18
  this.container.delete(index, len)
17
19
  }
18
20
 
@@ -20,15 +22,22 @@ export class TextDraftNode extends DraftNode<TextContainerShape> {
20
22
  return this.container.toString()
21
23
  }
22
24
 
25
+ toJSON(): string {
26
+ return this.toString()
27
+ }
28
+
23
29
  update(text: string): void {
30
+ if (this.readonly) throw new Error("Cannot modify readonly ref")
24
31
  this.container.update(text)
25
32
  }
26
33
 
27
34
  mark(range: { start: number; end: number }, key: string, value: any): void {
35
+ if (this.readonly) throw new Error("Cannot modify readonly ref")
28
36
  this.container.mark(range, key, value)
29
37
  }
30
38
 
31
39
  unmark(range: { start: number; end: number }, key: string): void {
40
+ if (this.readonly) throw new Error("Cannot modify readonly ref")
32
41
  this.container.unmark(range, key)
33
42
  }
34
43
 
@@ -37,6 +46,7 @@ export class TextDraftNode extends DraftNode<TextContainerShape> {
37
46
  }
38
47
 
39
48
  applyDelta(delta: any[]): void {
49
+ if (this.readonly) throw new Error("Cannot modify readonly ref")
40
50
  this.container.applyDelta(delta)
41
51
  }
42
52
 
@@ -1,21 +1,24 @@
1
1
  import type { TreeContainerShape } from "../shape.js"
2
- import { DraftNode } from "./base.js"
2
+ import { TypedRef } from "./base.js"
3
3
 
4
- // Tree draft node
5
- export class TreeDraftNode<T extends TreeContainerShape> extends DraftNode<T> {
4
+ // Tree typed ref
5
+ export class TreeRef<T extends TreeContainerShape> extends TypedRef<T> {
6
6
  absorbPlainValues() {
7
7
  // TODO(duane): implement for trees
8
8
  }
9
9
 
10
10
  createNode(parent?: any, index?: number): any {
11
+ if (this.readonly) throw new Error("Cannot modify readonly ref")
11
12
  return this.container.createNode(parent, index)
12
13
  }
13
14
 
14
15
  move(target: any, parent?: any, index?: number): void {
16
+ if (this.readonly) throw new Error("Cannot modify readonly ref")
15
17
  this.container.move(target, parent, index)
16
18
  }
17
19
 
18
20
  delete(target: any): void {
21
+ if (this.readonly) throw new Error("Cannot modify readonly ref")
19
22
  this.container.delete(target)
20
23
  }
21
24
 
@@ -8,57 +8,53 @@ import type {
8
8
  TextContainerShape,
9
9
  TreeContainerShape,
10
10
  } from "../shape.js"
11
- import type { DraftNode, DraftNodeParams } from "./base.js"
12
- import { CounterDraftNode } from "./counter.js"
13
- import { ListDraftNode } from "./list.js"
14
- import { MapDraftNode } from "./map.js"
15
- import { MovableListDraftNode } from "./movable-list.js"
11
+ import type { TypedRef, TypedRefParams } from "./base.js"
12
+ import { CounterRef } from "./counter.js"
13
+ import { ListRef } from "./list.js"
14
+ import { MapRef } from "./map.js"
15
+ import { MovableListRef } from "./movable-list.js"
16
16
  import {
17
17
  listProxyHandler,
18
18
  movableListProxyHandler,
19
19
  recordProxyHandler,
20
20
  } from "./proxy-handlers.js"
21
- import { RecordDraftNode } from "./record.js"
22
- import { TextDraftNode } from "./text.js"
23
- import { TreeDraftNode } from "./tree.js"
21
+ import { RecordRef } from "./record.js"
22
+ import { TextRef } from "./text.js"
23
+ import { TreeRef } from "./tree.js"
24
24
 
25
25
  // Generic catch-all overload
26
- export function createContainerDraftNode<T extends ContainerShape>(
27
- params: DraftNodeParams<T>,
28
- ): DraftNode<T>
26
+ export function createContainerTypedRef<T extends ContainerShape>(
27
+ params: TypedRefParams<T>,
28
+ ): TypedRef<T>
29
29
 
30
30
  // Implementation
31
- export function createContainerDraftNode(
32
- params: DraftNodeParams<ContainerShape>,
33
- ): DraftNode<ContainerShape> {
31
+ export function createContainerTypedRef(
32
+ params: TypedRefParams<ContainerShape>,
33
+ ): TypedRef<ContainerShape> {
34
34
  switch (params.shape._type) {
35
35
  case "counter":
36
- return new CounterDraftNode(
37
- params as DraftNodeParams<CounterContainerShape>,
38
- )
36
+ return new CounterRef(params as TypedRefParams<CounterContainerShape>)
39
37
  case "list":
40
38
  return new Proxy(
41
- new ListDraftNode(params as DraftNodeParams<ListContainerShape>),
39
+ new ListRef(params as TypedRefParams<ListContainerShape>),
42
40
  listProxyHandler,
43
41
  )
44
42
  case "map":
45
- return new MapDraftNode(params as DraftNodeParams<MapContainerShape>)
43
+ return new MapRef(params as TypedRefParams<MapContainerShape>)
46
44
  case "movableList":
47
45
  return new Proxy(
48
- new MovableListDraftNode(
49
- params as DraftNodeParams<MovableListContainerShape>,
50
- ),
46
+ new MovableListRef(params as TypedRefParams<MovableListContainerShape>),
51
47
  movableListProxyHandler,
52
48
  )
53
49
  case "record":
54
50
  return new Proxy(
55
- new RecordDraftNode(params as DraftNodeParams<RecordContainerShape>),
51
+ new RecordRef(params as TypedRefParams<RecordContainerShape>),
56
52
  recordProxyHandler,
57
53
  )
58
54
  case "text":
59
- return new TextDraftNode(params as DraftNodeParams<TextContainerShape>)
55
+ return new TextRef(params as TypedRefParams<TextContainerShape>)
60
56
  case "tree":
61
- return new TreeDraftNode(params as DraftNodeParams<TreeContainerShape>)
57
+ return new TreeRef(params as TypedRefParams<TreeContainerShape>)
62
58
  default:
63
59
  throw new Error(
64
60
  `Unknown container type: ${(params.shape as ContainerShape)._type}`,
@@ -66,8 +62,8 @@ export function createContainerDraftNode(
66
62
  }
67
63
  }
68
64
 
69
- export function assignPlainValueToDraftNode(
70
- node: DraftNode<any>,
65
+ export function assignPlainValueToTypedRef(
66
+ node: TypedRef<any>,
71
67
  value: any,
72
68
  ): boolean {
73
69
  const shapeType = (node as any).shape._type
package/src/types.test.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { describe, expectTypeOf, it } from "vitest"
1
+ import { describe, expect, expectTypeOf, it } from "vitest"
2
2
  import type { ContainerShape, ValueShape } from "./shape.js"
3
3
  import { Shape } from "./shape.js"
4
- import type { Infer } from "./types.js"
4
+ import { createTypedDoc } from "./typed-doc.js"
5
+ import type { DeepReadonly, Infer } from "./types.js"
5
6
 
6
7
  describe("Infer type helper", () => {
7
8
  it("infers DocShape plain type", () => {
@@ -186,3 +187,97 @@ describe("Infer type helper", () => {
186
187
  expectTypeOf<Result>().toEqualTypeOf<Expected>()
187
188
  })
188
189
  })
190
+
191
+ describe("DeepReadonly type helper", () => {
192
+ it("Object.values returns clean types without toJSON function in union", () => {
193
+ const ParticipantSchema = Shape.plain.object({
194
+ id: Shape.plain.string(),
195
+ name: Shape.plain.string(),
196
+ })
197
+
198
+ const GroupSessionSchema = Shape.doc({
199
+ participants: Shape.record(ParticipantSchema),
200
+ })
201
+
202
+ const doc = createTypedDoc(GroupSessionSchema)
203
+
204
+ doc.change((root: any) => {
205
+ root.participants.set("p1", { id: "1", name: "Alice" })
206
+ root.participants.set("p2", { id: "2", name: "Bob" })
207
+ })
208
+
209
+ const participants = doc.value.participants
210
+
211
+ // Object.values should return clean types
212
+ const values = Object.values(participants)
213
+
214
+ type Participant = Infer<typeof ParticipantSchema>
215
+
216
+ // FIXED: Object.values now returns clean DeepReadonly<Participant>[]
217
+ // Previously it returned: (DeepReadonly<Participant> | (() => Record<...>))[]
218
+ expectTypeOf(values).toEqualTypeOf<DeepReadonly<Participant>[]>()
219
+
220
+ // Runtime check
221
+ expect(values).toHaveLength(2)
222
+ expect(values.map(p => p.name).sort()).toEqual(["Alice", "Bob"])
223
+ })
224
+
225
+ it("toJSON is still callable on Records", () => {
226
+ const ParticipantSchema = Shape.plain.object({
227
+ id: Shape.plain.string(),
228
+ name: Shape.plain.string(),
229
+ })
230
+
231
+ const GroupSessionSchema = Shape.doc({
232
+ participants: Shape.record(ParticipantSchema),
233
+ })
234
+
235
+ const doc = createTypedDoc(GroupSessionSchema)
236
+
237
+ doc.change((root: any) => {
238
+ root.participants.set("p1", { id: "1", name: "Alice" })
239
+ })
240
+
241
+ const participants = doc.value.participants
242
+
243
+ // toJSON should be callable
244
+ const json = participants.toJSON()
245
+
246
+ // Type check: toJSON returns the plain Record type
247
+ expectTypeOf(json).toEqualTypeOf<
248
+ Record<string, { id: string; name: string }>
249
+ >()
250
+
251
+ // Runtime check
252
+ expect(json).toEqual({ p1: { id: "1", name: "Alice" } })
253
+ })
254
+
255
+ it("toJSON is still callable on Maps", () => {
256
+ const MetaSchema = Shape.map({
257
+ title: Shape.plain.string(),
258
+ count: Shape.plain.number(),
259
+ })
260
+
261
+ const DocSchema = Shape.doc({
262
+ meta: MetaSchema,
263
+ })
264
+
265
+ const doc = createTypedDoc(DocSchema)
266
+
267
+ doc.change((root: any) => {
268
+ root.meta.title = "Test"
269
+ root.meta.count = 42
270
+ })
271
+
272
+ const meta = doc.value.meta
273
+
274
+ // toJSON should be callable
275
+ const json = meta.toJSON()
276
+
277
+ // Type check
278
+ expectTypeOf(json).toEqualTypeOf<{ title: string; count: number }>()
279
+
280
+ // Runtime check
281
+ expect(json).toEqual({ title: "Test", count: 42 })
282
+ })
283
+ })
package/src/types.ts CHANGED
@@ -36,7 +36,16 @@ import type { ContainerShape, DocShape, Shape } from "./shape.js"
36
36
  */
37
37
  export type Infer<T> = T extends Shape<infer P, any, any> ? P : never
38
38
 
39
- export type InferDraftType<T> = T extends Shape<any, infer D, any> ? D : never
39
+ /**
40
+ * Infers the mutable type from any Shape.
41
+ * This is the type used within change() callbacks for mutation.
42
+ */
43
+ export type InferMutableType<T> = T extends Shape<any, infer M, any> ? M : never
44
+
45
+ /**
46
+ * @deprecated Use InferMutableType<T> instead
47
+ */
48
+ export type InferDraftType<T> = InferMutableType<T>
40
49
 
41
50
  /**
42
51
  * Extracts the valid placeholder type from a shape.
@@ -48,10 +57,58 @@ export type InferPlaceholderType<T> = T extends Shape<any, any, infer P>
48
57
  ? P
49
58
  : never
50
59
 
51
- // Draft-specific type inference that properly handles the draft context
60
+ /**
61
+ * Mutable type for use within change() callbacks.
62
+ * This is the type-safe wrapper around CRDT containers that allows mutation.
63
+ */
64
+ export type Mutable<T extends DocShape<Record<string, ContainerShape>>> =
65
+ InferMutableType<T>
66
+
67
+ /**
68
+ * @deprecated Use Mutable<T> instead
69
+ */
52
70
  export type Draft<T extends DocShape<Record<string, ContainerShape>>> =
53
- InferDraftType<T>
71
+ Mutable<T>
54
72
 
55
- export type DeepReadonly<T> = {
56
- readonly [P in keyof T]: DeepReadonly<T[P]>
73
+ /**
74
+ * Interface for objects that have a toJSON method.
75
+ * This is separate from the data type to avoid polluting Object.values().
76
+ */
77
+ export interface HasToJSON<T> {
78
+ toJSON(): T
57
79
  }
80
+
81
+ /**
82
+ * Deep readonly wrapper for plain objects (no index signature).
83
+ * Includes toJSON() method.
84
+ */
85
+ export type DeepReadonlyObject<T extends object> = {
86
+ readonly [P in keyof T]: DeepReadonly<T[P]>
87
+ } & HasToJSON<T>
88
+
89
+ /**
90
+ * Deep readonly wrapper for Record types (with string index signature).
91
+ * The toJSON() method is available but NOT part of the index signature,
92
+ * so Object.values() returns clean types.
93
+ */
94
+ export type DeepReadonlyRecord<T> = {
95
+ readonly [K in keyof T]: DeepReadonly<T[K]>
96
+ } & HasToJSON<Record<string, T[keyof T]>>
97
+
98
+ /**
99
+ * Deep readonly wrapper that makes all properties readonly recursively
100
+ * and adds a toJSON() method for JSON serialization.
101
+ *
102
+ * For arrays: Returns ReadonlyArray with toJSON()
103
+ * For objects with string index signature (Records): toJSON() is available
104
+ * but doesn't pollute Object.values() type inference
105
+ * For plain objects: Returns readonly properties with toJSON()
106
+ * For primitives: Returns as-is
107
+ */
108
+ export type DeepReadonly<T> = T extends any[]
109
+ ? ReadonlyArray<DeepReadonly<T[number]>> & HasToJSON<T>
110
+ : T extends object
111
+ ? string extends keyof T
112
+ ? DeepReadonlyRecord<T>
113
+ : DeepReadonlyObject<T>
114
+ : T