@loro-extended/change 1.1.0 → 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 +135 -56
- package/dist/index.d.ts +192 -94
- package/dist/index.js +271 -77
- 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 +4 -2
- package/src/derive-placeholder.ts +8 -0
- package/src/index.ts +13 -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 +120 -5
- 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,13 @@ 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))
|
|
499
591
|
|
|
500
592
|
#### Nullable Values
|
|
501
593
|
|
|
@@ -505,15 +597,18 @@ Use `.nullable()` on value types to create nullable fields with `null` as the de
|
|
|
505
597
|
const schema = Shape.doc({
|
|
506
598
|
profile: Shape.struct({
|
|
507
599
|
name: Shape.plain.string().placeholder("Anonymous"),
|
|
508
|
-
email: Shape.plain.string().nullable(),
|
|
509
|
-
age: Shape.plain.number().nullable(),
|
|
510
|
-
verified: Shape.plain.boolean().nullable(),
|
|
511
|
-
tags: Shape.plain.array(Shape.plain.string()).nullable(),
|
|
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
|
|
512
604
|
metadata: Shape.plain.record(Shape.plain.string()).nullable(), // Record<string, string> | null
|
|
513
|
-
location: Shape.plain
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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(),
|
|
517
612
|
}),
|
|
518
613
|
});
|
|
519
614
|
```
|
|
@@ -533,8 +628,10 @@ This is syntactic sugar for the more verbose union pattern:
|
|
|
533
628
|
|
|
534
629
|
```typescript
|
|
535
630
|
// These are equivalent:
|
|
536
|
-
email: Shape.plain.string().nullable()
|
|
537
|
-
email: Shape.plain
|
|
631
|
+
email: Shape.plain.string().nullable();
|
|
632
|
+
email: Shape.plain
|
|
633
|
+
.union([Shape.plain.null(), Shape.plain.string()])
|
|
634
|
+
.placeholder(null);
|
|
538
635
|
```
|
|
539
636
|
|
|
540
637
|
### TypedDoc API
|
|
@@ -557,7 +654,7 @@ doc.users.set("alice", { name: "Alice" });
|
|
|
557
654
|
|
|
558
655
|
// Check existence
|
|
559
656
|
doc.users.has("alice"); // true
|
|
560
|
-
"alice" in doc.users;
|
|
657
|
+
"alice" in doc.users; // true
|
|
561
658
|
```
|
|
562
659
|
|
|
563
660
|
For batched mutations, use `$.change()` instead.
|
|
@@ -784,12 +881,12 @@ loroDoc.subscribe((event) => {
|
|
|
784
881
|
});
|
|
785
882
|
```
|
|
786
883
|
|
|
787
|
-
##
|
|
884
|
+
## TypedEphemeral (Presence)
|
|
788
885
|
|
|
789
|
-
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()`:
|
|
790
887
|
|
|
791
888
|
```typescript
|
|
792
|
-
import {
|
|
889
|
+
import { Shape } from "@loro-extended/change";
|
|
793
890
|
|
|
794
891
|
// Define a presence schema with placeholders
|
|
795
892
|
const PresenceSchema = Shape.plain.struct({
|
|
@@ -801,46 +898,28 @@ const PresenceSchema = Shape.plain.struct({
|
|
|
801
898
|
status: Shape.plain.string().placeholder("online"),
|
|
802
899
|
});
|
|
803
900
|
|
|
804
|
-
//
|
|
805
|
-
|
|
806
|
-
const typedPresence = new TypedPresence(PresenceSchema, presenceInterface);
|
|
901
|
+
// Use with @loro-extended/repo
|
|
902
|
+
const handle = repo.get("doc-id", DocSchema, { presence: PresenceSchema });
|
|
807
903
|
|
|
808
904
|
// Read your presence (with placeholder defaults merged in)
|
|
809
|
-
console.log(
|
|
905
|
+
console.log(handle.presence.self);
|
|
810
906
|
// { cursor: { x: 0, y: 0 }, name: "Anonymous", status: "online" }
|
|
811
907
|
|
|
812
908
|
// Set presence values
|
|
813
|
-
|
|
909
|
+
handle.presence.setSelf({ cursor: { x: 100, y: 200 }, name: "Alice" });
|
|
814
910
|
|
|
815
|
-
// Read
|
|
816
|
-
|
|
817
|
-
|
|
911
|
+
// Read other peers' presence
|
|
912
|
+
for (const [peerId, presence] of handle.presence.peers) {
|
|
913
|
+
console.log(`${peerId}: ${presence.name}`);
|
|
914
|
+
}
|
|
818
915
|
|
|
819
916
|
// Subscribe to presence changes
|
|
820
|
-
|
|
821
|
-
console.log(
|
|
822
|
-
console.log("All peers:", all);
|
|
917
|
+
handle.presence.subscribe(({ key, value, source }) => {
|
|
918
|
+
console.log(`Peer ${key} updated:`, value);
|
|
823
919
|
});
|
|
824
920
|
```
|
|
825
921
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
`TypedPresence` works with any object implementing `PresenceInterface`:
|
|
829
|
-
|
|
830
|
-
```typescript
|
|
831
|
-
import type { PresenceInterface, ObjectValue } from "@loro-extended/change";
|
|
832
|
-
|
|
833
|
-
interface PresenceInterface {
|
|
834
|
-
set: (values: ObjectValue) => void;
|
|
835
|
-
get: (key: string) => Value;
|
|
836
|
-
readonly self: ObjectValue;
|
|
837
|
-
readonly all: Record<string, ObjectValue>;
|
|
838
|
-
setRaw: (key: string, value: Value) => void;
|
|
839
|
-
subscribe: (cb: (values: ObjectValue) => void) => () => void;
|
|
840
|
-
}
|
|
841
|
-
```
|
|
842
|
-
|
|
843
|
-
This is typically provided by `UntypedDocHandle.presence` in `@loro-extended/repo`.
|
|
922
|
+
See `@loro-extended/repo` documentation for full details on the `TypedEphemeral` interface.
|
|
844
923
|
|
|
845
924
|
## Performance Considerations
|
|
846
925
|
|