@loro-extended/change 0.9.0 → 1.0.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/README.md +179 -69
- package/dist/index.d.ts +369 -172
- package/dist/index.js +691 -382
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/change.test.ts +180 -175
- package/src/conversion.test.ts +91 -91
- package/src/conversion.ts +12 -12
- package/src/derive-placeholder.test.ts +14 -14
- package/src/derive-placeholder.ts +3 -3
- package/src/discriminated-union-assignability.test.ts +7 -7
- package/src/discriminated-union-tojson.test.ts +13 -24
- package/src/discriminated-union.test.ts +9 -8
- package/src/equality.test.ts +10 -2
- package/src/functional-helpers.test.ts +149 -0
- package/src/functional-helpers.ts +61 -0
- package/src/grand-unified-api.test.ts +423 -0
- package/src/index.ts +8 -6
- package/src/json-patch.test.ts +64 -56
- package/src/overlay-recursion.test.ts +326 -0
- package/src/overlay.ts +54 -17
- package/src/readonly.test.ts +27 -26
- package/src/shape.ts +103 -21
- package/src/typed-doc.ts +227 -58
- package/src/typed-refs/base.ts +33 -1
- package/src/typed-refs/counter.test.ts +44 -13
- package/src/typed-refs/counter.ts +42 -5
- package/src/typed-refs/doc.ts +29 -30
- package/src/typed-refs/json-compatibility.test.ts +37 -32
- package/src/typed-refs/list-base.ts +49 -21
- package/src/typed-refs/list.test.ts +4 -3
- package/src/typed-refs/movable-list.test.ts +3 -2
- package/src/typed-refs/movable-list.ts +6 -3
- package/src/typed-refs/proxy-handlers.ts +14 -1
- package/src/typed-refs/record.test.ts +116 -51
- package/src/typed-refs/record.ts +86 -81
- package/src/typed-refs/{map.ts → struct.ts} +66 -78
- package/src/typed-refs/text.ts +48 -7
- package/src/typed-refs/tree.ts +3 -3
- package/src/typed-refs/utils.ts +120 -13
- package/src/types.test.ts +34 -39
- package/src/types.ts +5 -40
- package/src/utils/type-guards.ts +11 -6
- package/src/validation.ts +10 -10
package/README.md
CHANGED
|
@@ -11,11 +11,11 @@ A schema-driven, type-safe wrapper for [Loro CRDT](https://github.com/loro-dev/l
|
|
|
11
11
|
Working with Loro directly involves somewhat verbose container operations and complex type management. The `change` package provides:
|
|
12
12
|
|
|
13
13
|
- **Schema-First Design**: Define your document structure with type-safe schemas
|
|
14
|
-
- **Natural Syntax**: Write `
|
|
14
|
+
- **Natural Syntax**: Write `doc.title.insert(0, "Hello")` instead of verbose CRDT operations
|
|
15
15
|
- **Empty State Overlay**: Seamlessly blend default values with CRDT state
|
|
16
16
|
- **Full Type Safety**: Complete TypeScript support with compile-time validation
|
|
17
|
-
- **Transactional Changes**: All mutations within a
|
|
18
|
-
- **Loro Compatible**: Works seamlessly with existing Loro code (`
|
|
17
|
+
- **Transactional Changes**: All mutations within a `$.batch()` block are atomic
|
|
18
|
+
- **Loro Compatible**: Works seamlessly with existing Loro code (`doc.$.loroDoc` is a familiar `LoroDoc`)
|
|
19
19
|
|
|
20
20
|
## Installation
|
|
21
21
|
|
|
@@ -28,35 +28,45 @@ pnpm add @loro-extended/change loro-crdt
|
|
|
28
28
|
## Quick Start
|
|
29
29
|
|
|
30
30
|
```typescript
|
|
31
|
-
import {
|
|
31
|
+
import { createTypedDoc, Shape, batch, toJSON } from "@loro-extended/change";
|
|
32
32
|
|
|
33
33
|
// Define your document schema
|
|
34
34
|
const schema = Shape.doc({
|
|
35
35
|
title: Shape.text().placeholder("My Todo List"),
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
completed: Shape.plain.boolean(),
|
|
41
|
-
})
|
|
42
|
-
),
|
|
36
|
+
count: Shape.counter(),
|
|
37
|
+
users: Shape.record(Shape.plain.object({
|
|
38
|
+
name: Shape.plain.string(),
|
|
39
|
+
})),
|
|
43
40
|
});
|
|
44
41
|
|
|
45
42
|
// Create a typed document
|
|
46
|
-
const doc =
|
|
43
|
+
const doc = createTypedDoc(schema);
|
|
47
44
|
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
45
|
+
// Direct mutations - commit immediately (auto-commit mode)
|
|
46
|
+
doc.title.insert(0, "📝 Todo");
|
|
47
|
+
doc.count.increment(5);
|
|
48
|
+
doc.users.set("alice", { name: "Alice" });
|
|
49
|
+
|
|
50
|
+
// Check existence
|
|
51
|
+
if (doc.users.has("alice")) {
|
|
52
|
+
console.log("Alice exists!");
|
|
53
|
+
}
|
|
54
|
+
if ("alice" in doc.users) {
|
|
55
|
+
console.log("Also works with 'in' operator!");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Batched mutations - commit together (optional, for performance)
|
|
59
|
+
// Using functional helper (recommended)
|
|
60
|
+
batch(doc, draft => {
|
|
61
|
+
draft.title.insert(0, "Batch: ");
|
|
62
|
+
draft.count.increment(10);
|
|
63
|
+
draft.users.set("bob", { name: "Bob" });
|
|
56
64
|
});
|
|
65
|
+
// All changes commit as one transaction
|
|
57
66
|
|
|
58
|
-
|
|
59
|
-
|
|
67
|
+
// Get JSON snapshot using functional helper
|
|
68
|
+
console.log(doc.toJSON());
|
|
69
|
+
// { title: "Batch: 📝 Todo", count: 15, users: { alice: { name: "Alice" }, bob: { name: "Bob" } } }
|
|
60
70
|
```
|
|
61
71
|
|
|
62
72
|
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.
|
|
@@ -122,29 +132,40 @@ const blogSchemaWithDefaults = Shape.doc({
|
|
|
122
132
|
),
|
|
123
133
|
});
|
|
124
134
|
|
|
125
|
-
const doc =
|
|
135
|
+
const doc = createTypedDoc(blogSchemaWithDefaults);
|
|
126
136
|
|
|
127
137
|
// Initially returns empty state
|
|
128
|
-
console.log(doc.
|
|
138
|
+
console.log(doc.toJSON());
|
|
129
139
|
// { title: "Untitled Document", viewCount: 0, ... }
|
|
130
140
|
|
|
131
141
|
// After changes, CRDT values take priority over empty state
|
|
132
|
-
doc
|
|
142
|
+
batch(doc, (draft) => {
|
|
133
143
|
draft.title.insert(0, "My Blog Post");
|
|
134
144
|
draft.viewCount.increment(10);
|
|
135
145
|
});
|
|
136
146
|
|
|
137
|
-
console.log(doc.
|
|
147
|
+
console.log(doc.toJSON());
|
|
138
148
|
// { title: "My Blog Post", viewCount: 10, tags: [], ... }
|
|
139
149
|
// ↑ CRDT value ↑ CRDT value ↑ empty state preserved
|
|
140
150
|
```
|
|
141
151
|
|
|
142
|
-
###
|
|
152
|
+
### Direct Mutations vs Batched Mutations
|
|
153
|
+
|
|
154
|
+
With the Grand Unified API, schema properties are accessed directly on the doc. Mutations commit immediately by default:
|
|
143
155
|
|
|
144
|
-
|
|
156
|
+
```typescript
|
|
157
|
+
// Direct mutations - each commits immediately
|
|
158
|
+
doc.title.insert(0, "📝");
|
|
159
|
+
doc.viewCount.increment(1);
|
|
160
|
+
doc.tags.push("typescript");
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
For batched operations (better performance, atomic undo), use `batch()`:
|
|
145
164
|
|
|
146
165
|
```typescript
|
|
147
|
-
|
|
166
|
+
import { batch, toJSON } from "@loro-extended/change";
|
|
167
|
+
|
|
168
|
+
batch(doc, (draft) => {
|
|
148
169
|
// Text operations
|
|
149
170
|
draft.title.insert(0, "📝");
|
|
150
171
|
draft.title.delete(5, 3);
|
|
@@ -171,10 +192,23 @@ const result = doc.change((draft) => {
|
|
|
171
192
|
draft.sections.move(0, 1); // Reorder sections
|
|
172
193
|
});
|
|
173
194
|
|
|
174
|
-
// All changes are committed atomically
|
|
175
|
-
|
|
195
|
+
// All changes are committed atomically as one transaction
|
|
196
|
+
// batch() returns the doc for chaining
|
|
197
|
+
console.log(doc.toJSON()); // Updated document state
|
|
176
198
|
```
|
|
177
199
|
|
|
200
|
+
### When to Use `batch()` vs Direct Mutations
|
|
201
|
+
|
|
202
|
+
| Use Case | Approach |
|
|
203
|
+
|----------|----------|
|
|
204
|
+
| Single mutation | Direct: `doc.count.increment(1)` |
|
|
205
|
+
| Multiple related mutations | Batched: `batch(doc, d => { ... })` |
|
|
206
|
+
| Atomic undo/redo | Batched: `batch(doc, d => { ... })` |
|
|
207
|
+
| Performance-critical bulk updates | Batched: `batch(doc, d => { ... })` |
|
|
208
|
+
| Simple reads + writes | Direct: `doc.users.set(...)` |
|
|
209
|
+
|
|
210
|
+
> **Note:** The `$.change()` and `$.batch()` methods are available as an escape hatch, but the functional `batch()` helper is recommended for cleaner code.
|
|
211
|
+
|
|
178
212
|
## Advanced Usage
|
|
179
213
|
|
|
180
214
|
### Discriminated Unions
|
|
@@ -278,9 +312,9 @@ const emptyState = {
|
|
|
278
312
|
},
|
|
279
313
|
};
|
|
280
314
|
|
|
281
|
-
const doc =
|
|
315
|
+
const doc = createTypedDoc(complexSchema);
|
|
282
316
|
|
|
283
|
-
doc
|
|
317
|
+
batch(doc, (draft) => {
|
|
284
318
|
draft.article.title.insert(0, "Deep Nesting Example");
|
|
285
319
|
draft.article.metadata.views.increment(5);
|
|
286
320
|
draft.article.metadata.author.name = "Alice"; // plain string update is captured and applied after closure
|
|
@@ -301,7 +335,7 @@ const schema = Shape.doc({
|
|
|
301
335
|
}),
|
|
302
336
|
});
|
|
303
337
|
|
|
304
|
-
doc
|
|
338
|
+
batch(doc, (draft) => {
|
|
305
339
|
// Set individual values
|
|
306
340
|
draft.settings.theme = "dark";
|
|
307
341
|
draft.settings.collapsed = true;
|
|
@@ -329,7 +363,7 @@ const collaborativeSchema = Shape.doc({
|
|
|
329
363
|
),
|
|
330
364
|
});
|
|
331
365
|
|
|
332
|
-
doc
|
|
366
|
+
batch(doc, (draft) => {
|
|
333
367
|
// Push creates and configures nested containers automatically
|
|
334
368
|
draft.articles.push({
|
|
335
369
|
title: "Collaborative Article",
|
|
@@ -352,22 +386,79 @@ doc.change((draft) => {
|
|
|
352
386
|
|
|
353
387
|
### Core Functions
|
|
354
388
|
|
|
355
|
-
#### `
|
|
389
|
+
#### `createTypedDoc<T>(schema, existingDoc?)`
|
|
390
|
+
|
|
391
|
+
Creates a new typed Loro document. This is the recommended way to create documents.
|
|
356
392
|
|
|
357
|
-
|
|
393
|
+
```typescript
|
|
394
|
+
import { createTypedDoc, Shape } from "@loro-extended/change";
|
|
395
|
+
|
|
396
|
+
const doc = createTypedDoc(schema);
|
|
397
|
+
const docFromExisting = createTypedDoc(schema, existingLoroDoc);
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
#### `new TypedDoc<T>(schema, existingDoc?)` *(deprecated)*
|
|
401
|
+
|
|
402
|
+
Constructor-style API. Use `createTypedDoc()` instead for cleaner code.
|
|
358
403
|
|
|
359
404
|
```typescript
|
|
405
|
+
// Deprecated - use createTypedDoc() instead
|
|
360
406
|
const doc = new TypedDoc(schema);
|
|
361
|
-
const docFromExisting = new TypedDoc(schema, existingLoroDoc);
|
|
362
407
|
```
|
|
363
408
|
|
|
364
|
-
|
|
409
|
+
### Functional Helpers (Recommended)
|
|
410
|
+
|
|
411
|
+
These functional helpers provide a cleaner API and are the recommended way to work with TypedDoc:
|
|
412
|
+
|
|
413
|
+
#### `batch(doc, mutator)`
|
|
414
|
+
|
|
415
|
+
Batches multiple mutations into a single transaction. Returns the doc for chaining.
|
|
416
|
+
|
|
417
|
+
```typescript
|
|
418
|
+
import { batch } from "@loro-extended/change";
|
|
419
|
+
|
|
420
|
+
batch(doc, (draft) => {
|
|
421
|
+
draft.title.insert(0, "Hello");
|
|
422
|
+
draft.count.increment(5);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// Chainable - batch returns the doc
|
|
426
|
+
batch(doc, d => d.count.increment(1)).count.increment(2);
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
#### `toJSON(doc)`
|
|
430
|
+
|
|
431
|
+
Returns the full plain JavaScript object representation of the document.
|
|
432
|
+
|
|
433
|
+
```typescript
|
|
434
|
+
import { toJSON } from "@loro-extended/change";
|
|
435
|
+
|
|
436
|
+
const snapshot = toJSON(doc);
|
|
437
|
+
// { title: "Hello", count: 5, ... }
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
#### `getLoroDoc(doc)`
|
|
441
|
+
|
|
442
|
+
Access the underlying LoroDoc for advanced operations.
|
|
443
|
+
|
|
444
|
+
```typescript
|
|
445
|
+
import { getLoroDoc } from "@loro-extended/change";
|
|
446
|
+
|
|
447
|
+
const loroDoc = getLoroDoc(doc);
|
|
448
|
+
loroDoc.subscribe((event) => console.log("Changed:", event));
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
### $ Namespace (Escape Hatch)
|
|
365
452
|
|
|
366
|
-
|
|
453
|
+
The `$` namespace provides access to meta-operations. While functional helpers are recommended, the `$` namespace is available for advanced use cases:
|
|
454
|
+
|
|
455
|
+
#### `doc.$.batch(mutator)`
|
|
456
|
+
|
|
457
|
+
Same as `batch(doc, mutator)`.
|
|
367
458
|
|
|
368
459
|
```typescript
|
|
369
|
-
|
|
370
|
-
// Make changes to draft
|
|
460
|
+
doc.$.batch((draft) => {
|
|
461
|
+
// Make changes to draft - all commit together
|
|
371
462
|
});
|
|
372
463
|
```
|
|
373
464
|
|
|
@@ -408,39 +499,54 @@ const schema = Shape.doc({
|
|
|
408
499
|
- `Shape.plain.union(shapes)` - Union of value types (e.g., `string | null`)
|
|
409
500
|
- `Shape.plain.discriminatedUnion(key, variants)` - Tagged union types with a discriminant key
|
|
410
501
|
|
|
411
|
-
### TypedDoc
|
|
502
|
+
### TypedDoc API
|
|
412
503
|
|
|
413
|
-
|
|
504
|
+
With the proxy-based API, schema properties are accessed directly on the doc object, and meta-operations are accessed via the `$` namespace.
|
|
414
505
|
|
|
415
|
-
|
|
506
|
+
#### Direct Schema Access
|
|
507
|
+
|
|
508
|
+
Access schema properties directly on the doc. Mutations commit immediately (auto-commit mode).
|
|
416
509
|
|
|
417
510
|
```typescript
|
|
418
|
-
|
|
511
|
+
// Read values
|
|
512
|
+
const title = doc.title.toString();
|
|
513
|
+
const count = doc.count.value;
|
|
514
|
+
|
|
515
|
+
// Mutate directly - commits immediately
|
|
516
|
+
doc.title.insert(0, "Hello");
|
|
517
|
+
doc.count.increment(5);
|
|
518
|
+
doc.users.set("alice", { name: "Alice" });
|
|
519
|
+
|
|
520
|
+
// Check existence
|
|
521
|
+
doc.users.has("alice"); // true
|
|
522
|
+
"alice" in doc.users; // true
|
|
419
523
|
```
|
|
420
524
|
|
|
421
|
-
|
|
525
|
+
For batched mutations, use `$.batch()` instead.
|
|
422
526
|
|
|
423
|
-
####
|
|
527
|
+
#### `doc.$.toJSON()`
|
|
424
528
|
|
|
425
|
-
Returns
|
|
529
|
+
Same as `doc.toJSON`. Returns the full plain JavaScript object representation.
|
|
426
530
|
|
|
427
531
|
```typescript
|
|
428
|
-
const
|
|
532
|
+
const snapshot = doc.$.toJSON();
|
|
429
533
|
```
|
|
430
534
|
|
|
431
|
-
####
|
|
535
|
+
#### `doc.$.rawValue`
|
|
432
536
|
|
|
433
|
-
|
|
537
|
+
Returns raw CRDT state without empty state overlay.
|
|
434
538
|
|
|
435
539
|
```typescript
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
const foods = loroDoc.getMap("foods");
|
|
439
|
-
const drinks = loroDoc.getOrCreateContainer("drinks", new LoroMap());
|
|
440
|
-
// etc.
|
|
540
|
+
const crdtState = doc.$.rawValue;
|
|
441
541
|
```
|
|
442
542
|
|
|
443
|
-
|
|
543
|
+
#### `doc.$.loroDoc`
|
|
544
|
+
|
|
545
|
+
Same as `getLoroDoc(doc)`. Access the underlying LoroDoc.
|
|
546
|
+
|
|
547
|
+
```typescript
|
|
548
|
+
const loroDoc = doc.$.loroDoc;
|
|
549
|
+
```
|
|
444
550
|
|
|
445
551
|
## CRDT Container Operations
|
|
446
552
|
|
|
@@ -505,7 +611,7 @@ draft.todos.forEach((todo, index) => {
|
|
|
505
611
|
**Important**: Methods like `find()` and `filter()` return **mutable draft objects** that you can modify directly:
|
|
506
612
|
|
|
507
613
|
```typescript
|
|
508
|
-
doc
|
|
614
|
+
batch(doc, (draft) => {
|
|
509
615
|
// Find and mutate pattern - very common!
|
|
510
616
|
const todo = draft.todos.find((t) => t.id === "123");
|
|
511
617
|
if (todo) {
|
|
@@ -521,7 +627,7 @@ doc.change((draft) => {
|
|
|
521
627
|
});
|
|
522
628
|
```
|
|
523
629
|
|
|
524
|
-
This dual interface ensures predicates work with current data (including previous mutations in the same `
|
|
630
|
+
This dual interface ensures predicates work with current data (including previous mutations in the same `batch()` block) while returned objects remain mutable.
|
|
525
631
|
|
|
526
632
|
### Movable List Operations
|
|
527
633
|
|
|
@@ -553,16 +659,16 @@ You can easily get a plain JavaScript object snapshot of any part of the documen
|
|
|
553
659
|
|
|
554
660
|
```typescript
|
|
555
661
|
// Get full document snapshot
|
|
556
|
-
const snapshot = doc
|
|
662
|
+
const snapshot = doc.$.toJSON();
|
|
557
663
|
|
|
558
664
|
// Get snapshot of a specific list
|
|
559
|
-
const todos = doc.
|
|
665
|
+
const todos = doc.todos.toJSON(); // returns plain array of todos
|
|
560
666
|
|
|
561
667
|
// Works with nested structures
|
|
562
|
-
const metadata = doc.
|
|
668
|
+
const metadata = doc.metadata.toJSON(); // returns plain object
|
|
563
669
|
|
|
564
670
|
// Serialize as JSON
|
|
565
|
-
const serializedMetadata = JSON.stringify(doc.
|
|
671
|
+
const serializedMetadata = JSON.stringify(doc.metadata); // returns string
|
|
566
672
|
```
|
|
567
673
|
|
|
568
674
|
**Note:** `JSON.stringify()` is recommended for serialization as it handles all data types correctly. `.toJSON()` is available on all `TypedRef` objects and proxied placeholders for convenience when you need a direct object snapshot.
|
|
@@ -595,8 +701,8 @@ const todoSchema = Shape.doc({
|
|
|
595
701
|
// TypeScript will ensure the schema produces the correct type
|
|
596
702
|
const doc = new TypedDoc(todoSchema);
|
|
597
703
|
|
|
598
|
-
//
|
|
599
|
-
|
|
704
|
+
// Mutations are type-safe
|
|
705
|
+
batch(doc, (draft) => {
|
|
600
706
|
draft.title.insert(0, "Hello"); // ✅ Valid - TypeScript knows this is LoroText
|
|
601
707
|
draft.todos.push({
|
|
602
708
|
// ✅ Valid - TypeScript knows the expected shape
|
|
@@ -609,6 +715,9 @@ const result: TodoDoc = doc.change((draft) => {
|
|
|
609
715
|
// draft.todos.push({ invalid: true }); // ❌ TypeScript error
|
|
610
716
|
});
|
|
611
717
|
|
|
718
|
+
// The result is properly typed as TodoDoc
|
|
719
|
+
const result: TodoDoc = doc.toJSON();
|
|
720
|
+
|
|
612
721
|
// You can also use type assertion to ensure schema compatibility
|
|
613
722
|
type SchemaType = InferPlainType<typeof todoSchema>;
|
|
614
723
|
const _typeCheck: TodoDoc = {} as SchemaType; // ✅ Will error if types don't match
|
|
@@ -622,13 +731,14 @@ const _typeCheck: TodoDoc = {} as SchemaType; // ✅ Will error if types don't m
|
|
|
622
731
|
|
|
623
732
|
```typescript
|
|
624
733
|
import { LoroDoc } from "loro-crdt";
|
|
734
|
+
import { createTypedDoc, getLoroDoc } from "@loro-extended/change";
|
|
625
735
|
|
|
626
736
|
// Wrap existing LoroDoc
|
|
627
737
|
const existingDoc = new LoroDoc();
|
|
628
|
-
const typedDoc =
|
|
738
|
+
const typedDoc = createTypedDoc(schema, existingDoc);
|
|
629
739
|
|
|
630
740
|
// Access underlying LoroDoc
|
|
631
|
-
const loroDoc = typedDoc
|
|
741
|
+
const loroDoc = getLoroDoc(typedDoc);
|
|
632
742
|
|
|
633
743
|
// Use with existing Loro APIs
|
|
634
744
|
loroDoc.subscribe((event) => {
|
|
@@ -696,7 +806,7 @@ This is typically provided by `UntypedDocHandle.presence` in `@loro-extended/rep
|
|
|
696
806
|
|
|
697
807
|
## Performance Considerations
|
|
698
808
|
|
|
699
|
-
- All changes within a `
|
|
809
|
+
- All changes within a `batch()` call are batched into a single transaction
|
|
700
810
|
- Empty state overlay is computed on-demand, not stored
|
|
701
811
|
- Container creation is lazy - containers are only created when accessed
|
|
702
812
|
- Type validation occurs at development time, not runtime
|