@loro-extended/change 1.0.1 → 2.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 +165 -46
- package/dist/index.d.ts +206 -101
- package/dist/index.js +363 -109
- package/dist/index.js.map +1 -1
- package/package.json +3 -4
- package/src/any-shape.test.ts +164 -0
- package/src/change.test.ts +255 -2
- package/src/derive-placeholder.ts +8 -0
- package/src/index.ts +15 -2
- package/src/overlay.ts +10 -0
- package/src/path-builder.ts +131 -0
- package/src/path-compiler.ts +64 -0
- package/src/path-evaluator.ts +76 -0
- package/src/path-selector.test.ts +322 -0
- package/src/path-selector.ts +131 -0
- package/src/readonly.test.ts +5 -4
- package/src/shape.ts +256 -40
- package/src/typed-refs/base.ts +6 -0
- package/src/typed-refs/counter.test.ts +2 -1
- package/src/typed-refs/doc.ts +13 -2
- package/src/typed-refs/json-compatibility.test.ts +27 -0
- package/src/typed-refs/list-base.ts +1 -1
- package/src/typed-refs/list.test.ts +1 -1
- package/src/typed-refs/list.ts +5 -2
- package/src/typed-refs/movable-list.test.ts +1 -1
- package/src/typed-refs/movable-list.ts +2 -2
- package/src/typed-refs/record.ts +11 -2
- package/src/typed-refs/struct.ts +9 -0
- package/src/typed-refs/tree.ts +6 -0
- package/src/typed-refs/utils.ts +13 -0
- package/src/validation.ts +9 -0
- package/src/presence-interface.ts +0 -52
- package/src/typed-presence.ts +0 -96
package/README.md
CHANGED
|
@@ -34,9 +34,11 @@ import { createTypedDoc, Shape, change } from "@loro-extended/change";
|
|
|
34
34
|
const schema = Shape.doc({
|
|
35
35
|
title: Shape.text().placeholder("My Todo List"),
|
|
36
36
|
count: Shape.counter(),
|
|
37
|
-
users: Shape.record(
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
users: Shape.record(
|
|
38
|
+
Shape.plain.struct({
|
|
39
|
+
name: Shape.plain.string(),
|
|
40
|
+
})
|
|
41
|
+
),
|
|
40
42
|
});
|
|
41
43
|
|
|
42
44
|
// Create a typed document
|
|
@@ -57,7 +59,7 @@ if ("alice" in doc.users) {
|
|
|
57
59
|
|
|
58
60
|
// Batched mutations - commit together (optional, for performance)
|
|
59
61
|
// Using functional helper (recommended)
|
|
60
|
-
change(doc, draft => {
|
|
62
|
+
change(doc, (draft) => {
|
|
61
63
|
draft.title.insert(0, "Change: ");
|
|
62
64
|
draft.count.increment(10);
|
|
63
65
|
draft.users.set("bob", { name: "Bob" });
|
|
@@ -199,13 +201,13 @@ console.log(doc.toJSON()); // Updated document state
|
|
|
199
201
|
|
|
200
202
|
### When to Use `change()` vs Direct Mutations
|
|
201
203
|
|
|
202
|
-
| Use Case
|
|
203
|
-
|
|
204
|
-
| Single mutation
|
|
205
|
-
| Multiple related mutations
|
|
206
|
-
| Atomic undo/redo
|
|
204
|
+
| Use Case | Approach |
|
|
205
|
+
| --------------------------------- | ------------------------------------ |
|
|
206
|
+
| Single mutation | Direct: `doc.count.increment(1)` |
|
|
207
|
+
| Multiple related mutations | Batched: `change(doc, d => { ... })` |
|
|
208
|
+
| Atomic undo/redo | Batched: `change(doc, d => { ... })` |
|
|
207
209
|
| Performance-critical bulk updates | Batched: `change(doc, d => { ... })` |
|
|
208
|
-
| Simple reads + writes
|
|
210
|
+
| Simple reads + writes | Direct: `doc.users.set(...)` |
|
|
209
211
|
|
|
210
212
|
> **Note:** The `$.change()` method is available as an escape hatch, but the functional `change()` helper is recommended for cleaner code.
|
|
211
213
|
|
|
@@ -276,11 +278,73 @@ function handlePresence(presence: typeof result) {
|
|
|
276
278
|
```
|
|
277
279
|
|
|
278
280
|
**Key features:**
|
|
281
|
+
|
|
279
282
|
- The discriminant key (e.g., `"type"`) determines which variant shape to use
|
|
280
283
|
- Missing fields are filled from the empty state of the matching variant
|
|
281
|
-
- Works seamlessly with `@loro-extended/react`'s `
|
|
284
|
+
- Works seamlessly with `@loro-extended/react`'s `useEphemeral` hook
|
|
282
285
|
- Full TypeScript support for discriminated union types
|
|
283
286
|
|
|
287
|
+
### Untyped Integration with External Libraries
|
|
288
|
+
|
|
289
|
+
When integrating with external libraries that manage their own document structure (like `loro-prosemirror`), you may want typed presence but untyped document content. Use `Shape.any()` as an escape hatch:
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
import { Shape } from "@loro-extended/change";
|
|
293
|
+
|
|
294
|
+
// Fully typed presence with binary cursor data
|
|
295
|
+
const CursorPresenceSchema = Shape.plain.struct({
|
|
296
|
+
anchor: Shape.plain.bytes().nullable(), // Uint8Array | null
|
|
297
|
+
focus: Shape.plain.bytes().nullable(),
|
|
298
|
+
user: Shape.plain
|
|
299
|
+
.struct({
|
|
300
|
+
name: Shape.plain.string(),
|
|
301
|
+
color: Shape.plain.string(),
|
|
302
|
+
})
|
|
303
|
+
.nullable(),
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// With @loro-extended/repo:
|
|
307
|
+
|
|
308
|
+
// Option 1: Shape.any() directly - entire document is untyped
|
|
309
|
+
const handle = repo.get(docId, Shape.any(), { presence: CursorPresenceSchema });
|
|
310
|
+
handle.loroDoc; // Raw LoroDoc - use Loro API directly
|
|
311
|
+
handle.loroDoc.getMap("anything").set("key", "value");
|
|
312
|
+
handle.presence.setSelf({
|
|
313
|
+
// Typed presence
|
|
314
|
+
anchor: cursor.encode(), // Uint8Array directly
|
|
315
|
+
focus: null,
|
|
316
|
+
user: { name: "Alice", color: "#ff0000" },
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Option 2: Shape.any() in a container - one container is untyped
|
|
320
|
+
const ProseMirrorDocShape = Shape.doc({
|
|
321
|
+
doc: Shape.any(), // loro-prosemirror manages this
|
|
322
|
+
metadata: Shape.struct({
|
|
323
|
+
// But we can still have typed containers
|
|
324
|
+
title: Shape.text(),
|
|
325
|
+
}),
|
|
326
|
+
});
|
|
327
|
+
const handle2 = repo.get(docId, ProseMirrorDocShape, { presence: CursorPresenceSchema });
|
|
328
|
+
handle2.doc.toJSON(); // { doc: unknown, metadata: { title: string } }
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**Key features:**
|
|
332
|
+
|
|
333
|
+
- `Shape.any()` creates an `AnyContainerShape` - type inference produces `unknown`
|
|
334
|
+
- `Shape.plain.any()` creates an `AnyValueShape` - type inference produces Loro's `Value` type
|
|
335
|
+
- `Shape.plain.bytes()` is an alias for `Shape.plain.uint8Array()` for better discoverability
|
|
336
|
+
- All support `.nullable()` for optional values
|
|
337
|
+
|
|
338
|
+
**When to use:**
|
|
339
|
+
|
|
340
|
+
| Scenario | Shape to Use |
|
|
341
|
+
| ------------------------------------------- | ------------------------------------------------------------------ |
|
|
342
|
+
| External library manages entire document | `repo.get(docId, Shape.any(), { presence: presenceSchema })` |
|
|
343
|
+
| External library manages one container | `Shape.doc({ doc: Shape.any(), ... })` |
|
|
344
|
+
| Flexible metadata in presence | `Shape.plain.any()` for dynamic values |
|
|
345
|
+
| Binary cursor/selection data | `Shape.plain.bytes().nullable()` for `Uint8Array` \| `null` |
|
|
346
|
+
| Full type safety | Use specific shapes like `Shape.struct()`, `Shape.text()` |
|
|
347
|
+
|
|
284
348
|
### Nested Structures
|
|
285
349
|
|
|
286
350
|
Handle complex nested documents with ease:
|
|
@@ -382,6 +446,31 @@ change(doc, (draft) => {
|
|
|
382
446
|
});
|
|
383
447
|
```
|
|
384
448
|
|
|
449
|
+
## Path Selector DSL
|
|
450
|
+
|
|
451
|
+
The `@loro-extended/change` package exports a type-safe path selector DSL for building (a subset of) JSONPath expressions with full TypeScript type inference. This is primarily used by `Handle.subscribe()` in `@loro-extended/repo` for efficient, type-safe subscriptions:
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
// In @loro-extended/repo, use with Handle.subscribe():
|
|
455
|
+
handle.subscribe(
|
|
456
|
+
(p) => p.books.$each.title, // Type-safe path selector
|
|
457
|
+
(titles, prev) => {
|
|
458
|
+
// titles: string[], prev: string[] | undefined
|
|
459
|
+
console.log("Titles changed:", titles);
|
|
460
|
+
}
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
// DSL constructs:
|
|
464
|
+
// p.config.theme - Property access
|
|
465
|
+
// p.books.$each - All items in list/record
|
|
466
|
+
// p.books.$at(0) - Item at index (supports negative: -1 = last)
|
|
467
|
+
// p.books.$first - First item (alias for $at(0))
|
|
468
|
+
// p.books.$last - Last item (alias for $at(-1))
|
|
469
|
+
// p.users.$key("alice") - Record value by key
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
See `@loro-extended/repo` documentation for full details on `Handle.subscribe()`.
|
|
473
|
+
|
|
385
474
|
## API Reference
|
|
386
475
|
|
|
387
476
|
### Core Functions
|
|
@@ -397,7 +486,7 @@ const doc = createTypedDoc(schema);
|
|
|
397
486
|
const docFromExisting = createTypedDoc(schema, existingLoroDoc);
|
|
398
487
|
```
|
|
399
488
|
|
|
400
|
-
#### `new TypedDoc<T>(schema, existingDoc?)`
|
|
489
|
+
#### `new TypedDoc<T>(schema, existingDoc?)` _(deprecated)_
|
|
401
490
|
|
|
402
491
|
Constructor-style API. Use `createTypedDoc()` instead for cleaner code.
|
|
403
492
|
|
|
@@ -423,7 +512,7 @@ change(doc, (draft) => {
|
|
|
423
512
|
});
|
|
424
513
|
|
|
425
514
|
// Chainable - change returns the doc
|
|
426
|
-
change(doc, d => d.count.increment(1)).count.increment(2);
|
|
515
|
+
change(doc, (d) => d.count.increment(1)).count.increment(2);
|
|
427
516
|
```
|
|
428
517
|
|
|
429
518
|
#### `doc.toJSON()`
|
|
@@ -482,6 +571,7 @@ const schema = Shape.doc({
|
|
|
482
571
|
- `Shape.struct(shape)` - Collaborative structs with fixed keys (uses LoroMap internally)
|
|
483
572
|
- `Shape.record(valueSchema)` - Collaborative key-value maps with dynamic string keys
|
|
484
573
|
- `Shape.tree(shape)` - Collaborative hierarchical tree structures (Note: incomplete implementation)
|
|
574
|
+
- `Shape.any()` - Escape hatch for untyped containers (see [Untyped Integration](#untyped-integration-with-external-libraries))
|
|
485
575
|
|
|
486
576
|
#### Value Types
|
|
487
577
|
|
|
@@ -491,11 +581,58 @@ const schema = Shape.doc({
|
|
|
491
581
|
- `Shape.plain.null()` - Null values
|
|
492
582
|
- `Shape.plain.undefined()` - Undefined values
|
|
493
583
|
- `Shape.plain.uint8Array()` - Binary data values
|
|
584
|
+
- `Shape.plain.bytes()` - Alias for `uint8Array()` for better discoverability
|
|
494
585
|
- `Shape.plain.struct(shape)` - Struct values with fixed keys
|
|
495
586
|
- `Shape.plain.record(valueShape)` - Object values with dynamic string keys
|
|
496
587
|
- `Shape.plain.array(itemShape)` - Array values
|
|
497
588
|
- `Shape.plain.union(shapes)` - Union of value types (e.g., `string | null`)
|
|
498
589
|
- `Shape.plain.discriminatedUnion(key, variants)` - Tagged union types with a discriminant key
|
|
590
|
+
- `Shape.plain.any()` - Escape hatch for untyped values (see [Untyped Integration](#untyped-integration-with-external-libraries))
|
|
591
|
+
|
|
592
|
+
#### Nullable Values
|
|
593
|
+
|
|
594
|
+
Use `.nullable()` on value types to create nullable fields with `null` as the default placeholder:
|
|
595
|
+
|
|
596
|
+
```typescript
|
|
597
|
+
const schema = Shape.doc({
|
|
598
|
+
profile: Shape.struct({
|
|
599
|
+
name: Shape.plain.string().placeholder("Anonymous"),
|
|
600
|
+
email: Shape.plain.string().nullable(), // string | null, defaults to null
|
|
601
|
+
age: Shape.plain.number().nullable(), // number | null, defaults to null
|
|
602
|
+
verified: Shape.plain.boolean().nullable(), // boolean | null, defaults to null
|
|
603
|
+
tags: Shape.plain.array(Shape.plain.string()).nullable(), // string[] | null
|
|
604
|
+
metadata: Shape.plain.record(Shape.plain.string()).nullable(), // Record<string, string> | null
|
|
605
|
+
location: Shape.plain
|
|
606
|
+
.struct({
|
|
607
|
+
// { lat: number, lng: number } | null
|
|
608
|
+
lat: Shape.plain.number(),
|
|
609
|
+
lng: Shape.plain.number(),
|
|
610
|
+
})
|
|
611
|
+
.nullable(),
|
|
612
|
+
}),
|
|
613
|
+
});
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
You can chain `.placeholder()` after `.nullable()` to customize the default value:
|
|
617
|
+
|
|
618
|
+
```typescript
|
|
619
|
+
const schema = Shape.doc({
|
|
620
|
+
settings: Shape.struct({
|
|
621
|
+
// Nullable string with custom default
|
|
622
|
+
nickname: Shape.plain.string().nullable().placeholder("Guest"),
|
|
623
|
+
}),
|
|
624
|
+
});
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
This is syntactic sugar for the more verbose union pattern:
|
|
628
|
+
|
|
629
|
+
```typescript
|
|
630
|
+
// These are equivalent:
|
|
631
|
+
email: Shape.plain.string().nullable();
|
|
632
|
+
email: Shape.plain
|
|
633
|
+
.union([Shape.plain.null(), Shape.plain.string()])
|
|
634
|
+
.placeholder(null);
|
|
635
|
+
```
|
|
499
636
|
|
|
500
637
|
### TypedDoc API
|
|
501
638
|
|
|
@@ -517,7 +654,7 @@ doc.users.set("alice", { name: "Alice" });
|
|
|
517
654
|
|
|
518
655
|
// Check existence
|
|
519
656
|
doc.users.has("alice"); // true
|
|
520
|
-
"alice" in doc.users;
|
|
657
|
+
"alice" in doc.users; // true
|
|
521
658
|
```
|
|
522
659
|
|
|
523
660
|
For batched mutations, use `$.change()` instead.
|
|
@@ -744,12 +881,12 @@ loroDoc.subscribe((event) => {
|
|
|
744
881
|
});
|
|
745
882
|
```
|
|
746
883
|
|
|
747
|
-
##
|
|
884
|
+
## TypedEphemeral (Presence)
|
|
748
885
|
|
|
749
|
-
The `
|
|
886
|
+
The `TypedEphemeral` interface in `@loro-extended/repo` provides type-safe access to ephemeral presence data with placeholder defaults. Define your presence schema and use it with `repo.get()`:
|
|
750
887
|
|
|
751
888
|
```typescript
|
|
752
|
-
import {
|
|
889
|
+
import { Shape } from "@loro-extended/change";
|
|
753
890
|
|
|
754
891
|
// Define a presence schema with placeholders
|
|
755
892
|
const PresenceSchema = Shape.plain.struct({
|
|
@@ -761,46 +898,28 @@ const PresenceSchema = Shape.plain.struct({
|
|
|
761
898
|
status: Shape.plain.string().placeholder("online"),
|
|
762
899
|
});
|
|
763
900
|
|
|
764
|
-
//
|
|
765
|
-
|
|
766
|
-
const typedPresence = new TypedPresence(PresenceSchema, presenceInterface);
|
|
901
|
+
// Use with @loro-extended/repo
|
|
902
|
+
const handle = repo.get("doc-id", DocSchema, { presence: PresenceSchema });
|
|
767
903
|
|
|
768
904
|
// Read your presence (with placeholder defaults merged in)
|
|
769
|
-
console.log(
|
|
905
|
+
console.log(handle.presence.self);
|
|
770
906
|
// { cursor: { x: 0, y: 0 }, name: "Anonymous", status: "online" }
|
|
771
907
|
|
|
772
908
|
// Set presence values
|
|
773
|
-
|
|
909
|
+
handle.presence.setSelf({ cursor: { x: 100, y: 200 }, name: "Alice" });
|
|
774
910
|
|
|
775
|
-
// Read
|
|
776
|
-
|
|
777
|
-
|
|
911
|
+
// Read other peers' presence
|
|
912
|
+
for (const [peerId, presence] of handle.presence.peers) {
|
|
913
|
+
console.log(`${peerId}: ${presence.name}`);
|
|
914
|
+
}
|
|
778
915
|
|
|
779
916
|
// Subscribe to presence changes
|
|
780
|
-
|
|
781
|
-
console.log(
|
|
782
|
-
console.log("All peers:", all);
|
|
917
|
+
handle.presence.subscribe(({ key, value, source }) => {
|
|
918
|
+
console.log(`Peer ${key} updated:`, value);
|
|
783
919
|
});
|
|
784
920
|
```
|
|
785
921
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
`TypedPresence` works with any object implementing `PresenceInterface`:
|
|
789
|
-
|
|
790
|
-
```typescript
|
|
791
|
-
import type { PresenceInterface, ObjectValue } from "@loro-extended/change";
|
|
792
|
-
|
|
793
|
-
interface PresenceInterface {
|
|
794
|
-
set: (values: ObjectValue) => void;
|
|
795
|
-
get: (key: string) => Value;
|
|
796
|
-
readonly self: ObjectValue;
|
|
797
|
-
readonly all: Record<string, ObjectValue>;
|
|
798
|
-
setRaw: (key: string, value: Value) => void;
|
|
799
|
-
subscribe: (cb: (values: ObjectValue) => void) => () => void;
|
|
800
|
-
}
|
|
801
|
-
```
|
|
802
|
-
|
|
803
|
-
This is typically provided by `UntypedDocHandle.presence` in `@loro-extended/repo`.
|
|
922
|
+
See `@loro-extended/repo` documentation for full details on the `TypedEphemeral` interface.
|
|
804
923
|
|
|
805
924
|
## Performance Considerations
|
|
806
925
|
|