@loro-extended/change 0.7.0 → 0.8.1

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,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
  })