@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.
@@ -84,7 +84,7 @@ export abstract class ListDraftNodeBase<
84
84
  ): DraftNodeParams<ContainerShape> {
85
85
  return {
86
86
  shape,
87
- emptyState: undefined, // List items don't have empty state
87
+ placeholder: undefined, // List items don't have placeholder
88
88
  getContainer: () => {
89
89
  const containerItem = this.container.get(index)
90
90
  if (!containerItem || !isContainer(containerItem)) {
@@ -92,6 +92,7 @@ export abstract class ListDraftNodeBase<
92
92
  }
93
93
  return containerItem
94
94
  },
95
+ readonly: this.readonly,
95
96
  }
96
97
  }
97
98
 
@@ -142,7 +143,7 @@ export abstract class ListDraftNodeBase<
142
143
  }
143
144
 
144
145
  // Get item for return values - returns DraftItem that can be mutated
145
- protected getDraftItem(index: number): DraftItem {
146
+ protected getDraftItem(index: number): any {
146
147
  // Check if we already have a cached item for this index
147
148
  let cachedItem = this.itemCache.get(index)
148
149
  if (cachedItem) {
@@ -167,14 +168,29 @@ export abstract class ListDraftNodeBase<
167
168
  // For primitives, just use the value directly
168
169
  cachedItem = containerItem
169
170
  }
170
- this.itemCache.set(index, cachedItem)
171
+ // Only cache primitive values if NOT readonly
172
+ if (!this.readonly) {
173
+ this.itemCache.set(index, cachedItem)
174
+ }
171
175
  return cachedItem as DraftItem
172
176
  } else {
173
177
  // For container shapes, create a proper draft node using the new pattern
174
178
  cachedItem = createContainerDraftNode(
175
179
  this.getDraftNodeParams(index, this.shape.shape as ContainerShape),
176
180
  )
181
+ // Cache container nodes
177
182
  this.itemCache.set(index, cachedItem)
183
+
184
+ if (this.readonly) {
185
+ const shape = this.shape.shape as ContainerShape
186
+ if (shape._type === "counter") {
187
+ return (cachedItem as any).value
188
+ }
189
+ if (shape._type === "text") {
190
+ return (cachedItem as any).toString()
191
+ }
192
+ }
193
+
178
194
  return cachedItem as DraftItem
179
195
  }
180
196
  }
@@ -254,26 +270,31 @@ export abstract class ListDraftNodeBase<
254
270
  }
255
271
 
256
272
  insert(index: number, item: Item): void {
273
+ if (this.readonly) throw new Error("Cannot modify readonly doc")
257
274
  // Update cache indices before performing the insert operation
258
275
  this.updateCacheForInsert(index)
259
276
  this.insertWithConversion(index, item)
260
277
  }
261
278
 
262
279
  delete(index: number, len: number): void {
280
+ if (this.readonly) throw new Error("Cannot modify readonly doc")
263
281
  // Update cache indices before performing the delete operation
264
282
  this.updateCacheForDelete(index, len)
265
283
  this.container.delete(index, len)
266
284
  }
267
285
 
268
286
  push(item: Item): void {
287
+ if (this.readonly) throw new Error("Cannot modify readonly doc")
269
288
  this.pushWithConversion(item)
270
289
  }
271
290
 
272
291
  pushContainer(container: Container): Container {
292
+ if (this.readonly) throw new Error("Cannot modify readonly doc")
273
293
  return this.container.pushContainer(container)
274
294
  }
275
295
 
276
296
  insertContainer(index: number, container: Container): Container {
297
+ if (this.readonly) throw new Error("Cannot modify readonly doc")
277
298
  return this.container.insertContainer(index, container)
278
299
  }
279
300
 
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { createTypedDoc, Shape } from "../index.js"
3
+
4
+ describe("ListDraftNode", () => {
5
+ describe("set via index", () => {
6
+ it("should allow setting a plain object for a list item via index", () => {
7
+ const schema = Shape.doc({
8
+ users: Shape.list(
9
+ Shape.map({
10
+ name: Shape.plain.string(),
11
+ }),
12
+ ),
13
+ })
14
+
15
+ const doc = createTypedDoc(schema)
16
+
17
+ doc.change(draft => {
18
+ draft.users.push({ name: "Alice" })
19
+
20
+ // Update via index
21
+ draft.users[0] = { name: "Bob" }
22
+ })
23
+
24
+ expect(doc.toJSON().users[0]).toEqual({ name: "Bob" })
25
+ })
26
+
27
+ it("should allow setting a primitive value via index", () => {
28
+ const schema = Shape.doc({
29
+ tags: Shape.list(Shape.plain.string()),
30
+ })
31
+
32
+ const doc = createTypedDoc(schema)
33
+
34
+ doc.change(draft => {
35
+ draft.tags.push("a")
36
+ draft.tags.push("b")
37
+ draft.tags[1] = "c"
38
+ })
39
+
40
+ expect(doc.toJSON().tags).toEqual(["a", "c"])
41
+ })
42
+ })
43
+ })
@@ -1,11 +1,14 @@
1
1
  import type { LoroList } from "loro-crdt"
2
2
  import type { ContainerOrValueShape } from "../shape.js"
3
+ import type { Infer } from "../types.js"
3
4
  import { ListDraftNodeBase } from "./list-base.js"
4
5
 
5
6
  // List draft node
6
7
  export class ListDraftNode<
7
8
  NestedShape extends ContainerOrValueShape,
8
9
  > extends ListDraftNodeBase<NestedShape> {
10
+ [index: number]: Infer<NestedShape>
11
+
9
12
  protected get container(): LoroList {
10
13
  return super.container as LoroList
11
14
  }
@@ -16,7 +16,10 @@ import type {
16
16
  } from "../shape.js"
17
17
  import { isContainerShape, isValueShape } from "../utils/type-guards.js"
18
18
  import { DraftNode, type DraftNodeParams } from "./base.js"
19
- import { createContainerDraftNode } from "./utils.js"
19
+ import {
20
+ assignPlainValueToDraftNode,
21
+ createContainerDraftNode,
22
+ } from "./utils.js"
20
23
 
21
24
  const containerConstructor = {
22
25
  counter: LoroCounter,
@@ -64,42 +67,66 @@ export class MapDraftNode<
64
67
  key: string,
65
68
  shape: S,
66
69
  ): DraftNodeParams<ContainerShape> {
67
- const emptyState = (this.emptyState as any)?.[key]
70
+ const placeholder = (this.placeholder as any)?.[key]
68
71
 
69
72
  const LoroContainer = containerConstructor[shape._type]
70
73
 
71
74
  return {
72
75
  shape,
73
- emptyState,
76
+ placeholder,
74
77
  getContainer: () =>
75
78
  this.container.getOrCreateContainer(key, new (LoroContainer as any)()),
79
+ readonly: this.readonly,
76
80
  }
77
81
  }
78
82
 
79
83
  getOrCreateNode<Shape extends ContainerShape | ValueShape>(
80
84
  key: string,
81
85
  shape: Shape,
82
- ): Shape extends ContainerShape ? DraftNode<Shape> : Value {
86
+ ): any {
83
87
  let node = this.propertyCache.get(key)
84
88
  if (!node) {
85
89
  if (isContainerShape(shape)) {
86
90
  node = createContainerDraftNode(this.getDraftNodeParams(key, shape))
91
+ // We cache container nodes even in readonly mode because they are just handles
92
+ this.propertyCache.set(key, node)
87
93
  } else {
88
94
  // For value shapes, first try to get the value from the container
89
95
  const containerValue = this.container.get(key)
90
96
  if (containerValue !== undefined) {
91
97
  node = containerValue as Value
92
98
  } else {
93
- // Only fall back to empty state if the container doesn't have the value
94
- const emptyState = (this.emptyState as any)?.[key]
95
- if (emptyState === undefined) {
96
- throw new Error("empty state required")
99
+ // Only fall back to placeholder if the container doesn't have the value
100
+ const placeholder = (this.placeholder as any)?.[key]
101
+ if (placeholder === undefined) {
102
+ throw new Error("placeholder required")
97
103
  }
98
- node = emptyState as Value
104
+ node = placeholder as Value
105
+ }
106
+
107
+ // In readonly mode, we DO NOT cache primitive values.
108
+ // This ensures we always get the latest value from the CRDT on next access.
109
+ if (!this.readonly) {
110
+ this.propertyCache.set(key, node)
99
111
  }
100
112
  }
101
113
  if (node === undefined) throw new Error("no container made")
102
- this.propertyCache.set(key, node)
114
+ }
115
+
116
+ if (this.readonly && isContainerShape(shape)) {
117
+ // In readonly mode, if the container doesn't exist, return the placeholder
118
+ // This ensures we respect default values (e.g. counter: 1)
119
+ const existing = this.container.get(key)
120
+ if (existing === undefined) {
121
+ return (this.placeholder as any)?.[key]
122
+ }
123
+
124
+ if (shape._type === "counter") {
125
+ return (node as any).value
126
+ }
127
+ if (shape._type === "text") {
128
+ return (node as any).toString()
129
+ }
103
130
  }
104
131
 
105
132
  return node as Shape extends ContainerShape ? DraftNode<Shape> : Value
@@ -110,13 +137,24 @@ export class MapDraftNode<
110
137
  const shape = this.shape.shapes[key]
111
138
  Object.defineProperty(this, key, {
112
139
  get: () => this.getOrCreateNode(key, shape),
113
- set: isValueShape(shape)
114
- ? value => {
115
- // console.log("set value", value)
116
- this.container.set(key, value)
117
- this.propertyCache.set(key, value)
140
+ set: value => {
141
+ if (this.readonly) throw new Error("Cannot modify readonly doc")
142
+ if (isValueShape(shape)) {
143
+ this.container.set(key, value)
144
+ this.propertyCache.set(key, value)
145
+ } else {
146
+ if (value && typeof value === "object") {
147
+ const node = this.getOrCreateNode(key, shape)
148
+
149
+ if (assignPlainValueToDraftNode(node as DraftNode<any>, value)) {
150
+ return
151
+ }
118
152
  }
119
- : undefined,
153
+ throw new Error(
154
+ "Cannot set container directly, modify the draft node instead",
155
+ )
156
+ }
157
+ },
120
158
  })
121
159
  }
122
160
  }
@@ -127,14 +165,17 @@ export class MapDraftNode<
127
165
  }
128
166
 
129
167
  set(key: string, value: Value): void {
168
+ if (this.readonly) throw new Error("Cannot modify readonly doc")
130
169
  this.container.set(key, value)
131
170
  }
132
171
 
133
172
  setContainer<C extends Container>(key: string, container: C): C {
173
+ if (this.readonly) throw new Error("Cannot modify readonly doc")
134
174
  return this.container.setContainer(key, container)
135
175
  }
136
176
 
137
177
  delete(key: string): void {
178
+ if (this.readonly) throw new Error("Cannot modify readonly doc")
138
179
  this.container.delete(key)
139
180
  }
140
181
 
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { createTypedDoc, Shape } from "../index.js"
3
+
4
+ describe("MovableListDraftNode", () => {
5
+ describe("set via index", () => {
6
+ it("should allow setting a plain object for a list item via index", () => {
7
+ const schema = Shape.doc({
8
+ users: Shape.movableList(
9
+ Shape.map({
10
+ name: Shape.plain.string(),
11
+ }),
12
+ ),
13
+ })
14
+
15
+ const doc = createTypedDoc(schema)
16
+
17
+ doc.change(draft => {
18
+ draft.users.push({ name: "Alice" })
19
+
20
+ // Update via index
21
+ draft.users[0] = { name: "Bob" }
22
+ })
23
+
24
+ expect(doc.toJSON().users[0]).toEqual({ name: "Bob" })
25
+ })
26
+ })
27
+ })
@@ -1,5 +1,6 @@
1
1
  import type { Container, LoroMovableList } from "loro-crdt"
2
2
  import type { ContainerOrValueShape } from "../shape.js"
3
+ import type { Infer } from "../types.js"
3
4
  import { ListDraftNodeBase } from "./list-base.js"
4
5
 
5
6
  // Movable list draft node
@@ -7,6 +8,8 @@ export class MovableListDraftNode<
7
8
  NestedShape extends ContainerOrValueShape,
8
9
  Item = NestedShape["_plain"],
9
10
  > extends ListDraftNodeBase<NestedShape> {
11
+ [index: number]: Infer<NestedShape>
12
+
10
13
  protected get container(): LoroMovableList {
11
14
  return super.container as LoroMovableList
12
15
  }
@@ -17,10 +20,12 @@ export class MovableListDraftNode<
17
20
  }
18
21
 
19
22
  move(from: number, to: number): void {
23
+ if (this.readonly) throw new Error("Cannot modify readonly doc")
20
24
  this.container.move(from, to)
21
25
  }
22
26
 
23
27
  set(index: number, item: Exclude<Item, Container>) {
28
+ if (this.readonly) throw new Error("Cannot modify readonly doc")
24
29
  return this.container.set(index, item)
25
30
  }
26
31
  }
@@ -0,0 +1,87 @@
1
+ import type { ListDraftNode } from "./list.js"
2
+ import type { MovableListDraftNode } from "./movable-list.js"
3
+ import type { RecordDraftNode } from "./record.js"
4
+
5
+ export const recordProxyHandler: ProxyHandler<RecordDraftNode<any>> = {
6
+ get: (target, prop) => {
7
+ if (typeof prop === "string" && !(prop in target)) {
8
+ return target.get(prop)
9
+ }
10
+ return Reflect.get(target, prop)
11
+ },
12
+ set: (target, prop, value) => {
13
+ if (typeof prop === "string" && !(prop in target)) {
14
+ target.set(prop, value)
15
+ return true
16
+ }
17
+ return Reflect.set(target, prop, value)
18
+ },
19
+ deleteProperty: (target, prop) => {
20
+ if (typeof prop === "string" && !(prop in target)) {
21
+ target.delete(prop)
22
+ return true
23
+ }
24
+ return Reflect.deleteProperty(target, prop)
25
+ },
26
+ ownKeys: target => {
27
+ return target.keys()
28
+ },
29
+ getOwnPropertyDescriptor: (target, prop) => {
30
+ if (typeof prop === "string" && target.has(prop)) {
31
+ return {
32
+ configurable: true,
33
+ enumerable: true,
34
+ value: target.get(prop),
35
+ }
36
+ }
37
+ return Reflect.getOwnPropertyDescriptor(target, prop)
38
+ },
39
+ }
40
+
41
+ export const listProxyHandler: ProxyHandler<ListDraftNode<any>> = {
42
+ get: (target, prop) => {
43
+ if (typeof prop === "string") {
44
+ const index = Number(prop)
45
+ if (!Number.isNaN(index)) {
46
+ return target.get(index)
47
+ }
48
+ }
49
+ return Reflect.get(target, prop)
50
+ },
51
+ set: (target, prop, value) => {
52
+ if (typeof prop === "string") {
53
+ const index = Number(prop)
54
+ if (!Number.isNaN(index)) {
55
+ // For lists, assignment to index implies replacement
56
+ target.delete(index, 1)
57
+ target.insert(index, value)
58
+ return true
59
+ }
60
+ }
61
+ return Reflect.set(target, prop, value)
62
+ },
63
+ }
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
+ }
73
+ }
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
+ }
84
+ }
85
+ return Reflect.set(target, prop, value)
86
+ },
87
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { Shape, TypedDoc } from "./index.js"
2
+ import { Shape, TypedDoc } from "../index.js"
3
3
 
4
4
  describe("Record Types", () => {
5
5
  describe("Shape.record (Container)", () => {
@@ -8,14 +8,14 @@ describe("Record Types", () => {
8
8
  scores: Shape.record(Shape.counter()),
9
9
  })
10
10
 
11
- const doc = new TypedDoc(schema, { scores: {} })
11
+ const doc = new TypedDoc(schema)
12
12
 
13
13
  doc.change(draft => {
14
14
  draft.scores.getOrCreateNode("alice").increment(10)
15
15
  draft.scores.getOrCreateNode("bob").increment(5)
16
16
  })
17
17
 
18
- expect(doc.value.scores).toEqual({
18
+ expect(doc.toJSON().scores).toEqual({
19
19
  alice: 10,
20
20
  bob: 5,
21
21
  })
@@ -25,7 +25,7 @@ describe("Record Types", () => {
25
25
  draft.scores.delete("bob")
26
26
  })
27
27
 
28
- expect(doc.value.scores).toEqual({
28
+ expect(doc.toJSON().scores).toEqual({
29
29
  alice: 15,
30
30
  })
31
31
  })
@@ -35,14 +35,14 @@ describe("Record Types", () => {
35
35
  notes: Shape.record(Shape.text()),
36
36
  })
37
37
 
38
- const doc = new TypedDoc(schema, { notes: {} })
38
+ const doc = new TypedDoc(schema)
39
39
 
40
40
  doc.change(draft => {
41
41
  draft.notes.getOrCreateNode("todo").insert(0, "Buy milk")
42
42
  draft.notes.getOrCreateNode("reminders").insert(0, "Call mom")
43
43
  })
44
44
 
45
- expect(doc.value.notes).toEqual({
45
+ expect(doc.toJSON().notes).toEqual({
46
46
  todo: "Buy milk",
47
47
  reminders: "Call mom",
48
48
  })
@@ -53,7 +53,7 @@ describe("Record Types", () => {
53
53
  groups: Shape.record(Shape.list(Shape.plain.string())),
54
54
  })
55
55
 
56
- const doc = new TypedDoc(schema, { groups: {} })
56
+ const doc = new TypedDoc(schema)
57
57
 
58
58
  doc.change(draft => {
59
59
  const groupA = draft.groups.getOrCreateNode("groupA")
@@ -64,7 +64,7 @@ describe("Record Types", () => {
64
64
  groupB.push("charlie")
65
65
  })
66
66
 
67
- expect(doc.value.groups).toEqual({
67
+ expect(doc.toJSON().groups).toEqual({
68
68
  groupA: ["alice", "bob"],
69
69
  groupB: ["charlie"],
70
70
  })
@@ -79,14 +79,14 @@ describe("Record Types", () => {
79
79
  }),
80
80
  })
81
81
 
82
- const doc = new TypedDoc(schema, { wrapper: { config: {} } })
82
+ const doc = new TypedDoc(schema)
83
83
 
84
84
  doc.change(draft => {
85
85
  draft.wrapper.config.theme = "dark"
86
86
  draft.wrapper.config.lang = "en"
87
87
  })
88
88
 
89
- expect(doc.value.wrapper.config).toEqual({
89
+ expect(doc.toJSON().wrapper.config).toEqual({
90
90
  theme: "dark",
91
91
  lang: "en",
92
92
  })
@@ -96,7 +96,7 @@ describe("Record Types", () => {
96
96
  draft.wrapper.config.lang = "fr"
97
97
  })
98
98
 
99
- expect(doc.value.wrapper.config).toEqual({
99
+ expect(doc.toJSON().wrapper.config).toEqual({
100
100
  lang: "fr",
101
101
  })
102
102
  })
@@ -108,15 +108,14 @@ describe("Record Types", () => {
108
108
  }),
109
109
  })
110
110
 
111
- // Empty state must use empty record - add initial data via change()
112
- const doc = new TypedDoc(schema, { wrapper: { stats: {} } })
111
+ const doc = new TypedDoc(schema)
113
112
 
114
113
  doc.change(draft => {
115
114
  draft.wrapper.stats.visits = 100
116
115
  draft.wrapper.stats.clicks = 50
117
116
  })
118
117
 
119
- expect(doc.value.wrapper.stats).toEqual({
118
+ expect(doc.toJSON().wrapper.stats).toEqual({
120
119
  visits: 100,
121
120
  clicks: 50,
122
121
  })
@@ -131,7 +130,7 @@ describe("Record Types", () => {
131
130
  }),
132
131
  })
133
132
 
134
- const doc = new TypedDoc(schema, { wrapper: { settings: {} } })
133
+ const doc = new TypedDoc(schema)
135
134
 
136
135
  doc.change(draft => {
137
136
  draft.wrapper.settings.ui = {
@@ -144,7 +143,7 @@ describe("Record Types", () => {
144
143
  }
145
144
  })
146
145
 
147
- expect(doc.value.wrapper.settings).toEqual({
146
+ expect(doc.toJSON().wrapper.settings).toEqual({
148
147
  ui: {
149
148
  darkMode: true,
150
149
  sidebar: false,
@@ -168,7 +167,7 @@ describe("Record Types", () => {
168
167
  ),
169
168
  })
170
169
 
171
- const doc = new TypedDoc(schema, { users: {} })
170
+ const doc = new TypedDoc(schema)
172
171
 
173
172
  doc.change(draft => {
174
173
  const alice = draft.users.getOrCreateNode("u1")
@@ -180,10 +179,91 @@ describe("Record Types", () => {
180
179
  bob.age = 25
181
180
  })
182
181
 
183
- expect(doc.value.users).toEqual({
182
+ expect(doc.toJSON().users).toEqual({
184
183
  u1: { name: "Alice", age: 30 },
185
184
  u2: { name: "Bob", age: 25 },
186
185
  })
187
186
  })
187
+
188
+ it("should allow setting a plain object for a record with map values", () => {
189
+ const schema = Shape.doc({
190
+ participants: Shape.record(
191
+ Shape.map({
192
+ id: Shape.plain.string(),
193
+ role: Shape.plain.string(),
194
+ name: Shape.plain.string(),
195
+ color: Shape.plain.string(),
196
+ }),
197
+ ),
198
+ })
199
+
200
+ const doc = new TypedDoc(schema)
201
+
202
+ doc.change(draft => {
203
+ draft.participants["student-1"] = {
204
+ id: "student-1",
205
+ role: "student",
206
+ name: "Alice",
207
+ color: "indigo",
208
+ }
209
+ })
210
+
211
+ expect(doc.toJSON().participants["student-1"]).toEqual({
212
+ id: "student-1",
213
+ role: "student",
214
+ name: "Alice",
215
+ color: "indigo",
216
+ })
217
+ })
218
+
219
+ it("should allow setting a plain object for a record with nested map values", () => {
220
+ const schema = Shape.doc({
221
+ data: Shape.record(
222
+ Shape.map({
223
+ info: Shape.map({
224
+ name: Shape.plain.string(),
225
+ }),
226
+ }),
227
+ ),
228
+ })
229
+
230
+ const doc = new TypedDoc(schema)
231
+
232
+ doc.change(draft => {
233
+ draft.data["item-1"] = {
234
+ info: {
235
+ name: "Item 1",
236
+ },
237
+ }
238
+ })
239
+
240
+ expect(doc.toJSON().data["item-1"]).toEqual({
241
+ info: {
242
+ name: "Item 1",
243
+ },
244
+ })
245
+ })
246
+
247
+ it("should allow setting a plain array for a record with list values", () => {
248
+ const schema = Shape.doc({
249
+ histories: Shape.record(Shape.list(Shape.plain.string())),
250
+ })
251
+
252
+ const doc = new TypedDoc(schema)
253
+
254
+ doc.change(draft => {
255
+ draft.histories.user1 = ["a", "b"]
256
+ })
257
+
258
+ expect(doc.toJSON().histories.user1).toEqual(["a", "b"])
259
+
260
+ doc.change(draft => {
261
+ // biome-ignore lint/complexity/useLiteralKeys: tests indexed assignment
262
+ draft.histories["user1"] = ["c"]
263
+ })
264
+
265
+ // biome-ignore lint/complexity/useLiteralKeys: tests indexed assignment
266
+ expect(doc.toJSON().histories["user1"]).toEqual(["c"])
267
+ })
188
268
  })
189
269
  })