@loro-extended/change 0.2.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/LICENSE +21 -0
- package/README.md +565 -0
- package/dist/index.d.ts +339 -0
- package/dist/index.js +1491 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
- package/src/change.test.ts +2006 -0
- package/src/change.ts +105 -0
- package/src/conversion.test.ts +728 -0
- package/src/conversion.ts +220 -0
- package/src/draft-nodes/base.ts +34 -0
- package/src/draft-nodes/counter.ts +21 -0
- package/src/draft-nodes/doc.ts +81 -0
- package/src/draft-nodes/list-base.ts +326 -0
- package/src/draft-nodes/list.ts +18 -0
- package/src/draft-nodes/map.ts +156 -0
- package/src/draft-nodes/movable-list.ts +26 -0
- package/src/draft-nodes/record.ts +215 -0
- package/src/draft-nodes/text.ts +48 -0
- package/src/draft-nodes/tree.ts +31 -0
- package/src/draft-nodes/utils.ts +55 -0
- package/src/index.ts +33 -0
- package/src/json-patch.test.ts +697 -0
- package/src/json-patch.ts +391 -0
- package/src/overlay.ts +90 -0
- package/src/record.test.ts +188 -0
- package/src/schema.fixtures.ts +138 -0
- package/src/shape.ts +348 -0
- package/src/types.ts +15 -0
- package/src/utils/type-guards.ts +210 -0
- package/src/validation.ts +261 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/** biome-ignore-all lint/suspicious/noExplicitAny: JSON Patch values can be any type */
|
|
2
|
+
|
|
3
|
+
import type { DocShape } from "./shape.js"
|
|
4
|
+
import type { Draft } from "./types.js"
|
|
5
|
+
|
|
6
|
+
// =============================================================================
|
|
7
|
+
// JSON PATCH TYPES - Discriminated Union for Type Safety
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
export type JsonPatchAddOperation = {
|
|
11
|
+
op: "add"
|
|
12
|
+
path: string | (string | number)[]
|
|
13
|
+
value: any
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type JsonPatchRemoveOperation = {
|
|
17
|
+
op: "remove"
|
|
18
|
+
path: string | (string | number)[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type JsonPatchReplaceOperation = {
|
|
22
|
+
op: "replace"
|
|
23
|
+
path: string | (string | number)[]
|
|
24
|
+
value: any
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type JsonPatchMoveOperation = {
|
|
28
|
+
op: "move"
|
|
29
|
+
path: string | (string | number)[]
|
|
30
|
+
from: string | (string | number)[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type JsonPatchCopyOperation = {
|
|
34
|
+
op: "copy"
|
|
35
|
+
path: string | (string | number)[]
|
|
36
|
+
from: string | (string | number)[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type JsonPatchTestOperation = {
|
|
40
|
+
op: "test"
|
|
41
|
+
path: string | (string | number)[]
|
|
42
|
+
value: any
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type JsonPatchOperation =
|
|
46
|
+
| JsonPatchAddOperation
|
|
47
|
+
| JsonPatchRemoveOperation
|
|
48
|
+
| JsonPatchReplaceOperation
|
|
49
|
+
| JsonPatchMoveOperation
|
|
50
|
+
| JsonPatchCopyOperation
|
|
51
|
+
| JsonPatchTestOperation
|
|
52
|
+
|
|
53
|
+
export type JsonPatch = JsonPatchOperation[]
|
|
54
|
+
|
|
55
|
+
// =============================================================================
|
|
56
|
+
// PATH NAVIGATION UTILITIES
|
|
57
|
+
// =============================================================================
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Normalize JSON Pointer string to path array
|
|
61
|
+
* Handles RFC 6901 escaping: ~1 -> /, ~0 -> ~
|
|
62
|
+
*/
|
|
63
|
+
export function normalizePath(
|
|
64
|
+
path: string | (string | number)[],
|
|
65
|
+
): (string | number)[] {
|
|
66
|
+
if (Array.isArray(path)) {
|
|
67
|
+
return path
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Handle JSON Pointer format (RFC 6901)
|
|
71
|
+
if (path.startsWith("/")) {
|
|
72
|
+
return path
|
|
73
|
+
.slice(1) // Remove leading slash
|
|
74
|
+
.split("/")
|
|
75
|
+
.map(segment => {
|
|
76
|
+
// Handle JSON Pointer escaping
|
|
77
|
+
const unescaped = segment.replace(/~1/g, "/").replace(/~0/g, "~")
|
|
78
|
+
// Try to parse as number for array indices
|
|
79
|
+
const asNumber = Number(unescaped)
|
|
80
|
+
return Number.isInteger(asNumber) && asNumber >= 0
|
|
81
|
+
? asNumber
|
|
82
|
+
: unescaped
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Handle simple dot notation or single segment
|
|
87
|
+
return path.split(".").map(segment => {
|
|
88
|
+
const asNumber = Number(segment)
|
|
89
|
+
return Number.isInteger(asNumber) && asNumber >= 0 ? asNumber : segment
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Navigate to a target path using natural DraftNode property access
|
|
95
|
+
* This follows the existing patterns from the test suite
|
|
96
|
+
*/
|
|
97
|
+
function navigateToPath<T extends DocShape>(
|
|
98
|
+
draft: Draft<T>,
|
|
99
|
+
path: (string | number)[],
|
|
100
|
+
): { parent: any; key: string | number } {
|
|
101
|
+
if (path.length === 0) {
|
|
102
|
+
throw new Error("Cannot navigate to empty path")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let current = draft as any
|
|
106
|
+
|
|
107
|
+
// Navigate to parent of target
|
|
108
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
109
|
+
const segment = path[i]
|
|
110
|
+
|
|
111
|
+
if (typeof segment === "string") {
|
|
112
|
+
// Use natural property access - this leverages existing DraftNode lazy creation
|
|
113
|
+
current = current[segment]
|
|
114
|
+
if (current === undefined) {
|
|
115
|
+
throw new Error(`Cannot navigate to path segment: ${segment}`)
|
|
116
|
+
}
|
|
117
|
+
} else if (typeof segment === "number") {
|
|
118
|
+
// List/array access using get() method (following existing patterns)
|
|
119
|
+
if (current.get && typeof current.get === "function") {
|
|
120
|
+
current = current.get(segment)
|
|
121
|
+
if (current === undefined) {
|
|
122
|
+
throw new Error(`List index ${segment} does not exist`)
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
throw new Error(`Cannot use numeric index ${segment} on non-list`)
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
throw new Error(`Invalid path segment type: ${typeof segment}`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const targetKey = path[path.length - 1]
|
|
133
|
+
return { parent: current, key: targetKey }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get value at path using natural DraftNode access patterns
|
|
138
|
+
*/
|
|
139
|
+
function getValueAtPath<T extends DocShape>(
|
|
140
|
+
draft: Draft<T>,
|
|
141
|
+
path: (string | number)[],
|
|
142
|
+
): any {
|
|
143
|
+
if (path.length === 0) {
|
|
144
|
+
return draft
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const { parent, key } = navigateToPath(draft, path)
|
|
148
|
+
|
|
149
|
+
if (typeof key === "string") {
|
|
150
|
+
// Use natural property access or get() method
|
|
151
|
+
if (parent.get && typeof parent.get === "function") {
|
|
152
|
+
return parent.get(key)
|
|
153
|
+
}
|
|
154
|
+
return parent[key]
|
|
155
|
+
} else if (typeof key === "number") {
|
|
156
|
+
// List access using get() method
|
|
157
|
+
if (parent.get && typeof parent.get === "function") {
|
|
158
|
+
return parent.get(key)
|
|
159
|
+
}
|
|
160
|
+
throw new Error(`Cannot use numeric index ${key} on non-list`)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
throw new Error(`Invalid key type: ${typeof key}`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// =============================================================================
|
|
167
|
+
// OPERATION HANDLERS - Following existing DraftNode patterns
|
|
168
|
+
// =============================================================================
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Handle 'add' operation using existing DraftNode methods
|
|
172
|
+
*/
|
|
173
|
+
function handleAdd<T extends DocShape>(
|
|
174
|
+
draft: Draft<T>,
|
|
175
|
+
operation: JsonPatchAddOperation,
|
|
176
|
+
): void {
|
|
177
|
+
const path = normalizePath(operation.path)
|
|
178
|
+
const { parent, key } = navigateToPath(draft, path)
|
|
179
|
+
|
|
180
|
+
if (typeof key === "string") {
|
|
181
|
+
// Map-like operations - use natural assignment or set() method
|
|
182
|
+
if (parent.set && typeof parent.set === "function") {
|
|
183
|
+
parent.set(key, operation.value)
|
|
184
|
+
} else {
|
|
185
|
+
// Natural property assignment (follows existing test patterns)
|
|
186
|
+
parent[key] = operation.value
|
|
187
|
+
}
|
|
188
|
+
} else if (typeof key === "number") {
|
|
189
|
+
// List operations - use insert() method (follows existing patterns)
|
|
190
|
+
if (parent.insert && typeof parent.insert === "function") {
|
|
191
|
+
parent.insert(key, operation.value)
|
|
192
|
+
} else {
|
|
193
|
+
throw new Error(`Cannot insert at numeric index ${key} on non-list`)
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
throw new Error(`Invalid key type: ${typeof key}`)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Handle 'remove' operation using existing DraftNode methods
|
|
202
|
+
*/
|
|
203
|
+
function handleRemove<T extends DocShape>(
|
|
204
|
+
draft: Draft<T>,
|
|
205
|
+
operation: JsonPatchRemoveOperation,
|
|
206
|
+
): void {
|
|
207
|
+
const path = normalizePath(operation.path)
|
|
208
|
+
const { parent, key } = navigateToPath(draft, path)
|
|
209
|
+
|
|
210
|
+
if (typeof key === "string") {
|
|
211
|
+
// Map-like operations - use delete() method (follows existing patterns)
|
|
212
|
+
if (parent.delete && typeof parent.delete === "function") {
|
|
213
|
+
parent.delete(key)
|
|
214
|
+
} else {
|
|
215
|
+
delete parent[key]
|
|
216
|
+
}
|
|
217
|
+
} else if (typeof key === "number") {
|
|
218
|
+
// List operations - use delete() method with count (follows existing patterns)
|
|
219
|
+
if (parent.delete && typeof parent.delete === "function") {
|
|
220
|
+
parent.delete(key, 1)
|
|
221
|
+
} else {
|
|
222
|
+
throw new Error(`Cannot remove at numeric index ${key} on non-list`)
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
throw new Error(`Invalid key type: ${typeof key}`)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Handle 'replace' operation using existing DraftNode methods
|
|
231
|
+
*/
|
|
232
|
+
function handleReplace<T extends DocShape>(
|
|
233
|
+
draft: Draft<T>,
|
|
234
|
+
operation: JsonPatchReplaceOperation,
|
|
235
|
+
): void {
|
|
236
|
+
const path = normalizePath(operation.path)
|
|
237
|
+
const { parent, key } = navigateToPath(draft, path)
|
|
238
|
+
|
|
239
|
+
if (typeof key === "string") {
|
|
240
|
+
// Map-like operations - use set() method or natural assignment
|
|
241
|
+
if (parent.set && typeof parent.set === "function") {
|
|
242
|
+
parent.set(key, operation.value)
|
|
243
|
+
} else {
|
|
244
|
+
parent[key] = operation.value
|
|
245
|
+
}
|
|
246
|
+
} else if (typeof key === "number") {
|
|
247
|
+
// List operations - delete then insert (follows existing patterns)
|
|
248
|
+
if (
|
|
249
|
+
parent.delete &&
|
|
250
|
+
parent.insert &&
|
|
251
|
+
typeof parent.delete === "function" &&
|
|
252
|
+
typeof parent.insert === "function"
|
|
253
|
+
) {
|
|
254
|
+
parent.delete(key, 1)
|
|
255
|
+
parent.insert(key, operation.value)
|
|
256
|
+
} else {
|
|
257
|
+
throw new Error(`Cannot replace at numeric index ${key} on non-list`)
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
throw new Error(`Invalid key type: ${typeof key}`)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Handle 'move' operation using existing DraftNode methods
|
|
266
|
+
*/
|
|
267
|
+
function handleMove<T extends DocShape>(
|
|
268
|
+
draft: Draft<T>,
|
|
269
|
+
operation: JsonPatchMoveOperation,
|
|
270
|
+
): void {
|
|
271
|
+
const fromPath = normalizePath(operation.from)
|
|
272
|
+
const toPath = normalizePath(operation.path)
|
|
273
|
+
|
|
274
|
+
// For list moves within the same parent, we need special handling
|
|
275
|
+
if (
|
|
276
|
+
fromPath.length === toPath.length &&
|
|
277
|
+
fromPath.slice(0, -1).every((segment, i) => segment === toPath[i])
|
|
278
|
+
) {
|
|
279
|
+
// Same parent container - use list move operation if available
|
|
280
|
+
const fromIndex = fromPath[fromPath.length - 1]
|
|
281
|
+
const toIndex = toPath[toPath.length - 1]
|
|
282
|
+
|
|
283
|
+
if (typeof fromIndex === "number" && typeof toIndex === "number") {
|
|
284
|
+
const { parent } = navigateToPath(draft, fromPath.slice(0, -1))
|
|
285
|
+
|
|
286
|
+
// Check if the parent has a move method (like LoroMovableList)
|
|
287
|
+
if (parent.move && typeof parent.move === "function") {
|
|
288
|
+
parent.move(fromIndex, toIndex)
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Otherwise, get value, remove, then add at target index
|
|
293
|
+
const value = getValueAtPath(draft, fromPath)
|
|
294
|
+
handleRemove(draft, { op: "remove", path: operation.from })
|
|
295
|
+
|
|
296
|
+
// For JSON Patch move semantics, the target index refers to the position
|
|
297
|
+
// in the final array, not the intermediate array after removal.
|
|
298
|
+
// No index adjustment needed - use the original target index.
|
|
299
|
+
handleAdd(draft, { op: "add", path: operation.path, value })
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Different parents or non-numeric indices - standard move
|
|
305
|
+
const value = getValueAtPath(draft, fromPath)
|
|
306
|
+
handleRemove(draft, { op: "remove", path: operation.from })
|
|
307
|
+
handleAdd(draft, { op: "add", path: operation.path, value })
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Handle 'copy' operation using existing DraftNode methods
|
|
312
|
+
*/
|
|
313
|
+
function handleCopy<T extends DocShape>(
|
|
314
|
+
draft: Draft<T>,
|
|
315
|
+
operation: JsonPatchCopyOperation,
|
|
316
|
+
): void {
|
|
317
|
+
const fromPath = normalizePath(operation.from)
|
|
318
|
+
|
|
319
|
+
// Get the value to copy
|
|
320
|
+
const value = getValueAtPath(draft, fromPath)
|
|
321
|
+
|
|
322
|
+
// Add to destination (no removal)
|
|
323
|
+
handleAdd(draft, { op: "add", path: operation.path, value })
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Handle 'test' operation using existing DraftNode value access
|
|
328
|
+
*/
|
|
329
|
+
function handleTest<T extends DocShape>(
|
|
330
|
+
draft: Draft<T>,
|
|
331
|
+
operation: JsonPatchTestOperation,
|
|
332
|
+
): boolean {
|
|
333
|
+
const path = normalizePath(operation.path)
|
|
334
|
+
const actualValue = getValueAtPath(draft, path)
|
|
335
|
+
|
|
336
|
+
// Deep equality check for test operation
|
|
337
|
+
return JSON.stringify(actualValue) === JSON.stringify(operation.value)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// =============================================================================
|
|
341
|
+
// MAIN APPLICATOR - Simple orchestration following existing patterns
|
|
342
|
+
// =============================================================================
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Main JSON Patch applicator - follows existing change() patterns
|
|
346
|
+
*/
|
|
347
|
+
export class JsonPatchApplicator<T extends DocShape> {
|
|
348
|
+
constructor(private rootDraft: Draft<T>) {}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Apply a single JSON Patch operation
|
|
352
|
+
*/
|
|
353
|
+
applyOperation(operation: JsonPatchOperation): void {
|
|
354
|
+
switch (operation.op) {
|
|
355
|
+
case "add":
|
|
356
|
+
handleAdd(this.rootDraft, operation)
|
|
357
|
+
break
|
|
358
|
+
case "remove":
|
|
359
|
+
handleRemove(this.rootDraft, operation)
|
|
360
|
+
break
|
|
361
|
+
case "replace":
|
|
362
|
+
handleReplace(this.rootDraft, operation)
|
|
363
|
+
break
|
|
364
|
+
case "move":
|
|
365
|
+
handleMove(this.rootDraft, operation)
|
|
366
|
+
break
|
|
367
|
+
case "copy":
|
|
368
|
+
handleCopy(this.rootDraft, operation)
|
|
369
|
+
break
|
|
370
|
+
case "test":
|
|
371
|
+
if (!handleTest(this.rootDraft, operation)) {
|
|
372
|
+
throw new Error(`JSON Patch test failed at path: ${operation.path}`)
|
|
373
|
+
}
|
|
374
|
+
break
|
|
375
|
+
default:
|
|
376
|
+
// TypeScript will catch this at compile time with proper discriminated union
|
|
377
|
+
throw new Error(
|
|
378
|
+
`Unsupported JSON Patch operation: ${(operation as any).op}`,
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Apply multiple JSON Patch operations in sequence
|
|
385
|
+
*/
|
|
386
|
+
applyPatch(patch: JsonPatch): void {
|
|
387
|
+
for (const operation of patch) {
|
|
388
|
+
this.applyOperation(operation)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
package/src/overlay.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Value } from "loro-crdt"
|
|
2
|
+
import type { ContainerShape, DocShape, ValueShape } from "./shape.js"
|
|
3
|
+
import { isObjectValue } from "./utils/type-guards.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Overlays CRDT state with empty state defaults
|
|
7
|
+
*/
|
|
8
|
+
export function overlayEmptyState<Shape extends DocShape>(
|
|
9
|
+
shape: Shape,
|
|
10
|
+
crdtValue: { [key: string]: Value },
|
|
11
|
+
emptyValue: { [key: string]: Value },
|
|
12
|
+
): { [key: string]: Value } {
|
|
13
|
+
if (typeof crdtValue !== "object") {
|
|
14
|
+
throw new Error("crdt object is required")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (typeof emptyValue !== "object") {
|
|
18
|
+
throw new Error("empty object is required")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const result = { ...emptyValue }
|
|
22
|
+
|
|
23
|
+
for (const [key, propShape] of Object.entries(shape.shapes)) {
|
|
24
|
+
const propCrdtValue = crdtValue[key]
|
|
25
|
+
|
|
26
|
+
const propEmptyValue = emptyValue[key as keyof typeof emptyValue]
|
|
27
|
+
|
|
28
|
+
result[key as keyof typeof result] = mergeValue(
|
|
29
|
+
propShape,
|
|
30
|
+
propCrdtValue,
|
|
31
|
+
propEmptyValue,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return result
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Merges individual CRDT values with empty state defaults
|
|
40
|
+
*/
|
|
41
|
+
export function mergeValue<Shape extends ContainerShape | ValueShape>(
|
|
42
|
+
shape: Shape,
|
|
43
|
+
crdtValue: Value,
|
|
44
|
+
emptyValue: Value,
|
|
45
|
+
): Value {
|
|
46
|
+
if (crdtValue === undefined && emptyValue === undefined) {
|
|
47
|
+
throw new Error("either crdt or empty value must be defined")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
switch (shape._type) {
|
|
51
|
+
case "text":
|
|
52
|
+
return crdtValue ?? emptyValue ?? ""
|
|
53
|
+
case "counter":
|
|
54
|
+
return crdtValue ?? emptyValue ?? 0
|
|
55
|
+
case "list":
|
|
56
|
+
case "movableList":
|
|
57
|
+
return crdtValue ?? emptyValue ?? []
|
|
58
|
+
case "map": {
|
|
59
|
+
if (!isObjectValue(crdtValue) && crdtValue !== undefined) {
|
|
60
|
+
throw new Error("map crdt must be object")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const crdtMapValue = crdtValue ?? {}
|
|
64
|
+
|
|
65
|
+
if (!isObjectValue(emptyValue) && emptyValue !== undefined) {
|
|
66
|
+
throw new Error("map empty state must be object")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const emptyMapValue = emptyValue ?? {}
|
|
70
|
+
|
|
71
|
+
const result = { ...emptyMapValue }
|
|
72
|
+
for (const [key, nestedShape] of Object.entries(shape.shapes)) {
|
|
73
|
+
const nestedCrdtValue = crdtMapValue[key]
|
|
74
|
+
const nestedEmptyValue = emptyMapValue[key]
|
|
75
|
+
|
|
76
|
+
result[key as keyof typeof result] = mergeValue(
|
|
77
|
+
nestedShape,
|
|
78
|
+
nestedCrdtValue,
|
|
79
|
+
nestedEmptyValue,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return result
|
|
84
|
+
}
|
|
85
|
+
case "tree":
|
|
86
|
+
return crdtValue ?? emptyValue ?? []
|
|
87
|
+
default:
|
|
88
|
+
return crdtValue ?? emptyValue
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { Shape, TypedDoc } from "./index.js"
|
|
3
|
+
|
|
4
|
+
describe("Record Types", () => {
|
|
5
|
+
describe("Shape.record (Container)", () => {
|
|
6
|
+
it("should handle record of counters", () => {
|
|
7
|
+
const schema = Shape.doc({
|
|
8
|
+
scores: Shape.record(Shape.counter()),
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const doc = new TypedDoc(schema, { scores: {} })
|
|
12
|
+
|
|
13
|
+
doc.change(draft => {
|
|
14
|
+
draft.scores.getOrCreateNode("alice").increment(10)
|
|
15
|
+
draft.scores.getOrCreateNode("bob").increment(5)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
expect(doc.value.scores).toEqual({
|
|
19
|
+
alice: 10,
|
|
20
|
+
bob: 5,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
doc.change(draft => {
|
|
24
|
+
draft.scores.getOrCreateNode("alice").increment(5)
|
|
25
|
+
draft.scores.delete("bob")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
expect(doc.value.scores).toEqual({
|
|
29
|
+
alice: 15,
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("should handle record of text", () => {
|
|
34
|
+
const schema = Shape.doc({
|
|
35
|
+
notes: Shape.record(Shape.text()),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const doc = new TypedDoc(schema, { notes: {} })
|
|
39
|
+
|
|
40
|
+
doc.change(draft => {
|
|
41
|
+
draft.notes.getOrCreateNode("todo").insert(0, "Buy milk")
|
|
42
|
+
draft.notes.getOrCreateNode("reminders").insert(0, "Call mom")
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
expect(doc.value.notes).toEqual({
|
|
46
|
+
todo: "Buy milk",
|
|
47
|
+
reminders: "Call mom",
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("should handle record of lists", () => {
|
|
52
|
+
const schema = Shape.doc({
|
|
53
|
+
groups: Shape.record(Shape.list(Shape.plain.string())),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const doc = new TypedDoc(schema, { groups: {} })
|
|
57
|
+
|
|
58
|
+
doc.change(draft => {
|
|
59
|
+
const groupA = draft.groups.getOrCreateNode("groupA")
|
|
60
|
+
groupA.push("alice")
|
|
61
|
+
groupA.push("bob")
|
|
62
|
+
|
|
63
|
+
const groupB = draft.groups.getOrCreateNode("groupB")
|
|
64
|
+
groupB.push("charlie")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
expect(doc.value.groups).toEqual({
|
|
68
|
+
groupA: ["alice", "bob"],
|
|
69
|
+
groupB: ["charlie"],
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe("Shape.plain.record (Value)", () => {
|
|
75
|
+
it("should handle record of plain strings", () => {
|
|
76
|
+
const schema = Shape.doc({
|
|
77
|
+
wrapper: Shape.map({
|
|
78
|
+
config: Shape.plain.record(Shape.plain.string()),
|
|
79
|
+
}),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const doc = new TypedDoc(schema, { wrapper: { config: {} } })
|
|
83
|
+
|
|
84
|
+
doc.change(draft => {
|
|
85
|
+
draft.wrapper.config.theme = "dark"
|
|
86
|
+
draft.wrapper.config.lang = "en"
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
expect(doc.value.wrapper.config).toEqual({
|
|
90
|
+
theme: "dark",
|
|
91
|
+
lang: "en",
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
doc.change(draft => {
|
|
95
|
+
delete draft.wrapper.config.theme
|
|
96
|
+
draft.wrapper.config.lang = "fr"
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
expect(doc.value.wrapper.config).toEqual({
|
|
100
|
+
lang: "fr",
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("should handle record of plain numbers", () => {
|
|
105
|
+
const schema = Shape.doc({
|
|
106
|
+
wrapper: Shape.map({
|
|
107
|
+
stats: Shape.plain.record(Shape.plain.number()),
|
|
108
|
+
}),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const doc = new TypedDoc(schema, { wrapper: { stats: { visits: 0 } } })
|
|
112
|
+
|
|
113
|
+
doc.change(draft => {
|
|
114
|
+
draft.wrapper.stats.visits = 100
|
|
115
|
+
draft.wrapper.stats.clicks = 50
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
expect(doc.value.wrapper.stats).toEqual({
|
|
119
|
+
visits: 100,
|
|
120
|
+
clicks: 50,
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("should handle nested records", () => {
|
|
125
|
+
const schema = Shape.doc({
|
|
126
|
+
wrapper: Shape.map({
|
|
127
|
+
settings: Shape.plain.record(
|
|
128
|
+
Shape.plain.record(Shape.plain.boolean()),
|
|
129
|
+
),
|
|
130
|
+
}),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const doc = new TypedDoc(schema, { wrapper: { settings: {} } })
|
|
134
|
+
|
|
135
|
+
doc.change(draft => {
|
|
136
|
+
draft.wrapper.settings.ui = {
|
|
137
|
+
darkMode: true,
|
|
138
|
+
sidebar: false,
|
|
139
|
+
}
|
|
140
|
+
draft.wrapper.settings.notifications = {
|
|
141
|
+
email: true,
|
|
142
|
+
push: true,
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
expect(doc.value.wrapper.settings).toEqual({
|
|
147
|
+
ui: {
|
|
148
|
+
darkMode: true,
|
|
149
|
+
sidebar: false,
|
|
150
|
+
},
|
|
151
|
+
notifications: {
|
|
152
|
+
email: true,
|
|
153
|
+
push: true,
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe("Mixed Usage", () => {
|
|
160
|
+
it("should handle record of maps", () => {
|
|
161
|
+
const schema = Shape.doc({
|
|
162
|
+
users: Shape.record(
|
|
163
|
+
Shape.map({
|
|
164
|
+
name: Shape.plain.string(),
|
|
165
|
+
age: Shape.plain.number(),
|
|
166
|
+
}),
|
|
167
|
+
),
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const doc = new TypedDoc(schema, { users: {} })
|
|
171
|
+
|
|
172
|
+
doc.change(draft => {
|
|
173
|
+
const alice = draft.users.getOrCreateNode("u1")
|
|
174
|
+
alice.name = "Alice"
|
|
175
|
+
alice.age = 30
|
|
176
|
+
|
|
177
|
+
const bob = draft.users.getOrCreateNode("u2")
|
|
178
|
+
bob.name = "Bob"
|
|
179
|
+
bob.age = 25
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
expect(doc.value.users).toEqual({
|
|
183
|
+
u1: { name: "Alice", age: 30 },
|
|
184
|
+
u2: { name: "Bob", age: 25 },
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
})
|