@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 +21 -0
- package/README.md +565 -0
- package/dist/index.d.ts +339 -0
- package/dist/index.js +1491 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
- package/src/change.test.ts +2006 -0
- package/src/change.ts +105 -0
- package/src/conversion.test.ts +728 -0
- package/src/conversion.ts +220 -0
- package/src/draft-nodes/base.ts +34 -0
- package/src/draft-nodes/counter.ts +21 -0
- package/src/draft-nodes/doc.ts +81 -0
- package/src/draft-nodes/list-base.ts +326 -0
- package/src/draft-nodes/list.ts +18 -0
- package/src/draft-nodes/map.ts +156 -0
- package/src/draft-nodes/movable-list.ts +26 -0
- package/src/draft-nodes/record.ts +215 -0
- package/src/draft-nodes/text.ts +48 -0
- package/src/draft-nodes/tree.ts +31 -0
- package/src/draft-nodes/utils.ts +55 -0
- package/src/index.ts +33 -0
- package/src/json-patch.test.ts +697 -0
- package/src/json-patch.ts +391 -0
- package/src/overlay.ts +90 -0
- package/src/record.test.ts +188 -0
- package/src/schema.fixtures.ts +138 -0
- package/src/shape.ts +348 -0
- package/src/types.ts +15 -0
- package/src/utils/type-guards.ts +210 -0
- package/src/validation.ts +261 -0
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
|