@loro-extended/change 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 SchoolAI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,565 @@
1
+ # @loro-extended/change
2
+
3
+ A schema-driven, type-safe wrapper for [Loro CRDT](https://github.com/loro-dev/loro) that provides natural JavaScript syntax for collaborative document editing. Build local-first applications with intuitive APIs while maintaining full CRDT capabilities.
4
+
5
+ ## What is Loro?
6
+
7
+ [Loro](https://github.com/loro-dev/loro) is a high-performance CRDT (Conflict-free Replicated Data Type) library that enables real-time collaborative editing without conflicts. It's perfect for building local-first applications like collaborative editors, task managers, and (turn-based) multiplayer games.
8
+
9
+ ## Why Use `change`?
10
+
11
+ Working with Loro directly involves somewhat verbose container operations and complex type management. The `change` package provides:
12
+
13
+ - **Schema-First Design**: Define your document structure with type-safe schemas
14
+ - **Natural Syntax**: Write `draft.title.insert(0, "Hello")` instead of verbose CRDT operations
15
+ - **Empty State Overlay**: Seamlessly blend default values with CRDT state
16
+ - **Full Type Safety**: Complete TypeScript support with compile-time validation
17
+ - **Transactional Changes**: All mutations within a `change()` block are atomic
18
+ - **Loro Compatible**: Works seamlessly with existing Loro code (`typedDoc.loroDoc` is a familiar `LoroDoc`)
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ npm install @loro-extended/change loro-crdt
24
+ # or
25
+ pnpm add @loro-extended/change loro-crdt
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ ```typescript
31
+ import { TypedDoc, Shape } from "@loro-extended/change";
32
+
33
+ // Define your document schema
34
+ const schema = Shape.doc({
35
+ title: Shape.text(),
36
+ todos: Shape.list(
37
+ Shape.plain.object({
38
+ id: Shape.plain.string(),
39
+ text: Shape.plain.string(),
40
+ completed: Shape.plain.boolean(),
41
+ })
42
+ ),
43
+ });
44
+
45
+ // Define empty state (default values)
46
+ const emptyState = {
47
+ title: "My Todo List",
48
+ todos: [],
49
+ };
50
+
51
+ // Create a typed document
52
+ const doc = new TypedDoc(schema, emptyState);
53
+
54
+ // Make changes with natural syntax
55
+ const result = doc.change((draft) => {
56
+ draft.title.insert(0, "📝 Todo");
57
+ draft.todos.push({
58
+ id: "1",
59
+ text: "Learn Loro",
60
+ completed: false,
61
+ });
62
+ });
63
+
64
+ console.log(result);
65
+ // { title: "📝 Todo", todos: [{ id: "1", text: "Learn Loro", completed: false }] }
66
+ ```
67
+
68
+ Note that this is even more useful in combination with `@loro-extended/react` (if your app uses React) and `@loro-extended/repo` for syncing between client/server or among peers.
69
+
70
+ ## Core Concepts
71
+
72
+ ### Schema Definition with `Shape`
73
+
74
+ Define your document structure using `Shape` builders:
75
+
76
+ ```typescript
77
+ import { Shape } from "@loro-extended/change";
78
+
79
+ const blogSchema = Shape.doc({
80
+ // CRDT containers for collaborative editing
81
+ title: Shape.text(), // Collaborative text
82
+ viewCount: Shape.counter(), // Increment-only counter
83
+
84
+ // Lists for ordered data
85
+ tags: Shape.list(Shape.plain.string()), // List of strings
86
+
87
+ // Maps for structured data
88
+ metadata: Shape.map({
89
+ author: Shape.plain.string(), // Plain values (POJOs)
90
+ publishedAt: Shape.plain.string(), // ISO date string
91
+ featured: Shape.plain.boolean(),
92
+ }),
93
+
94
+ // Movable lists for reorderable content
95
+ sections: Shape.movableList(
96
+ Shape.map({
97
+ heading: Shape.text(), // Collaborative headings
98
+ content: Shape.text(), // Collaborative content
99
+ order: Shape.plain.number(), // Plain metadata
100
+ })
101
+ ),
102
+ });
103
+ ```
104
+
105
+ **NOTE:** Use `Shape.*` for collaborative containers and `Shape.plain.*` for plain values. Only put plain values inside Loro containers - a Loro container inside a plain JS object or array won't work.
106
+
107
+ ### Empty State Overlay
108
+
109
+ Empty state provides default values that are merged when CRDT containers are empty, keeping the whole document typesafe:
110
+
111
+ ```typescript
112
+ const emptyState = {
113
+ title: "Untitled Document", // unusual empty state, but technically ok
114
+ viewCount: 0,
115
+ tags: [],
116
+ metadata: {
117
+ author: "Anonymous",
118
+ publishedAt: "",
119
+ featured: false,
120
+ },
121
+ sections: [],
122
+ };
123
+
124
+ const doc = new TypedDoc(blogSchema, emptyState);
125
+
126
+ // Initially returns empty state
127
+ console.log(doc.value);
128
+ // { title: "Untitled Document", viewCount: 0, ... }
129
+
130
+ // After changes, CRDT values take priority over empty state
131
+ doc.change((draft) => {
132
+ draft.title.insert(0, "My Blog Post");
133
+ draft.viewCount.increment(10);
134
+ });
135
+
136
+ console.log(doc.value);
137
+ // { title: "My Blog Post", viewCount: 10, tags: [], ... }
138
+ // ↑ CRDT value ↑ CRDT value ↑ empty state preserved
139
+ ```
140
+
141
+ ### The `change()` Function
142
+
143
+ All mutations happen within transactional `change()` blocks:
144
+
145
+ ```typescript
146
+ const result = doc.change((draft) => {
147
+ // Text operations
148
+ draft.title.insert(0, "📝");
149
+ draft.title.delete(5, 3);
150
+
151
+ // Counter operations
152
+ draft.viewCount.increment(1);
153
+ draft.viewCount.decrement(2);
154
+
155
+ // List operations
156
+ draft.tags.push("typescript");
157
+ draft.tags.insert(0, "loro");
158
+ draft.tags.delete(1, 1);
159
+
160
+ // Map operations (POJO values)
161
+ draft.metadata.set("author", "John Doe");
162
+ draft.metadata.delete("featured");
163
+
164
+ // Movable list operations
165
+ draft.sections.push({
166
+ heading: "Introduction",
167
+ content: "Welcome to my blog...",
168
+ order: 1,
169
+ });
170
+ draft.sections.move(0, 1); // Reorder sections
171
+ });
172
+
173
+ // All changes are committed atomically
174
+ console.log(result); // Updated document state
175
+ ```
176
+
177
+ ## Advanced Usage
178
+
179
+ ### Nested Structures
180
+
181
+ Handle complex nested documents with ease:
182
+
183
+ ```typescript
184
+ const complexSchema = Shape.doc({
185
+ article: Shape.map({
186
+ title: Shape.text(),
187
+ metadata: Shape.map({
188
+ views: Shape.counter(),
189
+ author: Shape.map({
190
+ name: Shape.plain.string(),
191
+ email: Shape.plain.string(),
192
+ }),
193
+ }),
194
+ }),
195
+ });
196
+
197
+ const emptyState = {
198
+ article: {
199
+ title: "",
200
+ metadata: {
201
+ views: 0,
202
+ author: {
203
+ name: "Anonymous",
204
+ email: "",
205
+ },
206
+ },
207
+ },
208
+ };
209
+
210
+ const doc = new TypedDoc(complexSchema, emptyState);
211
+
212
+ doc.change((draft) => {
213
+ draft.article.title.insert(0, "Deep Nesting Example");
214
+ draft.article.metadata.views.increment(5);
215
+ draft.article.metadata.author.name = "Alice"; // plain string update is captured and applied after closure
216
+ draft.article.metadata.author.email = "alice@example.com"; // same here
217
+ });
218
+ ```
219
+
220
+ ### Map Operations
221
+
222
+ For map containers, use the standard map methods:
223
+
224
+ ```typescript
225
+ const schema = Shape.doc({
226
+ settings: Shape.map({
227
+ theme: Shape.plain.string(),
228
+ collapsed: Shape.plain.boolean(),
229
+ width: Shape.plain.number(),
230
+ }),
231
+ });
232
+
233
+ doc.change((draft) => {
234
+ // Set individual values
235
+ draft.settings.theme = "dark";
236
+ draft.settings.collapsed = true;
237
+ draft.settings.width = 250;
238
+ });
239
+ ```
240
+
241
+ ### Lists with Container Items
242
+
243
+ Create lists containing CRDT containers for collaborative nested structures:
244
+
245
+ ```typescript
246
+ const collaborativeSchema = Shape.doc({
247
+ articles: Shape.list(
248
+ Shape.map({
249
+ title: Shape.text(), // Collaborative title
250
+ content: Shape.text(), // Collaborative content
251
+ tags: Shape.list(Shape.plain.string()), // Collaborative tag list
252
+ metadata: Shape.plain.object({
253
+ // Static metadata
254
+ authorId: Shape.plain.string(),
255
+ publishedAt: Shape.plain.string(),
256
+ }),
257
+ })
258
+ ),
259
+ });
260
+
261
+ doc.change((draft) => {
262
+ // Push creates and configures nested containers automatically
263
+ draft.articles.push({
264
+ title: "Collaborative Article",
265
+ content: "This content can be edited by multiple users...",
266
+ tags: ["collaboration", "crdt"],
267
+ metadata: {
268
+ authorId: "user123",
269
+ publishedAt: new Date().toISOString(),
270
+ },
271
+ });
272
+
273
+ // Later, edit the collaborative parts
274
+ // Note: articles[0] returns the actual CRDT containers
275
+ draft.articles.get(0)?.title.insert(0, "✨ ");
276
+ draft.articles.get(0)?.tags.push("real-time");
277
+ });
278
+ ```
279
+
280
+ ## API Reference
281
+
282
+ ### Core Functions
283
+
284
+ #### `new TypedDoc<T>(schema, emptyState, existingDoc?)`
285
+
286
+ Creates a new typed Loro document.
287
+
288
+ ```typescript
289
+ const doc = new TypedDoc(schema, emptyState);
290
+ const docFromExisting = new TypedDoc(schema, emptyState, existingLoroDoc);
291
+ ```
292
+
293
+ #### `doc.change(mutator)`
294
+
295
+ Applies transactional changes to a document.
296
+
297
+ ```typescript
298
+ const result = doc.change((draft) => {
299
+ // Make changes to draft
300
+ });
301
+ ```
302
+
303
+ ### Schema Builders
304
+
305
+ #### `Shape.doc(shape)`
306
+
307
+ Creates a document schema.
308
+
309
+ ```typescript
310
+ const schema = Shape.doc({
311
+ field1: Shape.text(),
312
+ field2: Shape.counter(),
313
+ });
314
+ ```
315
+
316
+ #### Container Types
317
+
318
+ - `Shape.text()` - Collaborative text editing
319
+ - `Shape.counter()` - Collaborative increment/decrement counters
320
+ - `Shape.list(itemSchema)` - Collaborative ordered lists
321
+ - `Shape.movableList(itemSchema)` - Collaborative Reorderable lists
322
+ - `Shape.map(shape)` - Collaborative key-value maps
323
+ - `Shape.tree(shape)` - Collaborative hierarchical tree structures (Note: incomplete implementation)
324
+
325
+ #### Value Types
326
+
327
+ - `Shape.plain.string()` - String values
328
+ - `Shape.plain.number()` - Number values
329
+ - `Shape.plain.boolean()` - Boolean values
330
+ - `Shape.plain.null()` - Null values
331
+ - `Shape.plain.object(shape)` - Object values
332
+ - `Shape.plain.array(itemShape)` - Array values
333
+
334
+ ### TypedDoc Methods
335
+
336
+ #### `.value`
337
+
338
+ Returns the current document state with empty state overlay.
339
+
340
+ ```typescript
341
+ const currentState = doc.value;
342
+ ```
343
+
344
+ This overlays "empty state" defaults with CRDT values, returning a JSON object with full type information (from your schema).
345
+
346
+ #### `.rawValue`
347
+
348
+ Returns raw CRDT state without empty state overlay.
349
+
350
+ ```typescript
351
+ const crdtState = doc.rawValue;
352
+ ```
353
+
354
+ #### `.loroDoc`
355
+
356
+ Access the underlying LoroDoc for advanced operations.
357
+
358
+ ```typescript
359
+ const loroDoc = doc.loroDoc;
360
+
361
+ const foods = loroDoc.getMap("foods");
362
+ const drinks = loroDoc.getOrCreateContainer("drinks", new LoroMap());
363
+ // etc.
364
+ ```
365
+
366
+ You may need this when interfacing with other libraries, such as `loro-dev/loro-prosemirror`.
367
+
368
+ ## CRDT Container Operations
369
+
370
+ ### Text Operations
371
+
372
+ ```typescript
373
+ draft.title.insert(index, content);
374
+ draft.title.delete(index, length);
375
+ draft.title.update(newContent); // Replace entire content
376
+ draft.title.mark(range, key, value); // Add formatting
377
+ draft.title.unmark(range, key); // Remove formatting
378
+ draft.title.toDelta(); // Get Delta format
379
+ draft.title.applyDelta(delta); // Apply Delta operations
380
+ ```
381
+
382
+ ### Counter Operations
383
+
384
+ ```typescript
385
+ draft.count.increment(value);
386
+ draft.count.decrement(value);
387
+ const current = draft.count.value;
388
+ ```
389
+
390
+ ### List Operations
391
+
392
+ ```typescript
393
+ draft.items.push(item);
394
+ draft.items.insert(index, item);
395
+ draft.items.delete(index, length);
396
+ const item = draft.items.get(index);
397
+ const array = draft.items.toArray();
398
+ const length = draft.items.length;
399
+ ```
400
+
401
+ #### Array-like Methods
402
+
403
+ Lists support familiar JavaScript array methods for filtering and finding items:
404
+
405
+ ```typescript
406
+ // Find items (returns mutable draft objects)
407
+ const foundItem = draft.todos.find(todo => todo.completed);
408
+ const foundIndex = draft.todos.findIndex(todo => todo.id === "123");
409
+
410
+ // Filter items (returns array of mutable draft objects)
411
+ const completedTodos = draft.todos.filter(todo => todo.completed);
412
+ const activeTodos = draft.todos.filter(todo => !todo.completed);
413
+
414
+ // Transform items (returns plain array, not mutable)
415
+ const todoTexts = draft.todos.map(todo => todo.text);
416
+ const todoIds = draft.todos.map(todo => todo.id);
417
+
418
+ // Check conditions
419
+ const hasCompleted = draft.todos.some(todo => todo.completed);
420
+ const allCompleted = draft.todos.every(todo => todo.completed);
421
+
422
+ // Iterate over items
423
+ draft.todos.forEach((todo, index) => {
424
+ console.log(`Todo ${index}: ${todo.text}`);
425
+ });
426
+ ```
427
+
428
+ **Important**: Methods like `find()` and `filter()` return **mutable draft objects** that you can modify directly:
429
+
430
+ ```typescript
431
+ doc.change((draft) => {
432
+ // Find and mutate pattern - very common!
433
+ const todo = draft.todos.find(t => t.id === "123");
434
+ if (todo) {
435
+ todo.completed = true; // ✅ This mutation will persist!
436
+ todo.text = "Updated text"; // ✅ This too!
437
+ }
438
+
439
+ // Filter and modify multiple items
440
+ const activeTodos = draft.todos.filter(t => !t.completed);
441
+ activeTodos.forEach(todo => {
442
+ todo.priority = "high"; // ✅ All mutations persist!
443
+ });
444
+ });
445
+ ```
446
+
447
+ This dual interface ensures predicates work with current data (including previous mutations in the same `change()` block) while returned objects remain mutable.
448
+
449
+ ### Movable List Operations
450
+
451
+ ```typescript
452
+ draft.tasks.push(item);
453
+ draft.tasks.insert(index, item);
454
+ draft.tasks.set(index, item); // Replace item
455
+ draft.tasks.move(fromIndex, toIndex); // Reorder
456
+ draft.tasks.delete(index, length);
457
+ ```
458
+
459
+ ### Map Operations
460
+
461
+ ```typescript
462
+ draft.metadata.set(key, value);
463
+ draft.metadata.get(key);
464
+ draft.metadata.delete(key);
465
+ draft.metadata.has(key);
466
+ draft.metadata.keys();
467
+ draft.metadata.values();
468
+
469
+ // Access nested values
470
+ const value = draft.metadata.get("key");
471
+ ```
472
+
473
+ ## Type Safety
474
+
475
+ Full TypeScript support with compile-time validation:
476
+
477
+ ```typescript
478
+ import { TypedDoc, Shape, type InferPlainType } from "@loro-extended/change";
479
+
480
+ // Define your desired interface
481
+ interface TodoDoc {
482
+ title: string;
483
+ todos: Array<{ id: string; text: string; done: boolean }>;
484
+ }
485
+
486
+ // Define the schema that matches your interface
487
+ const todoSchema = Shape.doc({
488
+ title: Shape.text(),
489
+ todos: Shape.list(
490
+ Shape.plain.object({
491
+ id: Shape.plain.string(),
492
+ text: Shape.plain.string(),
493
+ done: Shape.plain.boolean(),
494
+ })
495
+ ),
496
+ });
497
+
498
+ // Define empty state that matches your interface
499
+ const emptyState: TodoDoc = {
500
+ title: "My Todos",
501
+ todos: [],
502
+ };
503
+
504
+ // TypeScript will ensure the schema produces the correct type
505
+ const doc = new TypedDoc(todoSchema, emptyState);
506
+
507
+ // The result will be properly typed as TodoDoc
508
+ const result: TodoDoc = doc.change((draft) => {
509
+ draft.title.insert(0, "Hello"); // ✅ Valid - TypeScript knows this is LoroText
510
+ draft.todos.push({
511
+ // ✅ Valid - TypeScript knows the expected shape
512
+ id: "1",
513
+ text: "Learn Loro",
514
+ done: false,
515
+ });
516
+
517
+ // draft.title.insert(0, 123); // ❌ TypeScript error
518
+ // draft.todos.push({ invalid: true }); // ❌ TypeScript error
519
+ });
520
+
521
+ // You can also use type assertion to ensure schema compatibility
522
+ type SchemaType = InferPlainType<typeof todoSchema>;
523
+ const _typeCheck: TodoDoc = {} as SchemaType; // ✅ Will error if types don't match
524
+ ```
525
+
526
+ **Note**: Use `Shape.plain.null()` for nullable fields, as Loro treats `null` and `undefined` equivalently.
527
+
528
+ ## Integration with Existing Loro Code
529
+
530
+ `TypedDoc` works seamlessly with existing Loro applications:
531
+
532
+ ```typescript
533
+ import { LoroDoc } from "loro-crdt";
534
+
535
+ // Wrap existing LoroDoc
536
+ const existingDoc = new LoroDoc();
537
+ const typedDoc = new TypedDoc(schema, emptyState, existingDoc);
538
+
539
+ // Access underlying LoroDoc
540
+ const loroDoc = typedDoc.loroDoc;
541
+
542
+ // Use with existing Loro APIs
543
+ loroDoc.subscribe((event) => {
544
+ console.log("Document changed:", event);
545
+ });
546
+ ```
547
+
548
+ ## Performance Considerations
549
+
550
+ - All changes within a `change()` block are batched into a single transaction
551
+ - Empty state overlay is computed on-demand, not stored
552
+ - Container creation is lazy - containers are only created when accessed
553
+ - Type validation occurs at development time, not runtime
554
+
555
+ ## Contributing
556
+
557
+ This package is part of the loro-extended ecosystem. Contributions welcome!
558
+
559
+ - **Build**: `pnpm build`
560
+ - **Test**: `pnpm test`
561
+ - **Lint**: Uses Biome for formatting and linting
562
+
563
+ ## License
564
+
565
+ MIT