@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 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(Shape.plain.struct({
38
- name: Shape.plain.string(),
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 | Approach |
203
- |----------|----------|
204
- | Single mutation | Direct: `doc.count.increment(1)` |
205
- | Multiple related mutations | Batched: `change(doc, d => { ... })` |
206
- | Atomic undo/redo | Batched: `change(doc, d => { ... })` |
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 | Direct: `doc.users.set(...)` |
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 `usePresence` hook
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?)` *(deprecated)*
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(), // string | null, defaults to null
509
- age: Shape.plain.number().nullable(), // number | null, defaults to null
510
- verified: Shape.plain.boolean().nullable(), // boolean | null, defaults to null
511
- tags: Shape.plain.array(Shape.plain.string()).nullable(), // string[] | null
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.struct({ // { lat: number, lng: number } | null
514
- lat: Shape.plain.number(),
515
- lng: Shape.plain.number(),
516
- }).nullable(),
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.union([Shape.plain.null(), Shape.plain.string()]).placeholder(null)
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; // true
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
- ## TypedPresence
884
+ ## TypedEphemeral (Presence)
788
885
 
789
- The `TypedPresence` class provides type-safe access to ephemeral presence data with placeholder defaults:
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 { TypedPresence, Shape } from "@loro-extended/change";
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
- // Create typed presence from a PresenceInterface
805
- // (Usually obtained from handle.presence in @loro-extended/repo)
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(typedPresence.self);
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
- typedPresence.set({ cursor: { x: 100, y: 200 }, name: "Alice" });
909
+ handle.presence.setSelf({ cursor: { x: 100, y: 200 }, name: "Alice" });
814
910
 
815
- // Read all peers' presence
816
- console.log(typedPresence.all);
817
- // { "peer-1": { cursor: { x: 100, y: 200 }, name: "Alice", status: "online" } }
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
- typedPresence.subscribe(({ self, all }) => {
821
- console.log("My presence:", self);
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
- ### PresenceInterface
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