@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.
- package/README.md +78 -0
- package/dist/index.d.ts +199 -43
- package/dist/index.js +642 -429
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/change.test.ts +277 -1
- package/src/conversion.test.ts +72 -72
- package/src/conversion.ts +5 -5
- package/src/discriminated-union-assignability.test.ts +45 -0
- package/src/discriminated-union-tojson.test.ts +128 -0
- package/src/index.ts +7 -0
- package/src/overlay-recursion.test.ts +325 -0
- package/src/overlay.ts +45 -8
- package/src/placeholder-proxy.test.ts +52 -0
- package/src/placeholder-proxy.ts +37 -0
- package/src/presence-interface.ts +52 -0
- package/src/shape.ts +44 -50
- package/src/typed-doc.ts +4 -4
- package/src/typed-presence.ts +96 -0
- package/src/{draft-nodes → typed-refs}/base.ts +14 -4
- package/src/{draft-nodes → typed-refs}/counter.test.ts +1 -1
- package/src/{draft-nodes → typed-refs}/counter.ts +9 -3
- package/src/{draft-nodes → typed-refs}/doc.ts +32 -25
- package/src/typed-refs/json-compatibility.test.ts +255 -0
- package/src/{draft-nodes → typed-refs}/list-base.ts +115 -42
- package/src/{draft-nodes → typed-refs}/list.test.ts +1 -1
- package/src/{draft-nodes → typed-refs}/list.ts +4 -4
- package/src/{draft-nodes → typed-refs}/map.ts +50 -66
- package/src/{draft-nodes → typed-refs}/movable-list.test.ts +1 -1
- package/src/{draft-nodes → typed-refs}/movable-list.ts +6 -6
- package/src/{draft-nodes → typed-refs}/proxy-handlers.ts +25 -26
- package/src/{draft-nodes → typed-refs}/record.test.ts +78 -9
- package/src/typed-refs/record.ts +193 -0
- package/src/{draft-nodes → typed-refs}/text.ts +13 -3
- package/src/{draft-nodes → typed-refs}/tree.ts +6 -3
- package/src/typed-refs/utils.ts +177 -0
- package/src/types.test.ts +97 -2
- package/src/types.ts +62 -5
- package/src/draft-nodes/counter.md +0 -31
- package/src/draft-nodes/record.ts +0 -177
- 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.
|
|
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",
|
package/src/change.test.ts
CHANGED
|
@@ -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
|
})
|