@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 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,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; // true
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
- ## TypedPresence
884
+ ## TypedEphemeral (Presence)
748
885
 
749
- 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()`:
750
887
 
751
888
  ```typescript
752
- import { TypedPresence, Shape } from "@loro-extended/change";
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
- // Create typed presence from a PresenceInterface
765
- // (Usually obtained from handle.presence in @loro-extended/repo)
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(typedPresence.self);
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
- typedPresence.set({ cursor: { x: 100, y: 200 }, name: "Alice" });
909
+ handle.presence.setSelf({ cursor: { x: 100, y: 200 }, name: "Alice" });
774
910
 
775
- // Read all peers' presence
776
- console.log(typedPresence.all);
777
- // { "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
+ }
778
915
 
779
916
  // Subscribe to presence changes
780
- typedPresence.subscribe(({ self, all }) => {
781
- console.log("My presence:", self);
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
- ### PresenceInterface
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