@loro-extended/change 5.1.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/README.md +94 -0
- package/dist/index.d.ts +283 -137
- package/dist/index.js +2027 -1930
- package/dist/index.js.map +1 -1
- package/package.json +3 -4
- package/src/fork-at.test.ts +260 -0
- package/src/functional-helpers.test.ts +527 -0
- package/src/functional-helpers.ts +172 -12
- package/src/index.ts +7 -2
- package/src/loro.ts +26 -2
- package/src/shape.ts +30 -5
- package/src/typed-doc.ts +58 -6
- package/src/typed-refs/base.ts +18 -0
- package/src/typed-refs/doc-ref-internals.ts +2 -2
- package/src/typed-refs/list-ref-base-internals.ts +2 -2
- package/src/typed-refs/list-ref-base.ts +2 -2
- package/src/typed-refs/record-ref-internals.ts +2 -2
- package/src/typed-refs/struct-ref-internals.ts +2 -2
- package/src/typed-refs/struct-ref.ts +7 -1
- package/src/typed-refs/tree-deleted-nodes.test.ts +213 -0
- package/src/typed-refs/tree-loro.test.ts +52 -0
- package/src/typed-refs/tree-node-ref-internals.ts +34 -1
- package/src/typed-refs/tree-node-ref.test.ts +24 -17
- package/src/typed-refs/tree-node-ref.ts +8 -0
- package/src/typed-refs/tree-node.test.ts +54 -22
- package/src/typed-refs/tree-ref.ts +10 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loro-extended/change",
|
|
3
|
-
"version": "5.
|
|
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",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"./src/*": "./src/*"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
+
"@typescript/native-preview": "7.0.0-dev.20260103.1",
|
|
24
25
|
"tsup": "^8.5.0",
|
|
25
26
|
"tsx": "^4.20.3",
|
|
26
27
|
"typescript": "^5.9.2",
|
|
@@ -31,8 +32,6 @@
|
|
|
31
32
|
},
|
|
32
33
|
"scripts": {
|
|
33
34
|
"build": "tsup",
|
|
34
|
-
"
|
|
35
|
-
"test": "vitest",
|
|
36
|
-
"typecheck": "tsc --noEmit --skipLibCheck"
|
|
35
|
+
"verify": "verify"
|
|
37
36
|
}
|
|
38
37
|
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { createTypedDoc, forkAt, loro, Shape } from "./index.js"
|
|
3
|
+
|
|
4
|
+
describe("forkAt", () => {
|
|
5
|
+
describe("TypedDoc.forkAt() method", () => {
|
|
6
|
+
it("should fork at a specific version and return correct state", () => {
|
|
7
|
+
const schema = Shape.doc({
|
|
8
|
+
title: Shape.text(),
|
|
9
|
+
count: Shape.counter(),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const doc = createTypedDoc(schema)
|
|
13
|
+
doc.title.update("Hello")
|
|
14
|
+
doc.count.increment(5)
|
|
15
|
+
|
|
16
|
+
// Get frontiers at this point
|
|
17
|
+
const frontiers = loro(doc).doc.frontiers()
|
|
18
|
+
|
|
19
|
+
// Make more changes
|
|
20
|
+
doc.title.update("World")
|
|
21
|
+
doc.count.increment(10)
|
|
22
|
+
|
|
23
|
+
// Fork at the earlier version
|
|
24
|
+
const forkedDoc = doc.forkAt(frontiers)
|
|
25
|
+
|
|
26
|
+
// Forked doc should have the earlier state
|
|
27
|
+
expect(forkedDoc.title.toString()).toBe("Hello")
|
|
28
|
+
expect(forkedDoc.count.value).toBe(5)
|
|
29
|
+
|
|
30
|
+
// Original doc should still have the latest state
|
|
31
|
+
expect(doc.title.toString()).toBe("World")
|
|
32
|
+
expect(doc.count.value).toBe(15)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("should preserve type safety on forked doc", () => {
|
|
36
|
+
const schema = Shape.doc({
|
|
37
|
+
items: Shape.list(
|
|
38
|
+
Shape.struct({
|
|
39
|
+
name: Shape.text(),
|
|
40
|
+
done: Shape.plain.boolean(),
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const doc = createTypedDoc(schema)
|
|
46
|
+
doc.items.push({ name: "Task 1", done: false })
|
|
47
|
+
|
|
48
|
+
const frontiers = loro(doc).doc.frontiers()
|
|
49
|
+
|
|
50
|
+
doc.items.push({ name: "Task 2", done: true })
|
|
51
|
+
|
|
52
|
+
const forkedDoc = doc.forkAt(frontiers)
|
|
53
|
+
|
|
54
|
+
// Type safety: forkedDoc.items should have the same type
|
|
55
|
+
expect(forkedDoc.items.length).toBe(1)
|
|
56
|
+
const firstItem = forkedDoc.items[0]
|
|
57
|
+
if (firstItem) {
|
|
58
|
+
expect(firstItem.name.toString()).toBe("Task 1")
|
|
59
|
+
expect(firstItem.done).toBe(false)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Can mutate forked doc independently
|
|
63
|
+
forkedDoc.items.push({ name: "Forked Task", done: true })
|
|
64
|
+
expect(forkedDoc.items.length).toBe(2)
|
|
65
|
+
expect(doc.items.length).toBe(2) // Original unchanged
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("should create independent documents (changes don't affect original)", () => {
|
|
69
|
+
const schema = Shape.doc({
|
|
70
|
+
value: Shape.counter(),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const doc = createTypedDoc(schema)
|
|
74
|
+
doc.value.increment(10)
|
|
75
|
+
|
|
76
|
+
const frontiers = loro(doc).doc.frontiers()
|
|
77
|
+
const forkedDoc = doc.forkAt(frontiers)
|
|
78
|
+
|
|
79
|
+
// Mutate forked doc
|
|
80
|
+
forkedDoc.value.increment(100)
|
|
81
|
+
|
|
82
|
+
// Original should be unchanged
|
|
83
|
+
expect(doc.value.value).toBe(10)
|
|
84
|
+
expect(forkedDoc.value.value).toBe(110)
|
|
85
|
+
|
|
86
|
+
// Mutate original
|
|
87
|
+
doc.value.increment(5)
|
|
88
|
+
|
|
89
|
+
// Forked should be unchanged
|
|
90
|
+
expect(doc.value.value).toBe(15)
|
|
91
|
+
expect(forkedDoc.value.value).toBe(110)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("should work with complex schemas (maps, trees)", () => {
|
|
95
|
+
const schema = Shape.doc({
|
|
96
|
+
settings: Shape.record(Shape.plain.string()),
|
|
97
|
+
tree: Shape.tree(
|
|
98
|
+
Shape.struct({
|
|
99
|
+
label: Shape.text(),
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const doc = createTypedDoc(schema)
|
|
105
|
+
doc.settings.set("theme", "dark")
|
|
106
|
+
const node = doc.tree.createNode({ label: "Root" })
|
|
107
|
+
|
|
108
|
+
const frontiers = loro(doc).doc.frontiers()
|
|
109
|
+
|
|
110
|
+
doc.settings.set("theme", "light")
|
|
111
|
+
doc.settings.set("lang", "en")
|
|
112
|
+
node.data.label.update("Updated Root")
|
|
113
|
+
|
|
114
|
+
const forkedDoc = doc.forkAt(frontiers)
|
|
115
|
+
|
|
116
|
+
expect(forkedDoc.settings.get("theme")).toBe("dark")
|
|
117
|
+
// "lang" was not set before the fork, so it should not exist
|
|
118
|
+
// Note: Record returns placeholder value (empty string) for missing keys
|
|
119
|
+
expect(forkedDoc.settings.has("lang")).toBe(false)
|
|
120
|
+
|
|
121
|
+
// Tree should have the earlier state
|
|
122
|
+
const forkedRoots = forkedDoc.tree.roots()
|
|
123
|
+
expect(forkedRoots.length).toBe(1)
|
|
124
|
+
expect(forkedRoots[0].data.label.toString()).toBe("Root")
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("should have different PeerID from original", () => {
|
|
128
|
+
const schema = Shape.doc({
|
|
129
|
+
text: Shape.text(),
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const doc = createTypedDoc(schema)
|
|
133
|
+
doc.text.update("Hello")
|
|
134
|
+
|
|
135
|
+
const frontiers = loro(doc).doc.frontiers()
|
|
136
|
+
const forkedDoc = doc.forkAt(frontiers)
|
|
137
|
+
|
|
138
|
+
const originalPeerId = loro(doc).doc.peerId
|
|
139
|
+
const forkedPeerId = loro(forkedDoc).doc.peerId
|
|
140
|
+
|
|
141
|
+
expect(forkedPeerId).not.toBe(originalPeerId)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe("forkAt() functional helper", () => {
|
|
146
|
+
it("should fork at a specific version", () => {
|
|
147
|
+
const schema = Shape.doc({
|
|
148
|
+
title: Shape.text(),
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const doc = createTypedDoc(schema)
|
|
152
|
+
doc.title.update("Hello")
|
|
153
|
+
|
|
154
|
+
const frontiers = loro(doc).doc.frontiers()
|
|
155
|
+
|
|
156
|
+
doc.title.update("World")
|
|
157
|
+
|
|
158
|
+
// Use functional helper
|
|
159
|
+
const forkedDoc = forkAt(doc, frontiers)
|
|
160
|
+
|
|
161
|
+
expect(forkedDoc.title.toString()).toBe("Hello")
|
|
162
|
+
expect(doc.title.toString()).toBe("World")
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("should preserve schema from original doc", () => {
|
|
166
|
+
const schema = Shape.doc({
|
|
167
|
+
count: Shape.counter().placeholder(42),
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const doc = createTypedDoc(schema)
|
|
171
|
+
// Don't increment - should use placeholder
|
|
172
|
+
|
|
173
|
+
const frontiers = loro(doc).doc.frontiers()
|
|
174
|
+
const forkedDoc = forkAt(doc, frontiers)
|
|
175
|
+
|
|
176
|
+
// Placeholder should be preserved
|
|
177
|
+
expect(forkedDoc.toJSON().count).toBe(42)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe("raw LoroDoc.forkAt() access", () => {
|
|
182
|
+
it("should still be accessible via loro() escape hatch", () => {
|
|
183
|
+
const schema = Shape.doc({
|
|
184
|
+
text: Shape.text(),
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const doc = createTypedDoc(schema)
|
|
188
|
+
doc.text.update("Hello")
|
|
189
|
+
|
|
190
|
+
const frontiers = loro(doc).doc.frontiers()
|
|
191
|
+
doc.text.update("World")
|
|
192
|
+
|
|
193
|
+
// Raw access returns LoroDoc, not TypedDoc
|
|
194
|
+
const rawForkedDoc = loro(doc).doc.forkAt(frontiers)
|
|
195
|
+
|
|
196
|
+
// It's a plain LoroDoc
|
|
197
|
+
expect(rawForkedDoc.toJSON()).toEqual({ text: "Hello" })
|
|
198
|
+
|
|
199
|
+
// Can wrap it manually if needed
|
|
200
|
+
const typedForkedDoc = createTypedDoc(schema, rawForkedDoc)
|
|
201
|
+
expect(typedForkedDoc.text.toString()).toBe("Hello")
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe("edge cases", () => {
|
|
206
|
+
it("should fork at empty frontiers (initial state)", () => {
|
|
207
|
+
const schema = Shape.doc({
|
|
208
|
+
count: Shape.counter().placeholder(0),
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
const doc = createTypedDoc(schema)
|
|
212
|
+
const emptyFrontiers = loro(doc).doc.frontiers()
|
|
213
|
+
|
|
214
|
+
doc.count.increment(10)
|
|
215
|
+
|
|
216
|
+
const forkedDoc = doc.forkAt(emptyFrontiers)
|
|
217
|
+
|
|
218
|
+
// Should be at initial state (placeholder value)
|
|
219
|
+
expect(forkedDoc.count.value).toBe(0)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it("should fork at current frontiers (same state)", () => {
|
|
223
|
+
const schema = Shape.doc({
|
|
224
|
+
text: Shape.text(),
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
const doc = createTypedDoc(schema)
|
|
228
|
+
doc.text.update("Hello")
|
|
229
|
+
|
|
230
|
+
const currentFrontiers = loro(doc).doc.frontiers()
|
|
231
|
+
const forkedDoc = doc.forkAt(currentFrontiers)
|
|
232
|
+
|
|
233
|
+
expect(forkedDoc.text.toString()).toBe("Hello")
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it("should work with change() on forked doc", () => {
|
|
237
|
+
const schema = Shape.doc({
|
|
238
|
+
items: Shape.list(Shape.plain.number()),
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
const doc = createTypedDoc(schema)
|
|
242
|
+
doc.items.push(1)
|
|
243
|
+
doc.items.push(2)
|
|
244
|
+
|
|
245
|
+
const frontiers = loro(doc).doc.frontiers()
|
|
246
|
+
doc.items.push(3)
|
|
247
|
+
|
|
248
|
+
const forkedDoc = doc.forkAt(frontiers)
|
|
249
|
+
|
|
250
|
+
// Use change() on forked doc
|
|
251
|
+
forkedDoc.change(draft => {
|
|
252
|
+
draft.items.push(100)
|
|
253
|
+
draft.items.push(200)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
expect(forkedDoc.items.toJSON()).toEqual([1, 2, 100, 200])
|
|
257
|
+
expect(doc.items.toJSON()).toEqual([1, 2, 3])
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
})
|