@loro-extended/change 0.9.1 → 1.0.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.
Files changed (43) hide show
  1. package/README.md +201 -93
  2. package/dist/index.d.ts +361 -169
  3. package/dist/index.js +516 -235
  4. package/dist/index.js.map +1 -1
  5. package/package.json +1 -1
  6. package/src/change.test.ts +180 -175
  7. package/src/conversion.test.ts +19 -19
  8. package/src/conversion.ts +7 -7
  9. package/src/derive-placeholder.test.ts +14 -14
  10. package/src/derive-placeholder.ts +3 -3
  11. package/src/discriminated-union-assignability.test.ts +7 -7
  12. package/src/discriminated-union-tojson.test.ts +13 -24
  13. package/src/discriminated-union.test.ts +9 -8
  14. package/src/equality.test.ts +10 -2
  15. package/src/functional-helpers.test.ts +149 -0
  16. package/src/functional-helpers.ts +61 -0
  17. package/src/grand-unified-api.test.ts +423 -0
  18. package/src/index.ts +8 -6
  19. package/src/json-patch.test.ts +64 -56
  20. package/src/overlay-recursion.test.ts +23 -22
  21. package/src/overlay.ts +9 -9
  22. package/src/readonly.test.ts +27 -26
  23. package/src/shape.ts +103 -21
  24. package/src/typed-doc.ts +227 -58
  25. package/src/typed-refs/base.ts +23 -1
  26. package/src/typed-refs/counter.test.ts +44 -13
  27. package/src/typed-refs/counter.ts +40 -3
  28. package/src/typed-refs/doc.ts +12 -6
  29. package/src/typed-refs/json-compatibility.test.ts +37 -32
  30. package/src/typed-refs/list-base.ts +26 -22
  31. package/src/typed-refs/list.test.ts +4 -3
  32. package/src/typed-refs/movable-list.test.ts +3 -2
  33. package/src/typed-refs/movable-list.ts +4 -1
  34. package/src/typed-refs/proxy-handlers.ts +14 -1
  35. package/src/typed-refs/record.test.ts +107 -42
  36. package/src/typed-refs/record.ts +37 -19
  37. package/src/typed-refs/{map.ts → struct.ts} +31 -16
  38. package/src/typed-refs/text.ts +42 -1
  39. package/src/typed-refs/utils.ts +28 -6
  40. package/src/types.test.ts +34 -39
  41. package/src/types.ts +5 -40
  42. package/src/utils/type-guards.ts +11 -6
  43. package/src/validation.ts +10 -10
@@ -0,0 +1,61 @@
1
+ import type { LoroDoc } from "loro-crdt"
2
+ import type { DocShape } from "./shape.js"
3
+ import type { TypedDoc } from "./typed-doc.js"
4
+ import type { Mutable } from "./types.js"
5
+
6
+ /**
7
+ * The primary method of mutating typed documents.
8
+ * Batches multiple mutations into a single transaction.
9
+ * All changes commit together at the end.
10
+ *
11
+ * Use this for:
12
+ * - Find-and-mutate operations (required due to JS limitations)
13
+ * - Performance (fewer commits)
14
+ * - Atomic undo (all changes = one undo step)
15
+ *
16
+ * Returns the doc for chaining.
17
+ *
18
+ * @param doc - The TypedDoc to mutate
19
+ * @param fn - Function that performs mutations on the draft
20
+ * @returns The same TypedDoc for chaining
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * import { change } from "@loro-extended/change"
25
+ *
26
+ * // Chainable API
27
+ * change(doc, draft => {
28
+ * draft.count.increment(10)
29
+ * draft.title.update("Hello")
30
+ * })
31
+ * .count.increment(5) // Optional: continue mutating
32
+ * .toJSON() // Optional: get last item snapshot when needed
33
+ * ```
34
+ */
35
+ export function change<Shape extends DocShape>(
36
+ doc: TypedDoc<Shape>,
37
+ fn: (draft: Mutable<Shape>) => void,
38
+ ): TypedDoc<Shape> {
39
+ return doc.$.change(fn)
40
+ }
41
+
42
+ /**
43
+ * Access the underlying LoroDoc for advanced operations.
44
+ *
45
+ * @param doc - The TypedDoc to unwrap
46
+ * @returns The underlying LoroDoc instance
47
+ *
48
+ * @example
49
+ * ```typescript
50
+ * import { getLoroDoc } from "@loro-extended/change"
51
+ *
52
+ * const loroDoc = getLoroDoc(doc)
53
+ * const version = loroDoc.version()
54
+ * loroDoc.subscribe(() => console.log("changed"))
55
+ * ```
56
+ */
57
+ export function getLoroDoc<Shape extends DocShape>(
58
+ doc: TypedDoc<Shape>,
59
+ ): LoroDoc {
60
+ return doc.$.loroDoc
61
+ }
@@ -0,0 +1,423 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { Shape } from "./shape.js"
3
+ import { createTypedDoc } from "./typed-doc.js"
4
+
5
+ /**
6
+ * Tests for Grand Unified API v3 with Proxy-based TypedDoc
7
+ *
8
+ * This API provides direct schema access on the doc object:
9
+ * - doc.count.increment(5) instead of doc.count.increment(5)
10
+ * - doc.$.change() for batched mutations
11
+ * - doc.toJSON() for serialization
12
+ */
13
+ describe("Grand Unified API v3", () => {
14
+ const schema = Shape.doc({
15
+ title: Shape.text(),
16
+ count: Shape.counter(),
17
+ users: Shape.record(
18
+ Shape.plain.struct({
19
+ name: Shape.plain.string(),
20
+ }),
21
+ ),
22
+ items: Shape.list(Shape.plain.string()),
23
+ })
24
+
25
+ describe("direct mutations (auto-commit)", () => {
26
+ it("should auto-commit counter increments", () => {
27
+ const doc = createTypedDoc(schema)
28
+ doc.count.increment(5)
29
+ expect(doc.toJSON().count).toBe(5)
30
+ })
31
+
32
+ it("should auto-commit counter decrements", () => {
33
+ const doc = createTypedDoc(schema)
34
+ doc.count.increment(10)
35
+ doc.count.decrement(3)
36
+ expect(doc.toJSON().count).toBe(7)
37
+ })
38
+
39
+ it("should auto-commit text inserts", () => {
40
+ const doc = createTypedDoc(schema)
41
+ doc.title.insert(0, "Hello")
42
+ expect(doc.toJSON().title).toBe("Hello")
43
+ })
44
+
45
+ it("should auto-commit text updates", () => {
46
+ const doc = createTypedDoc(schema)
47
+ doc.title.insert(0, "Hello")
48
+ doc.title.update("World")
49
+ expect(doc.toJSON().title).toBe("World")
50
+ })
51
+
52
+ it("should auto-commit text deletes", () => {
53
+ const doc = createTypedDoc(schema)
54
+ doc.title.insert(0, "Hello World")
55
+ doc.title.delete(0, 6)
56
+ expect(doc.toJSON().title).toBe("World")
57
+ })
58
+
59
+ it("should auto-commit record sets", () => {
60
+ const doc = createTypedDoc(schema)
61
+ doc.users.set("alice", { name: "Alice" })
62
+ expect(doc.toJSON().users.alice).toEqual({ name: "Alice" })
63
+ })
64
+
65
+ it("should auto-commit record deletes", () => {
66
+ const doc = createTypedDoc(schema)
67
+ doc.users.set("alice", { name: "Alice" })
68
+ doc.users.set("bob", { name: "Bob" })
69
+ doc.users.delete("alice")
70
+ expect(doc.toJSON().users.alice).toBeUndefined()
71
+ expect(doc.toJSON().users.bob).toEqual({ name: "Bob" })
72
+ })
73
+
74
+ it("should auto-commit list pushes", () => {
75
+ const doc = createTypedDoc(schema)
76
+ doc.items.push("first")
77
+ doc.items.push("second")
78
+ expect(doc.toJSON().items).toEqual(["first", "second"])
79
+ })
80
+
81
+ it("should auto-commit list inserts", () => {
82
+ const doc = createTypedDoc(schema)
83
+ doc.items.push("first")
84
+ doc.items.push("third")
85
+ doc.items.insert(1, "second")
86
+ expect(doc.toJSON().items).toEqual(["first", "second", "third"])
87
+ })
88
+
89
+ it("should auto-commit list deletes", () => {
90
+ const doc = createTypedDoc(schema)
91
+ doc.items.push("first")
92
+ doc.items.push("second")
93
+ doc.items.push("third")
94
+ doc.items.delete(1, 1)
95
+ expect(doc.toJSON().items).toEqual(["first", "third"])
96
+ })
97
+ })
98
+
99
+ describe("record has() method and 'in' operator", () => {
100
+ it("should support .has() method", () => {
101
+ const doc = createTypedDoc(schema)
102
+ doc.users.set("alice", { name: "Alice" })
103
+ expect(doc.users.has("alice")).toBe(true)
104
+ expect(doc.users.has("bob")).toBe(false)
105
+ })
106
+
107
+ it("should support 'in' operator for records", () => {
108
+ const doc = createTypedDoc(schema)
109
+ doc.users.set("alice", { name: "Alice" })
110
+ expect("alice" in doc.users).toBe(true)
111
+ expect("bob" in doc.users).toBe(false)
112
+ })
113
+
114
+ it("should support 'in' operator after change()", () => {
115
+ const doc = createTypedDoc(schema)
116
+ doc.$.change(draft => {
117
+ draft.users.set("alice", { name: "Alice" })
118
+ draft.users.set("bob", { name: "Bob" })
119
+ })
120
+ expect("alice" in doc.users).toBe(true)
121
+ expect("bob" in doc.users).toBe(true)
122
+ expect("charlie" in doc.users).toBe(false)
123
+ })
124
+ })
125
+
126
+ describe("batched mutations", () => {
127
+ it("should batch all changes into one commit", () => {
128
+ const doc = createTypedDoc(schema)
129
+
130
+ // Track commits by checking version changes
131
+ const versionBefore = doc.$.loroDoc.version()
132
+
133
+ doc.$.change(draft => {
134
+ draft.count.increment(1)
135
+ draft.count.increment(2)
136
+ draft.count.increment(3)
137
+ })
138
+
139
+ const versionAfter = doc.$.loroDoc.version()
140
+
141
+ // Version should have changed
142
+ expect(versionAfter).not.toEqual(versionBefore)
143
+ expect(doc.toJSON().count).toBe(6)
144
+ })
145
+
146
+ it("should batch multiple different operations", () => {
147
+ const doc = createTypedDoc(schema)
148
+
149
+ doc.$.change(draft => {
150
+ draft.title.insert(0, "Hello World")
151
+ draft.count.increment(42)
152
+ draft.users.set("alice", { name: "Alice" })
153
+ draft.items.push("item1")
154
+ })
155
+
156
+ const result = doc.toJSON()
157
+ expect(result.title).toBe("Hello World")
158
+ expect(result.count).toBe(42)
159
+ expect(result.users.alice).toEqual({ name: "Alice" })
160
+ expect(result.items).toEqual(["item1"])
161
+ })
162
+
163
+ it("should return doc for chaining from change()", () => {
164
+ const doc = createTypedDoc(schema)
165
+
166
+ const result = doc.$.change(draft => {
167
+ draft.title.insert(0, "Test")
168
+ draft.count.increment(5)
169
+ })
170
+
171
+ // change() returns the doc for chaining
172
+ expect(result).toBe(doc)
173
+ expect(result.toJSON().title).toBe("Test")
174
+ expect(result.toJSON().count).toBe(5)
175
+ })
176
+
177
+ it("should support chaining after change()", () => {
178
+ const doc = createTypedDoc(schema)
179
+
180
+ // Chain mutations after change
181
+ doc.$.change(draft => {
182
+ draft.count.increment(5)
183
+ }).count.increment(3)
184
+
185
+ expect(doc.toJSON().count).toBe(8)
186
+ })
187
+ })
188
+
189
+ describe("API consistency", () => {
190
+ it("should have same methods on doc and draft", () => {
191
+ const doc = createTypedDoc(schema)
192
+
193
+ // Both should have .has()
194
+ expect(typeof doc.users.has).toBe("function")
195
+ doc.$.change(draft => {
196
+ expect(typeof draft.users.has).toBe("function")
197
+ })
198
+
199
+ // Both should have .keys()
200
+ expect(typeof doc.users.keys).toBe("function")
201
+ doc.$.change(draft => {
202
+ expect(typeof draft.users.keys).toBe("function")
203
+ })
204
+
205
+ // Both should have .set()
206
+ expect(typeof doc.users.set).toBe("function")
207
+ doc.$.change(draft => {
208
+ expect(typeof draft.users.set).toBe("function")
209
+ })
210
+ })
211
+
212
+ it("should allow reading values on doc", () => {
213
+ const doc = createTypedDoc(schema)
214
+
215
+ // Set up some data
216
+ doc.$.change(draft => {
217
+ draft.title.insert(0, "Test Title")
218
+ draft.count.increment(42)
219
+ draft.users.set("alice", { name: "Alice" })
220
+ draft.items.push("item1")
221
+ })
222
+
223
+ // Read via doc directly
224
+ expect(doc.title.toString()).toBe("Test Title")
225
+ expect(doc.count.value).toBe(42)
226
+ expect(doc.users.has("alice")).toBe(true)
227
+ expect(doc.items.length).toBe(1)
228
+ })
229
+ })
230
+
231
+ describe("nested container mutations", () => {
232
+ it("should auto-commit nested map mutations", () => {
233
+ const nestedSchema = Shape.doc({
234
+ article: Shape.struct({
235
+ title: Shape.text(),
236
+ metadata: Shape.struct({
237
+ views: Shape.counter(),
238
+ author: Shape.plain.string(),
239
+ }),
240
+ }),
241
+ })
242
+
243
+ const doc = createTypedDoc(nestedSchema)
244
+
245
+ // Direct mutations on nested containers
246
+ doc.article.title.insert(0, "My Article")
247
+ doc.article.metadata.views.increment(100)
248
+ doc.article.metadata.set("author", "John Doe")
249
+
250
+ const result = doc.toJSON()
251
+ expect(result.article.title).toBe("My Article")
252
+ expect(result.article.metadata.views).toBe(100)
253
+ expect(result.article.metadata.author).toBe("John Doe")
254
+ })
255
+
256
+ it("should auto-commit list of maps mutations", () => {
257
+ const listMapSchema = Shape.doc({
258
+ articles: Shape.list(
259
+ Shape.struct({
260
+ title: Shape.text(),
261
+ views: Shape.counter(),
262
+ }),
263
+ ),
264
+ })
265
+
266
+ const doc = createTypedDoc(listMapSchema)
267
+
268
+ // Push via batch first to create the structure
269
+ doc.$.change(draft => {
270
+ draft.articles.push({ title: "Article 1", views: 0 })
271
+ draft.articles.push({ title: "Article 2", views: 0 })
272
+ })
273
+
274
+ // Then mutate directly
275
+ doc.articles.get(0)?.title.update("Updated Article 1")
276
+ doc.articles.get(0)?.views.increment(50)
277
+
278
+ const result = doc.toJSON()
279
+ expect(result.articles[0].title).toBe("Updated Article 1")
280
+ expect(result.articles[0].views).toBe(50)
281
+ })
282
+ })
283
+
284
+ describe("counter and text primitive coercion", () => {
285
+ it("should support valueOf() on CounterRef", () => {
286
+ const doc = createTypedDoc(schema)
287
+ doc.count.increment(42)
288
+
289
+ // valueOf() should return the number
290
+ expect(doc.count.valueOf()).toBe(42)
291
+
292
+ // Arithmetic should work via valueOf()
293
+ expect(+doc.count).toBe(42)
294
+ })
295
+
296
+ it("should support toString() on TextRef", () => {
297
+ const doc = createTypedDoc(schema)
298
+ doc.title.insert(0, "Hello World")
299
+
300
+ // toString() should return the string
301
+ expect(doc.title.toString()).toBe("Hello World")
302
+
303
+ // String concatenation should work
304
+ expect(`Title: ${doc.title}`).toBe("Title: Hello World")
305
+ })
306
+ })
307
+
308
+ describe("placeholder handling", () => {
309
+ it("should return placeholder for unmaterialized counter", () => {
310
+ const schemaWithPlaceholder = Shape.doc({
311
+ count: Shape.counter(), // default placeholder is 0
312
+ })
313
+
314
+ const doc = createTypedDoc(schemaWithPlaceholder)
315
+
316
+ // Before any mutations, should return placeholder
317
+ expect(doc.count.value).toBe(0)
318
+ expect(doc.toJSON().count).toBe(0)
319
+ })
320
+
321
+ it("should return placeholder for unmaterialized text via toJSON()", () => {
322
+ const schemaWithPlaceholder = Shape.doc({
323
+ title: Shape.text().placeholder("Default Title"),
324
+ })
325
+
326
+ const doc = createTypedDoc(schemaWithPlaceholder)
327
+
328
+ // Before any container access, toJSON() should return placeholder
329
+ expect(doc.toJSON().title).toBe("Default Title")
330
+
331
+ // Accessing doc.title creates a TextRef but doesn't materialize
332
+ // the container until we actually use it
333
+ const ref = doc.title
334
+ expect(doc.toJSON().title).toBe("Default Title") // Still placeholder
335
+
336
+ // Calling toString() on the ref accesses the container, materializing it
337
+ ref.toString()
338
+ // Now the container exists in the CRDT with empty string
339
+ // The overlay returns the actual CRDT value (empty string) since it exists
340
+ expect(doc.toJSON().title).toBe("")
341
+
342
+ // After mutation, the value changes
343
+ ref.insert(0, "Hello")
344
+ expect(doc.toJSON().title).toBe("Hello")
345
+ })
346
+
347
+ it("should return actual value after mutation", () => {
348
+ const schemaWithPlaceholder = Shape.doc({
349
+ count: Shape.counter(),
350
+ title: Shape.text().placeholder("Default"),
351
+ })
352
+
353
+ const doc = createTypedDoc(schemaWithPlaceholder)
354
+
355
+ doc.count.increment(10)
356
+ doc.title.update("Custom Title")
357
+
358
+ expect(doc.count.value).toBe(10)
359
+ expect(doc.title.toString()).toBe("Custom Title")
360
+ })
361
+ })
362
+
363
+ describe("multiple sequential mutations", () => {
364
+ it("should handle many sequential auto-commit mutations", () => {
365
+ const doc = createTypedDoc(schema)
366
+
367
+ // Many sequential mutations
368
+ for (let i = 0; i < 10; i++) {
369
+ doc.count.increment(1)
370
+ }
371
+
372
+ expect(doc.toJSON().count).toBe(10)
373
+ })
374
+
375
+ it("should handle interleaved reads and writes", () => {
376
+ const doc = createTypedDoc(schema)
377
+
378
+ doc.count.increment(5)
379
+ expect(doc.count.value).toBe(5)
380
+
381
+ doc.count.increment(3)
382
+ expect(doc.count.value).toBe(8)
383
+
384
+ doc.count.decrement(2)
385
+ expect(doc.count.value).toBe(6)
386
+ })
387
+ })
388
+
389
+ describe("$ namespace", () => {
390
+ it("should provide access to meta-operations via $", () => {
391
+ const doc = createTypedDoc(schema)
392
+
393
+ // $ should exist
394
+ expect(doc.$).toBeDefined()
395
+
396
+ // $ should have batch, toJSON, loroDoc, etc.
397
+ expect(typeof doc.$.change).toBe("function")
398
+ expect(typeof doc.toJSON).toBe("function")
399
+ expect(doc.$.loroDoc).toBeDefined()
400
+ })
401
+
402
+ it("should not enumerate $ in Object.keys()", () => {
403
+ const doc = createTypedDoc(schema)
404
+
405
+ // $ should not appear in Object.keys()
406
+ const keys = Object.keys(doc)
407
+ expect(keys).not.toContain("$")
408
+
409
+ // But schema keys should appear
410
+ expect(keys).toContain("title")
411
+ expect(keys).toContain("count")
412
+ expect(keys).toContain("users")
413
+ expect(keys).toContain("items")
414
+ })
415
+
416
+ it("should support 'in' operator for $", () => {
417
+ const doc = createTypedDoc(schema)
418
+
419
+ // $ should be accessible via 'in'
420
+ expect("$" in doc).toBe(true)
421
+ })
422
+ })
423
+ })
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@ export {
4
4
  derivePlaceholder,
5
5
  deriveShapePlaceholder,
6
6
  } from "./derive-placeholder.js"
7
+ // Functional helpers (recommended API)
8
+ export { change, getLoroDoc } from "./functional-helpers.js"
7
9
  export { mergeValue, overlayPlaceholder } from "./overlay.js"
8
10
  export { createPlaceholderProxy } from "./placeholder-proxy.js"
9
11
  export type { ObjectValue, PresenceInterface } from "./presence-interface.js"
@@ -19,11 +21,15 @@ export type {
19
21
  // Schema node types
20
22
  DocShape,
21
23
  ListContainerShape,
24
+ /** @deprecated Use StructContainerShape instead */
22
25
  MapContainerShape,
23
26
  MovableListContainerShape,
27
+ /** @deprecated Use StructValueShape instead */
24
28
  ObjectValueShape,
25
29
  RecordContainerShape,
26
30
  RecordValueShape,
31
+ StructContainerShape,
32
+ StructValueShape,
27
33
  TextContainerShape,
28
34
  TreeContainerShape,
29
35
  UnionValueShape,
@@ -34,16 +40,12 @@ export type {
34
40
  } from "./shape.js"
35
41
  // Schema and type exports
36
42
  export { Shape } from "./shape.js"
37
- export { createTypedDoc, TypedDoc } from "./typed-doc.js"
43
+ export type { TypedDoc } from "./typed-doc.js"
44
+ export { createTypedDoc } from "./typed-doc.js"
38
45
  export { TypedPresence } from "./typed-presence.js"
39
46
  export type {
40
- DeepReadonly,
41
- /** @deprecated Use Mutable instead */
42
- Draft,
43
47
  // Type inference - Infer<T> is the recommended unified helper
44
48
  Infer,
45
- /** @deprecated Use InferMutableType instead */
46
- InferDraftType,
47
49
  InferMutableType,
48
50
  InferPlaceholderType,
49
51
  Mutable,