@loro-extended/change 5.2.0 → 5.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loro-extended/change",
3
- "version": "5.2.0",
3
+ "version": "5.3.0",
4
4
  "description": "A schema-driven, type-safe wrapper for Loro CRDT that provides natural JavaScript syntax for collaborative data mutations",
5
5
  "author": "Duane Johnson",
6
6
  "license": "MIT",
@@ -361,6 +361,131 @@ describe("functional helpers", () => {
361
361
  })
362
362
  })
363
363
 
364
+ describe("loro(ref).subscribe() for imported (remote) changes", () => {
365
+ it("should fire TextRef subscription when changes are imported", () => {
366
+ // Create two documents - simulating two clients
367
+ const doc1 = createTypedDoc(fullSchema)
368
+ const doc2 = createTypedDoc(fullSchema)
369
+
370
+ // Set up subscription on doc2's title ref
371
+ const callback = vi.fn()
372
+ const unsubscribe = loro(doc2.title).subscribe(callback)
373
+
374
+ // Make changes on doc1
375
+ doc1.title.insert(0, "Hello from doc1")
376
+ loro(doc1).doc.commit()
377
+
378
+ // Export from doc1 and import into doc2 (simulating sync)
379
+ const snapshot = loro(doc1).doc.export({ mode: "snapshot" })
380
+ loro(doc2).doc.import(snapshot)
381
+
382
+ // The subscription should have fired
383
+ expect(callback).toHaveBeenCalled()
384
+
385
+ // And the value should be updated
386
+ expect(doc2.title.toString()).toBe("Hello from doc1")
387
+
388
+ unsubscribe()
389
+ })
390
+
391
+ it("should fire CounterRef subscription when changes are imported", () => {
392
+ const doc1 = createTypedDoc(fullSchema)
393
+ const doc2 = createTypedDoc(fullSchema)
394
+
395
+ const callback = vi.fn()
396
+ const unsubscribe = loro(doc2.count).subscribe(callback)
397
+
398
+ doc1.count.increment(42)
399
+ loro(doc1).doc.commit()
400
+
401
+ const snapshot = loro(doc1).doc.export({ mode: "snapshot" })
402
+ loro(doc2).doc.import(snapshot)
403
+
404
+ expect(callback).toHaveBeenCalled()
405
+ expect(doc2.count.value).toBe(42)
406
+
407
+ unsubscribe()
408
+ })
409
+
410
+ it("should fire ListRef subscription when changes are imported", () => {
411
+ const doc1 = createTypedDoc(fullSchema)
412
+ const doc2 = createTypedDoc(fullSchema)
413
+
414
+ const callback = vi.fn()
415
+ const unsubscribe = loro(doc2.items).subscribe(callback)
416
+
417
+ doc1.items.push("item1")
418
+ doc1.items.push("item2")
419
+ loro(doc1).doc.commit()
420
+
421
+ const snapshot = loro(doc1).doc.export({ mode: "snapshot" })
422
+ loro(doc2).doc.import(snapshot)
423
+
424
+ expect(callback).toHaveBeenCalled()
425
+ expect(doc2.items.toJSON()).toEqual(["item1", "item2"])
426
+
427
+ unsubscribe()
428
+ })
429
+
430
+ it("should fire doc-level subscription when changes are imported", () => {
431
+ const doc1 = createTypedDoc(fullSchema)
432
+ const doc2 = createTypedDoc(fullSchema)
433
+
434
+ const callback = vi.fn()
435
+ const unsubscribe = loro(doc2).doc.subscribe(callback)
436
+
437
+ doc1.title.insert(0, "Hello")
438
+ loro(doc1).doc.commit()
439
+
440
+ const snapshot = loro(doc1).doc.export({ mode: "snapshot" })
441
+ loro(doc2).doc.import(snapshot)
442
+
443
+ expect(callback).toHaveBeenCalled()
444
+
445
+ unsubscribe()
446
+ })
447
+
448
+ it("should NOT fire subscription for containers that were not changed", () => {
449
+ const doc1 = createTypedDoc(fullSchema)
450
+ const doc2 = createTypedDoc(fullSchema)
451
+
452
+ // Subscribe to count, but only change title
453
+ const countCallback = vi.fn()
454
+ const unsubscribe = loro(doc2.count).subscribe(countCallback)
455
+
456
+ doc1.title.insert(0, "Hello")
457
+ loro(doc1).doc.commit()
458
+
459
+ const snapshot = loro(doc1).doc.export({ mode: "snapshot" })
460
+ loro(doc2).doc.import(snapshot)
461
+
462
+ // Count subscription should NOT have fired since count wasn't changed
463
+ expect(countCallback).not.toHaveBeenCalled()
464
+
465
+ unsubscribe()
466
+ })
467
+
468
+ it("should provide updated value in subscription callback", () => {
469
+ const doc1 = createTypedDoc(fullSchema)
470
+ const doc2 = createTypedDoc(fullSchema)
471
+
472
+ let capturedValue: string | undefined
473
+ const unsubscribe = loro(doc2.title).subscribe(() => {
474
+ capturedValue = doc2.title.toString()
475
+ })
476
+
477
+ doc1.title.insert(0, "Remote text")
478
+ loro(doc1).doc.commit()
479
+
480
+ const snapshot = loro(doc1).doc.export({ mode: "snapshot" })
481
+ loro(doc2).doc.import(snapshot)
482
+
483
+ expect(capturedValue).toBe("Remote text")
484
+
485
+ unsubscribe()
486
+ })
487
+ })
488
+
364
489
  describe("getLoroDoc() on refs", () => {
365
490
  it("should return LoroDoc from TextRef", () => {
366
491
  const doc = createTypedDoc(fullSchema)
package/src/shape.ts CHANGED
@@ -222,6 +222,27 @@ export interface AnyContainerShape extends Shape<unknown, unknown, undefined> {
222
222
  readonly _type: "any"
223
223
  }
224
224
 
225
+ /**
226
+ * Union of all container shape types.
227
+ *
228
+ * Each container shape has a `_mutable` type parameter that maps to the
229
+ * corresponding TypedRef class (e.g., TextContainerShape → TextRef).
230
+ * This enables deriving ref types from shapes:
231
+ *
232
+ * ```typescript
233
+ * // Get the ref type for any container shape
234
+ * type RefType = ContainerShape["_mutable"]
235
+ *
236
+ * // Exclude AnyContainerShape to get only typed refs
237
+ * type AnyTypedRef = Exclude<ContainerShape, AnyContainerShape>["_mutable"]
238
+ * ```
239
+ *
240
+ * This creates intentional parallel hierarchies:
241
+ * - ContainerShape → defines what data looks like (schema)
242
+ * - TypedRef (via _mutable) → defines how you interact with data
243
+ * - loro() overloads → CRDT escape hatch (IDE DX)
244
+ * - change() overloads → mutation boundaries (IDE DX)
245
+ */
225
246
  export type ContainerShape =
226
247
  | AnyContainerShape
227
248
  | CounterContainerShape