@loro-extended/change 0.8.1 → 0.9.1

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.
Files changed (41) hide show
  1. package/README.md +78 -0
  2. package/dist/index.d.ts +199 -43
  3. package/dist/index.js +642 -429
  4. package/dist/index.js.map +1 -1
  5. package/package.json +1 -1
  6. package/src/change.test.ts +277 -1
  7. package/src/conversion.test.ts +72 -72
  8. package/src/conversion.ts +5 -5
  9. package/src/discriminated-union-assignability.test.ts +45 -0
  10. package/src/discriminated-union-tojson.test.ts +128 -0
  11. package/src/index.ts +7 -0
  12. package/src/overlay-recursion.test.ts +325 -0
  13. package/src/overlay.ts +45 -8
  14. package/src/placeholder-proxy.test.ts +52 -0
  15. package/src/placeholder-proxy.ts +37 -0
  16. package/src/presence-interface.ts +52 -0
  17. package/src/shape.ts +44 -50
  18. package/src/typed-doc.ts +4 -4
  19. package/src/typed-presence.ts +96 -0
  20. package/src/{draft-nodes → typed-refs}/base.ts +14 -4
  21. package/src/{draft-nodes → typed-refs}/counter.test.ts +1 -1
  22. package/src/{draft-nodes → typed-refs}/counter.ts +9 -3
  23. package/src/{draft-nodes → typed-refs}/doc.ts +32 -25
  24. package/src/typed-refs/json-compatibility.test.ts +255 -0
  25. package/src/{draft-nodes → typed-refs}/list-base.ts +115 -42
  26. package/src/{draft-nodes → typed-refs}/list.test.ts +1 -1
  27. package/src/{draft-nodes → typed-refs}/list.ts +4 -4
  28. package/src/{draft-nodes → typed-refs}/map.ts +50 -66
  29. package/src/{draft-nodes → typed-refs}/movable-list.test.ts +1 -1
  30. package/src/{draft-nodes → typed-refs}/movable-list.ts +6 -6
  31. package/src/{draft-nodes → typed-refs}/proxy-handlers.ts +25 -26
  32. package/src/{draft-nodes → typed-refs}/record.test.ts +78 -9
  33. package/src/typed-refs/record.ts +193 -0
  34. package/src/{draft-nodes → typed-refs}/text.ts +13 -3
  35. package/src/{draft-nodes → typed-refs}/tree.ts +6 -3
  36. package/src/typed-refs/utils.ts +177 -0
  37. package/src/types.test.ts +97 -2
  38. package/src/types.ts +62 -5
  39. package/src/draft-nodes/counter.md +0 -31
  40. package/src/draft-nodes/record.ts +0 -177
  41. package/src/draft-nodes/utils.ts +0 -96
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loro-extended/change",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
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",
@@ -1,6 +1,7 @@
1
+ import { LoroDoc, LoroMap } from "loro-crdt"
1
2
  import { describe, expect, it } from "vitest"
2
3
  import { Shape } from "./shape.js"
3
- import { createTypedDoc } from "./typed-doc.js"
4
+ import { createTypedDoc, TypedDoc } from "./typed-doc.js"
4
5
 
5
6
  describe("CRDT Operations", () => {
6
7
  describe("Text Operations", () => {
@@ -1727,6 +1728,281 @@ describe("Edge Cases and Error Handling", () => {
1727
1728
  expect(result.items.find(item => item.id === "999")).toBeUndefined()
1728
1729
  })
1729
1730
  })
1731
+
1732
+ describe("slice method", () => {
1733
+ it("should return a slice of the list with start and end", () => {
1734
+ const schema = Shape.doc({
1735
+ items: Shape.list(Shape.plain.string()),
1736
+ })
1737
+
1738
+ const typedDoc = createTypedDoc(schema)
1739
+
1740
+ typedDoc.change(draft => {
1741
+ draft.items.push("a")
1742
+ draft.items.push("b")
1743
+ draft.items.push("c")
1744
+ draft.items.push("d")
1745
+ draft.items.push("e")
1746
+
1747
+ // slice(1, 3) returns items at indices 1 and 2
1748
+ const sliced = draft.items.slice(1, 3)
1749
+ expect(sliced).toEqual(["b", "c"])
1750
+ })
1751
+ })
1752
+
1753
+ it("should handle negative indices", () => {
1754
+ const schema = Shape.doc({
1755
+ items: Shape.list(Shape.plain.string()),
1756
+ })
1757
+
1758
+ const typedDoc = createTypedDoc(schema)
1759
+
1760
+ typedDoc.change(draft => {
1761
+ draft.items.push("a")
1762
+ draft.items.push("b")
1763
+ draft.items.push("c")
1764
+ draft.items.push("d")
1765
+ draft.items.push("e")
1766
+
1767
+ // slice(-2) returns last 2 items
1768
+ const lastTwo = draft.items.slice(-2)
1769
+ expect(lastTwo).toEqual(["d", "e"])
1770
+
1771
+ // slice(1, -1) returns items from index 1 to second-to-last
1772
+ const middle = draft.items.slice(1, -1)
1773
+ expect(middle).toEqual(["b", "c", "d"])
1774
+
1775
+ // slice(-3, -1) returns items from third-to-last to second-to-last
1776
+ const negativeRange = draft.items.slice(-3, -1)
1777
+ expect(negativeRange).toEqual(["c", "d"])
1778
+ })
1779
+ })
1780
+
1781
+ it("should handle missing end parameter", () => {
1782
+ const schema = Shape.doc({
1783
+ items: Shape.list(Shape.plain.string()),
1784
+ })
1785
+
1786
+ const typedDoc = createTypedDoc(schema)
1787
+
1788
+ typedDoc.change(draft => {
1789
+ draft.items.push("a")
1790
+ draft.items.push("b")
1791
+ draft.items.push("c")
1792
+ draft.items.push("d")
1793
+
1794
+ // slice(2) returns items from index 2 to end
1795
+ const fromTwo = draft.items.slice(2)
1796
+ expect(fromTwo).toEqual(["c", "d"])
1797
+ })
1798
+ })
1799
+
1800
+ it("should handle no parameters", () => {
1801
+ const schema = Shape.doc({
1802
+ items: Shape.list(Shape.plain.string()),
1803
+ })
1804
+
1805
+ const typedDoc = createTypedDoc(schema)
1806
+
1807
+ typedDoc.change(draft => {
1808
+ draft.items.push("a")
1809
+ draft.items.push("b")
1810
+ draft.items.push("c")
1811
+
1812
+ // slice() returns all items (shallow copy)
1813
+ const all = draft.items.slice()
1814
+ expect(all).toEqual(["a", "b", "c"])
1815
+ })
1816
+ })
1817
+
1818
+ it("should handle out-of-bounds indices", () => {
1819
+ const schema = Shape.doc({
1820
+ items: Shape.list(Shape.plain.string()),
1821
+ })
1822
+
1823
+ const typedDoc = createTypedDoc(schema)
1824
+
1825
+ typedDoc.change(draft => {
1826
+ draft.items.push("a")
1827
+ draft.items.push("b")
1828
+ draft.items.push("c")
1829
+
1830
+ // slice(0, 100) on 3-item list returns all 3 items
1831
+ const overEnd = draft.items.slice(0, 100)
1832
+ expect(overEnd).toEqual(["a", "b", "c"])
1833
+
1834
+ // slice(100) returns empty array
1835
+ const overStart = draft.items.slice(100)
1836
+ expect(overStart).toEqual([])
1837
+
1838
+ // slice(-100) returns all items (clamped to 0)
1839
+ const underStart = draft.items.slice(-100)
1840
+ expect(underStart).toEqual(["a", "b", "c"])
1841
+ })
1842
+ })
1843
+
1844
+ it("should return empty array for empty list", () => {
1845
+ const schema = Shape.doc({
1846
+ items: Shape.list(Shape.plain.string()),
1847
+ })
1848
+
1849
+ const typedDoc = createTypedDoc(schema)
1850
+
1851
+ typedDoc.change(draft => {
1852
+ // slice() on empty list returns []
1853
+ expect(draft.items.slice()).toEqual([])
1854
+ expect(draft.items.slice(0, 10)).toEqual([])
1855
+ expect(draft.items.slice(-5)).toEqual([])
1856
+ })
1857
+ })
1858
+
1859
+ it("should allow mutations to persist", () => {
1860
+ const schema = Shape.doc({
1861
+ items: Shape.list(
1862
+ Shape.plain.object({
1863
+ id: Shape.plain.string(),
1864
+ value: Shape.plain.number(),
1865
+ }),
1866
+ ),
1867
+ })
1868
+
1869
+ const typedDoc = createTypedDoc(schema)
1870
+
1871
+ typedDoc.change(draft => {
1872
+ draft.items.push({ id: "1", value: 10 })
1873
+ draft.items.push({ id: "2", value: 20 })
1874
+ draft.items.push({ id: "3", value: 30 })
1875
+ draft.items.push({ id: "4", value: 40 })
1876
+ })
1877
+
1878
+ // Modify items from slice and verify changes persist
1879
+ const result = typedDoc.change(draft => {
1880
+ const middleItems = draft.items.slice(1, 3)
1881
+ // Mutate the sliced items
1882
+ middleItems[0].value = 200
1883
+ middleItems[1].value = 300
1884
+ })
1885
+
1886
+ // Verify mutations persisted to the original list
1887
+ expect(result.items[0].value).toBe(10) // unchanged
1888
+ expect(result.items[1].value).toBe(200) // mutated via slice
1889
+ expect(result.items[2].value).toBe(300) // mutated via slice
1890
+ expect(result.items[3].value).toBe(40) // unchanged
1891
+ })
1892
+
1893
+ it("should work with MovableListRef", () => {
1894
+ const schema = Shape.doc({
1895
+ tasks: Shape.movableList(Shape.plain.string()),
1896
+ })
1897
+
1898
+ const typedDoc = createTypedDoc(schema)
1899
+
1900
+ typedDoc.change(draft => {
1901
+ draft.tasks.push("task1")
1902
+ draft.tasks.push("task2")
1903
+ draft.tasks.push("task3")
1904
+ draft.tasks.push("task4")
1905
+
1906
+ // Test slice on movable list
1907
+ const sliced = draft.tasks.slice(1, 3)
1908
+ expect(sliced).toEqual(["task2", "task3"])
1909
+
1910
+ const lastTwo = draft.tasks.slice(-2)
1911
+ expect(lastTwo).toEqual(["task3", "task4"])
1912
+ })
1913
+ })
1914
+
1915
+ it("should work with nested container items", () => {
1916
+ const schema = Shape.doc({
1917
+ articles: Shape.list(
1918
+ Shape.map({
1919
+ title: Shape.text(),
1920
+ views: Shape.counter(),
1921
+ }),
1922
+ ),
1923
+ })
1924
+
1925
+ const typedDoc = createTypedDoc(schema)
1926
+
1927
+ typedDoc.change(draft => {
1928
+ draft.articles.push({ title: "Article 1", views: 10 })
1929
+ draft.articles.push({ title: "Article 2", views: 20 })
1930
+ draft.articles.push({ title: "Article 3", views: 30 })
1931
+ })
1932
+
1933
+ const result = typedDoc.change(draft => {
1934
+ const sliced = draft.articles.slice(0, 2)
1935
+ // Mutate nested containers in sliced items
1936
+ sliced[0].title.update("Updated Article 1")
1937
+ sliced[0].views.increment(5)
1938
+ sliced[1].views.increment(10)
1939
+ })
1940
+
1941
+ // Verify mutations persisted
1942
+ expect(result.articles[0].title).toBe("Updated Article 1")
1943
+ expect(result.articles[0].views).toBe(15) // 10 + 5
1944
+ expect(result.articles[1].views).toBe(30) // 20 + 10
1945
+ expect(result.articles[2].views).toBe(30) // unchanged
1946
+ })
1947
+ })
1948
+ })
1949
+ })
1950
+
1951
+ describe("Record with nested Map containers", () => {
1952
+ /**
1953
+ * Regression test for "placeholder required" error when calling toJSON()
1954
+ * on a document with Records containing Maps where the CRDT has partial data.
1955
+ *
1956
+ * The bug: When a Record contains Map entries that exist in the CRDT but not
1957
+ * in the placeholder (which is always {} for Records), the nested MapRef was
1958
+ * created with placeholder: undefined. When MapRef.toJSON() tried to access
1959
+ * value properties that don't exist in the CRDT, it threw "placeholder required".
1960
+ *
1961
+ * The fix: RecordRef.getTypedRefParams() now derives a placeholder from the
1962
+ * schema's shape when the Record's placeholder doesn't have an entry for that key.
1963
+ */
1964
+ it("should call toJSON() without error when Record has entries with partial CRDT data", () => {
1965
+ // Schema with a Record containing Maps (similar to user's tomState schema)
1966
+ const StudentStateSchema = Shape.map({
1967
+ peerId: Shape.plain.string(),
1968
+ authorName: Shape.plain.string(),
1969
+ authorColor: Shape.plain.string(),
1970
+ history: Shape.list(
1971
+ Shape.map({
1972
+ timestamp: Shape.plain.number(),
1973
+ value: Shape.plain.string(),
1974
+ }),
1975
+ ),
1976
+ })
1977
+
1978
+ const DocSchema = Shape.doc({
1979
+ students: Shape.record(StudentStateSchema),
1980
+ })
1981
+
1982
+ // Simulate loading an existing document that has Record entries with partial data
1983
+ const loroDoc = new LoroDoc()
1984
+
1985
+ // Add an entry to the students record with only some fields populated
1986
+ const studentsMap = loroDoc.getMap("students")
1987
+ const studentMap = studentsMap.setContainer("peer-123", new LoroMap())
1988
+
1989
+ // Set some but not all properties - this simulates partial data from CRDT sync
1990
+ studentMap.set("peerId", "peer-123")
1991
+ studentMap.set("authorName", "Alice")
1992
+ // Note: authorColor is NOT set - this should fall back to placeholder default
1993
+
1994
+ // Wrap with TypedDoc
1995
+ const typedDoc = new TypedDoc(DocSchema, loroDoc)
1996
+
1997
+ // This should not throw "placeholder required"
1998
+ expect(() => {
1999
+ const json = typedDoc.value.toJSON()
2000
+ // Verify the result has placeholder defaults for missing fields
2001
+ expect(json.students["peer-123"].peerId).toBe("peer-123")
2002
+ expect(json.students["peer-123"].authorName).toBe("Alice")
2003
+ expect(json.students["peer-123"].authorColor).toBe("") // placeholder default
2004
+ expect(json.students["peer-123"].history).toEqual([]) // placeholder default
2005
+ }).not.toThrow()
1730
2006
  })
1731
2007
  })
1732
2008
  })