@loro-extended/change 5.3.0 → 5.4.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 +46 -9
- package/dist/index.d.ts +174 -13
- package/dist/index.js +402 -22
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/conversion.ts +40 -4
- package/src/functional-helpers.ts +121 -7
- package/src/index.ts +14 -10
- package/src/loro.ts +2 -1
- package/src/nested-container-materialization.test.ts +336 -0
- package/src/replay-diff.test.ts +389 -0
- package/src/replay-diff.ts +229 -0
- package/src/shallow-fork.test.ts +302 -0
- package/src/typed-doc-ownkeys.test.ts +116 -0
- package/src/typed-doc.ts +10 -4
- package/src/typed-refs/base.ts +25 -4
- package/src/typed-refs/counter-ref-internals.ts +7 -2
- package/src/typed-refs/doc-ref-ownkeys.test.ts +78 -0
- package/src/typed-refs/list-ref-base-internals.ts +2 -1
- package/src/typed-refs/list-ref-base.ts +2 -1
- package/src/typed-refs/record-ref-internals.ts +104 -2
- package/src/typed-refs/record-ref.test.ts +522 -1
- package/src/typed-refs/record-ref.ts +72 -3
- package/src/typed-refs/struct-ref-internals.ts +28 -3
- package/src/typed-refs/text-ref-internals.ts +2 -2
- package/src/typed-refs/tree-node-ref-internals.ts +14 -2
- package/src/typed-refs/tree-ref-internals.ts +2 -1
- package/src/typed-refs/utils.ts +65 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loro-extended/change",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.4.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",
|
|
@@ -25,13 +25,14 @@
|
|
|
25
25
|
"tsup": "^8.5.0",
|
|
26
26
|
"tsx": "^4.20.3",
|
|
27
27
|
"typescript": "^5.9.2",
|
|
28
|
-
"vitest": "^
|
|
28
|
+
"vitest": "^4.0.17"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"loro-crdt": "^1.10.3"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "tsup",
|
|
35
|
+
"test": "verify logic",
|
|
35
36
|
"verify": "verify"
|
|
36
37
|
}
|
|
37
38
|
}
|
package/src/conversion.ts
CHANGED
|
@@ -108,17 +108,53 @@ function convertStructInput(
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
const map = new LoroMap()
|
|
111
|
-
|
|
111
|
+
|
|
112
|
+
// Iterate over schema keys to ensure all nested containers are materialized
|
|
113
|
+
for (const k of Object.keys(shape.shapes)) {
|
|
112
114
|
const nestedSchema = shape.shapes[k]
|
|
113
|
-
|
|
115
|
+
const v = value[k]
|
|
116
|
+
|
|
117
|
+
if (v !== undefined) {
|
|
114
118
|
const convertedValue = convertInputToRef(v, nestedSchema)
|
|
115
119
|
if (isContainer(convertedValue)) {
|
|
116
120
|
map.setContainer(k, convertedValue)
|
|
117
121
|
} else {
|
|
118
122
|
map.set(k, convertedValue)
|
|
119
123
|
}
|
|
120
|
-
} else {
|
|
121
|
-
|
|
124
|
+
} else if (isContainerShape(nestedSchema)) {
|
|
125
|
+
// If value is missing but it's a container shape, create an empty container
|
|
126
|
+
// This ensures deterministic container IDs across peers
|
|
127
|
+
let emptyValue: any
|
|
128
|
+
if (nestedSchema._type === "struct" || nestedSchema._type === "record") {
|
|
129
|
+
emptyValue = {}
|
|
130
|
+
} else if (
|
|
131
|
+
nestedSchema._type === "list" ||
|
|
132
|
+
nestedSchema._type === "movableList"
|
|
133
|
+
) {
|
|
134
|
+
emptyValue = []
|
|
135
|
+
} else if (nestedSchema._type === "text") {
|
|
136
|
+
emptyValue = ""
|
|
137
|
+
} else if (nestedSchema._type === "counter") {
|
|
138
|
+
emptyValue = 0
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (emptyValue !== undefined) {
|
|
142
|
+
const convertedValue = convertInputToRef(emptyValue, nestedSchema)
|
|
143
|
+
if (isContainer(convertedValue)) {
|
|
144
|
+
map.setContainer(k, convertedValue)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Also handle keys present in value but not in schema (if any, though for structs this shouldn't happen ideally)
|
|
151
|
+
// But for backward compatibility or loose typing, we might want to preserve them?
|
|
152
|
+
// The original code did:
|
|
153
|
+
// if (nestedSchema) { ... } else { map.set(k, value) }
|
|
154
|
+
// So it allowed extra keys.
|
|
155
|
+
for (const [k, v] of Object.entries(value)) {
|
|
156
|
+
if (!shape.shapes[k]) {
|
|
157
|
+
map.set(k, v)
|
|
122
158
|
}
|
|
123
159
|
}
|
|
124
160
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
LoroCounter,
|
|
1
|
+
import {
|
|
2
|
+
type LoroCounter,
|
|
3
3
|
LoroDoc,
|
|
4
|
-
LoroList,
|
|
5
|
-
LoroMap,
|
|
6
|
-
LoroMovableList,
|
|
7
|
-
LoroText,
|
|
8
|
-
LoroTree,
|
|
4
|
+
type LoroList,
|
|
5
|
+
type LoroMap,
|
|
6
|
+
type LoroMovableList,
|
|
7
|
+
type LoroText,
|
|
8
|
+
type LoroTree,
|
|
9
9
|
} from "loro-crdt"
|
|
10
10
|
import { loro } from "./loro.js"
|
|
11
11
|
import type {
|
|
@@ -274,6 +274,52 @@ export function getLoroContainer(
|
|
|
274
274
|
return loro(ref as any).container
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Creates a new TypedDoc as a fork of the current document.
|
|
279
|
+
* The forked doc contains all history up to the current version.
|
|
280
|
+
* The forked doc has a different PeerID from the original by default.
|
|
281
|
+
*
|
|
282
|
+
* For raw LoroDoc access, use: `loro(doc).doc.fork()`
|
|
283
|
+
*
|
|
284
|
+
* @param doc - The TypedDoc to fork
|
|
285
|
+
* @param options - Optional settings
|
|
286
|
+
* @param options.preservePeerId - If true, copies the original doc's peer ID to the fork
|
|
287
|
+
* @returns A new TypedDoc with the same schema at the current version
|
|
288
|
+
*
|
|
289
|
+
* @example
|
|
290
|
+
* ```typescript
|
|
291
|
+
* import { fork, loro } from "@loro-extended/change"
|
|
292
|
+
*
|
|
293
|
+
* const doc = createTypedDoc(schema);
|
|
294
|
+
* doc.title.update("Hello");
|
|
295
|
+
*
|
|
296
|
+
* // Fork the document
|
|
297
|
+
* const forkedDoc = fork(doc);
|
|
298
|
+
* forkedDoc.title.update("World");
|
|
299
|
+
*
|
|
300
|
+
* console.log(doc.title.toString()); // "Hello"
|
|
301
|
+
* console.log(forkedDoc.title.toString()); // "World"
|
|
302
|
+
*
|
|
303
|
+
* // Fork with same peer ID (for World/Worldview pattern)
|
|
304
|
+
* const worldview = fork(world, { preservePeerId: true });
|
|
305
|
+
* ```
|
|
306
|
+
*/
|
|
307
|
+
export function fork<Shape extends DocShape>(
|
|
308
|
+
doc: TypedDoc<Shape>,
|
|
309
|
+
options?: { preservePeerId?: boolean },
|
|
310
|
+
): TypedDoc<Shape> {
|
|
311
|
+
const loroDoc = loro(doc).doc
|
|
312
|
+
const forkedLoroDoc = loroDoc.fork()
|
|
313
|
+
const shape = loro(doc).docShape as Shape
|
|
314
|
+
|
|
315
|
+
// Optionally preserve the peer ID (useful for World/Worldview pattern)
|
|
316
|
+
if (options?.preservePeerId) {
|
|
317
|
+
forkedLoroDoc.setPeerId(loroDoc.peerId)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return createTypedDoc(shape, forkedLoroDoc)
|
|
321
|
+
}
|
|
322
|
+
|
|
277
323
|
/**
|
|
278
324
|
* Creates a new TypedDoc at a specified version (frontiers).
|
|
279
325
|
* The forked doc will only contain history before the specified frontiers.
|
|
@@ -309,3 +355,71 @@ export function forkAt<Shape extends DocShape>(
|
|
|
309
355
|
const shape = loro(doc).docShape as Shape
|
|
310
356
|
return createTypedDoc(shape, forkedLoroDoc)
|
|
311
357
|
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Creates a new TypedDoc at a specified version using a shallow snapshot.
|
|
361
|
+
* Unlike `forkAt`, this creates a "garbage-collected" snapshot that only
|
|
362
|
+
* contains the current state and history since the specified frontiers.
|
|
363
|
+
*
|
|
364
|
+
* This is more memory-efficient than `forkAt` for documents with long history,
|
|
365
|
+
* especially useful for the fork-and-merge pattern in LEA where we only need:
|
|
366
|
+
* 1. Read current state
|
|
367
|
+
* 2. Apply changes
|
|
368
|
+
* 3. Export delta and merge back
|
|
369
|
+
*
|
|
370
|
+
* The shallow fork has a different PeerID from the original by default.
|
|
371
|
+
* Use `preservePeerId: true` to copy the original's peer ID (useful for
|
|
372
|
+
* fork-and-merge patterns where you want consistent frontier progression).
|
|
373
|
+
*
|
|
374
|
+
* @param doc - The TypedDoc to fork
|
|
375
|
+
* @param frontiers - The version to fork at (obtained from `loro(doc).doc.frontiers()`)
|
|
376
|
+
* @param options - Optional settings
|
|
377
|
+
* @param options.preservePeerId - If true, copies the original doc's peer ID to the fork
|
|
378
|
+
* @returns A new TypedDoc with the same schema at the specified version (shallow)
|
|
379
|
+
*
|
|
380
|
+
* @example
|
|
381
|
+
* ```typescript
|
|
382
|
+
* import { shallowForkAt, loro } from "@loro-extended/change"
|
|
383
|
+
*
|
|
384
|
+
* const doc = createTypedDoc(schema);
|
|
385
|
+
* doc.title.update("Hello");
|
|
386
|
+
* const frontiers = loro(doc).doc.frontiers();
|
|
387
|
+
*
|
|
388
|
+
* // Create a shallow fork (memory-efficient)
|
|
389
|
+
* const shallowDoc = shallowForkAt(doc, frontiers, { preservePeerId: true });
|
|
390
|
+
*
|
|
391
|
+
* // Modify the shallow doc
|
|
392
|
+
* shallowDoc.title.update("World");
|
|
393
|
+
*
|
|
394
|
+
* // Merge changes back
|
|
395
|
+
* const update = loro(shallowDoc).doc.export({
|
|
396
|
+
* mode: "update",
|
|
397
|
+
* from: loro(doc).doc.version()
|
|
398
|
+
* });
|
|
399
|
+
* loro(doc).doc.import(update);
|
|
400
|
+
* ```
|
|
401
|
+
*/
|
|
402
|
+
export function shallowForkAt<Shape extends DocShape>(
|
|
403
|
+
doc: TypedDoc<Shape>,
|
|
404
|
+
frontiers: Frontiers,
|
|
405
|
+
options?: { preservePeerId?: boolean },
|
|
406
|
+
): TypedDoc<Shape> {
|
|
407
|
+
const loroDoc = loro(doc).doc
|
|
408
|
+
const shape = loro(doc).docShape as Shape
|
|
409
|
+
|
|
410
|
+
// Export a shallow snapshot at the specified frontiers
|
|
411
|
+
const shallowBytes = loroDoc.export({
|
|
412
|
+
mode: "shallow-snapshot",
|
|
413
|
+
frontiers,
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
// Create a new LoroDoc from the shallow snapshot
|
|
417
|
+
const shallowLoroDoc = LoroDoc.fromSnapshot(shallowBytes)
|
|
418
|
+
|
|
419
|
+
// Optionally preserve the peer ID for consistent frontier progression
|
|
420
|
+
if (options?.preservePeerId) {
|
|
421
|
+
shallowLoroDoc.setPeerId(loroDoc.peerId)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return createTypedDoc(shape, shallowLoroDoc)
|
|
425
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,13 +4,17 @@ export {
|
|
|
4
4
|
derivePlaceholder,
|
|
5
5
|
deriveShapePlaceholder,
|
|
6
6
|
} from "./derive-placeholder.js"
|
|
7
|
+
|
|
7
8
|
// Functional helpers (recommended API)
|
|
8
9
|
export {
|
|
9
10
|
change,
|
|
11
|
+
fork,
|
|
10
12
|
forkAt,
|
|
11
13
|
getLoroContainer,
|
|
12
14
|
getLoroDoc,
|
|
15
|
+
shallowForkAt,
|
|
13
16
|
} from "./functional-helpers.js"
|
|
17
|
+
|
|
14
18
|
// The loro() escape hatch for CRDT internals
|
|
15
19
|
export {
|
|
16
20
|
LORO_SYMBOL,
|
|
@@ -23,6 +27,7 @@ export {
|
|
|
23
27
|
type LoroTypedDocRef,
|
|
24
28
|
loro,
|
|
25
29
|
} from "./loro.js"
|
|
30
|
+
|
|
26
31
|
export { mergeValue, overlayPlaceholder } from "./overlay.js"
|
|
27
32
|
// Path selector DSL exports
|
|
28
33
|
export { createPathBuilder } from "./path-builder.js"
|
|
@@ -35,43 +40,42 @@ export type {
|
|
|
35
40
|
PathSelector,
|
|
36
41
|
} from "./path-selector.js"
|
|
37
42
|
export { createPlaceholderProxy } from "./placeholder-proxy.js"
|
|
43
|
+
export { replayDiff } from "./replay-diff.js"
|
|
44
|
+
// Shape utilities
|
|
45
|
+
// Container shapes
|
|
46
|
+
// Value shapes
|
|
38
47
|
export type {
|
|
39
|
-
// Escape hatch shapes for untyped integration
|
|
40
48
|
AnyContainerShape,
|
|
41
49
|
AnyValueShape,
|
|
42
50
|
ArrayValueShape,
|
|
43
51
|
ContainerOrValueShape,
|
|
44
52
|
ContainerShape,
|
|
45
53
|
ContainerType as RootContainerType,
|
|
46
|
-
// Container shapes
|
|
47
54
|
CounterContainerShape,
|
|
48
|
-
//
|
|
55
|
+
// Tagged union
|
|
49
56
|
DiscriminatedUnionValueShape,
|
|
50
|
-
// Schema node types
|
|
51
57
|
DocShape,
|
|
52
58
|
ListContainerShape,
|
|
53
|
-
/** @deprecated Use StructContainerShape instead */
|
|
54
|
-
MapContainerShape,
|
|
55
59
|
MovableListContainerShape,
|
|
56
|
-
|
|
57
|
-
ObjectValueShape,
|
|
60
|
+
NumberValueShape,
|
|
58
61
|
RecordContainerShape,
|
|
59
62
|
RecordValueShape,
|
|
63
|
+
StringValueShape,
|
|
60
64
|
StructContainerShape,
|
|
61
65
|
StructValueShape,
|
|
62
66
|
TextContainerShape,
|
|
63
67
|
TreeContainerShape,
|
|
64
|
-
// Tree-related types
|
|
65
68
|
TreeNodeJSON,
|
|
66
69
|
TreeRefInterface,
|
|
70
|
+
// Union of two or more plain value types
|
|
67
71
|
UnionValueShape,
|
|
68
|
-
// Value shapes
|
|
69
72
|
ValueShape,
|
|
70
73
|
// WithNullable type for shapes that support .nullable()
|
|
71
74
|
WithNullable,
|
|
72
75
|
// WithPlaceholder type for shapes that support .placeholder()
|
|
73
76
|
WithPlaceholder,
|
|
74
77
|
} from "./shape.js"
|
|
78
|
+
|
|
75
79
|
// Schema and type exports
|
|
76
80
|
export { Shape } from "./shape.js"
|
|
77
81
|
export type { Frontiers, TypedDoc } from "./typed-doc.js"
|
package/src/loro.ts
CHANGED
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
Container,
|
|
29
29
|
LoroCounter,
|
|
30
30
|
LoroDoc,
|
|
31
|
+
LoroEventBatch,
|
|
31
32
|
LoroList,
|
|
32
33
|
LoroMap,
|
|
33
34
|
LoroMovableList,
|
|
@@ -85,7 +86,7 @@ export interface LoroRefBase {
|
|
|
85
86
|
* @param callback - Function called when the container changes
|
|
86
87
|
* @returns Subscription that can be used to unsubscribe
|
|
87
88
|
*/
|
|
88
|
-
subscribe(callback: (event:
|
|
89
|
+
subscribe(callback: (event: LoroEventBatch) => void): Subscription
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
/**
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { convertInputToRef } from "./conversion.js"
|
|
3
|
+
import { createTypedDoc, loro, Shape } from "./index.js"
|
|
4
|
+
|
|
5
|
+
describe("Nested Container Materialization", () => {
|
|
6
|
+
it("syncs correctly between peers when struct has nested empty record", () => {
|
|
7
|
+
// Define a schema with a nested map
|
|
8
|
+
const schema = Shape.doc({
|
|
9
|
+
items: Shape.record(
|
|
10
|
+
Shape.struct({
|
|
11
|
+
id: Shape.plain.string(),
|
|
12
|
+
metadata: Shape.record(Shape.struct({ key: Shape.plain.string() })), // Nested map of structs
|
|
13
|
+
}),
|
|
14
|
+
),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// Create two separate documents (simulating two peers)
|
|
18
|
+
const clientDoc = createTypedDoc(schema)
|
|
19
|
+
const serverDoc = createTypedDoc(schema)
|
|
20
|
+
|
|
21
|
+
// Client creates an item with empty nested map
|
|
22
|
+
clientDoc.items.set("item-1", {
|
|
23
|
+
id: "item-1",
|
|
24
|
+
metadata: {}, // Empty nested map - BUG: may not materialize properly
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// Sync client -> server
|
|
28
|
+
const clientSnapshot = loro(clientDoc).doc.export({ mode: "snapshot" })
|
|
29
|
+
loro(serverDoc).doc.import(clientSnapshot)
|
|
30
|
+
|
|
31
|
+
// Server writes to the nested map
|
|
32
|
+
const serverEntry = serverDoc.items.get("item-1")
|
|
33
|
+
expect(serverEntry).toBeDefined()
|
|
34
|
+
serverEntry?.metadata.set("entry-1", { key: "value" })
|
|
35
|
+
|
|
36
|
+
// Sync server -> client
|
|
37
|
+
const serverUpdate = loro(serverDoc).doc.export({
|
|
38
|
+
mode: "update",
|
|
39
|
+
from: loro(clientDoc).doc.version(),
|
|
40
|
+
})
|
|
41
|
+
loro(clientDoc).doc.import(serverUpdate)
|
|
42
|
+
|
|
43
|
+
// BUG: Client's metadata is EMPTY!
|
|
44
|
+
const clientEntry = clientDoc.items.get("item-1")
|
|
45
|
+
expect(clientEntry?.metadata.toJSON()).toEqual({
|
|
46
|
+
"entry-1": { key: "value" },
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("handles concurrent creation of nested containers", () => {
|
|
51
|
+
const schema = Shape.doc({
|
|
52
|
+
items: Shape.record(
|
|
53
|
+
Shape.struct({
|
|
54
|
+
id: Shape.plain.string(),
|
|
55
|
+
metadata: Shape.record(Shape.plain.string()),
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const doc1 = createTypedDoc(schema)
|
|
61
|
+
const doc2 = createTypedDoc(schema)
|
|
62
|
+
|
|
63
|
+
// Peer 1 creates item with empty metadata
|
|
64
|
+
doc1.items.set("item-1", {
|
|
65
|
+
id: "item-1",
|
|
66
|
+
metadata: {},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// Sync 1 -> 2
|
|
70
|
+
loro(doc2).doc.import(loro(doc1).doc.export({ mode: "snapshot" }))
|
|
71
|
+
|
|
72
|
+
// Peer 1 writes to metadata
|
|
73
|
+
doc1.items.get("item-1")?.metadata.set("p1", "v1")
|
|
74
|
+
|
|
75
|
+
// Peer 2 writes to metadata (concurrently)
|
|
76
|
+
doc2.items.get("item-1")?.metadata.set("p2", "v2")
|
|
77
|
+
|
|
78
|
+
// Sync 1 -> 2
|
|
79
|
+
loro(doc2).doc.import(
|
|
80
|
+
loro(doc1).doc.export({ mode: "update", from: loro(doc2).doc.version() }),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
// Sync 2 -> 1
|
|
84
|
+
loro(doc1).doc.import(
|
|
85
|
+
loro(doc2).doc.export({ mode: "update", from: loro(doc1).doc.version() }),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
// Both should have both values
|
|
89
|
+
const json1 = doc1.items.get("item-1")?.metadata.toJSON()
|
|
90
|
+
const json2 = doc2.items.get("item-1")?.metadata.toJSON()
|
|
91
|
+
|
|
92
|
+
console.log("JSON1", json1)
|
|
93
|
+
console.log("JSON2", json2)
|
|
94
|
+
|
|
95
|
+
expect(json1).toEqual({ p1: "v1", p2: "v2" })
|
|
96
|
+
expect(json2).toEqual({ p1: "v1", p2: "v2" })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("materializes nested containers in Tree nodes", () => {
|
|
100
|
+
const schema = Shape.doc({
|
|
101
|
+
tree: Shape.tree(
|
|
102
|
+
Shape.struct({
|
|
103
|
+
id: Shape.plain.string(),
|
|
104
|
+
tags: Shape.record(Shape.plain.boolean()),
|
|
105
|
+
}),
|
|
106
|
+
),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const doc1 = createTypedDoc(schema)
|
|
110
|
+
const doc2 = createTypedDoc(schema)
|
|
111
|
+
|
|
112
|
+
// Create a node with empty tags
|
|
113
|
+
const node = doc1.tree.createNode({
|
|
114
|
+
id: "node-1",
|
|
115
|
+
tags: {},
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// Get the node ID for lookup
|
|
119
|
+
const nodeId = loro(node).container.id
|
|
120
|
+
|
|
121
|
+
// Sync 1 -> 2
|
|
122
|
+
loro(doc2).doc.import(loro(doc1).doc.export({ mode: "snapshot" }))
|
|
123
|
+
|
|
124
|
+
// Verify the container exists in doc1
|
|
125
|
+
const tags = node.data.tags
|
|
126
|
+
expect(tags).toBeDefined()
|
|
127
|
+
|
|
128
|
+
// Verify it's materialized by checking if we can get its container ID
|
|
129
|
+
const tagsContainer = loro(tags).container
|
|
130
|
+
expect(tagsContainer).toBeDefined()
|
|
131
|
+
|
|
132
|
+
// Now check doc2 using getNodeByID
|
|
133
|
+
const tree2 = loro(doc2.tree).container
|
|
134
|
+
const node2 = tree2.getNodeByID(nodeId)
|
|
135
|
+
|
|
136
|
+
expect(node2).toBeDefined()
|
|
137
|
+
if (node2) {
|
|
138
|
+
const data = node2.data // LoroMap
|
|
139
|
+
const tagsMap = data.get("tags") // Should be LoroMap
|
|
140
|
+
expect(tagsMap).toBeDefined()
|
|
141
|
+
// @ts-expect-error - kind() exists at runtime
|
|
142
|
+
expect(tagsMap.kind()).toBe("Map")
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("handles missing containers gracefully (schema evolution)", async () => {
|
|
147
|
+
// Simulate an old document that doesn't have a nested container
|
|
148
|
+
const { LoroDoc } = await import("loro-crdt")
|
|
149
|
+
const oldDoc = new LoroDoc()
|
|
150
|
+
const map = oldDoc.getMap("root")
|
|
151
|
+
map.set("existing", "value")
|
|
152
|
+
// "newField" is missing
|
|
153
|
+
oldDoc.commit()
|
|
154
|
+
|
|
155
|
+
// Load with new schema that expects "newField" as a container
|
|
156
|
+
const schema = Shape.doc({
|
|
157
|
+
root: Shape.struct({
|
|
158
|
+
existing: Shape.plain.string(),
|
|
159
|
+
newField: Shape.record(Shape.plain.string()),
|
|
160
|
+
}),
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const doc = createTypedDoc(schema)
|
|
164
|
+
loro(doc).doc.import(oldDoc.export({ mode: "snapshot" }))
|
|
165
|
+
|
|
166
|
+
// Access missing container
|
|
167
|
+
const root = doc.root
|
|
168
|
+
// Should not crash
|
|
169
|
+
expect(root.existing).toBe("value")
|
|
170
|
+
|
|
171
|
+
// Accessing the missing container ref should work (it creates the wrapper)
|
|
172
|
+
const newField = root.newField
|
|
173
|
+
expect(newField).toBeDefined()
|
|
174
|
+
|
|
175
|
+
// But the underlying container doesn't exist yet?
|
|
176
|
+
// getOrCreateRef creates the wrapper.
|
|
177
|
+
// The wrapper's getContainer() calls getOrCreateContainer().
|
|
178
|
+
|
|
179
|
+
// If we read from it:
|
|
180
|
+
expect(newField.keys()).toEqual([])
|
|
181
|
+
|
|
182
|
+
// If we write to it:
|
|
183
|
+
newField.set("k", "v")
|
|
184
|
+
|
|
185
|
+
// It should create the container lazily (or eagerly on set)
|
|
186
|
+
expect(newField.get("k")).toBe("v")
|
|
187
|
+
|
|
188
|
+
// Verify in raw doc
|
|
189
|
+
const rawMap = loro(doc).doc.getMap("root")
|
|
190
|
+
const rawNewField = rawMap.get("newField")
|
|
191
|
+
expect(rawNewField).toBeDefined()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// Task 1.2: Test struct with deeply nested empty containers
|
|
195
|
+
it("materializes all levels of deeply nested structs", () => {
|
|
196
|
+
const schema = Shape.doc({
|
|
197
|
+
root: Shape.struct({
|
|
198
|
+
level1: Shape.struct({
|
|
199
|
+
level2: Shape.struct({
|
|
200
|
+
level3: Shape.record(Shape.plain.string()),
|
|
201
|
+
}),
|
|
202
|
+
}),
|
|
203
|
+
}),
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const doc1 = createTypedDoc(schema)
|
|
207
|
+
const doc2 = createTypedDoc(schema)
|
|
208
|
+
|
|
209
|
+
// Initialize with empty nested structure - all levels should materialize
|
|
210
|
+
// Note: For structs, we need to access them to trigger materialization
|
|
211
|
+
// since the doc root is created lazily
|
|
212
|
+
const _root = doc1.root
|
|
213
|
+
const _level1 = doc1.root.level1
|
|
214
|
+
const _level2 = doc1.root.level1.level2
|
|
215
|
+
const _level3 = doc1.root.level1.level2.level3
|
|
216
|
+
|
|
217
|
+
// Sync doc1 -> doc2
|
|
218
|
+
loro(doc2).doc.import(loro(doc1).doc.export({ mode: "snapshot" }))
|
|
219
|
+
|
|
220
|
+
// Peer 2 writes to the deeply nested container
|
|
221
|
+
doc2.root.level1.level2.level3.set("deep-key", "deep-value")
|
|
222
|
+
|
|
223
|
+
// Sync doc2 -> doc1
|
|
224
|
+
loro(doc1).doc.import(
|
|
225
|
+
loro(doc2).doc.export({ mode: "update", from: loro(doc1).doc.version() }),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
// Verify the deeply nested value is visible in doc1
|
|
229
|
+
expect(doc1.root.level1.level2.level3.get("deep-key")).toBe("deep-value")
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// Task 1.3: Test list push with nested empty container
|
|
233
|
+
it("materializes nested containers when pushing to list", () => {
|
|
234
|
+
const schema = Shape.doc({
|
|
235
|
+
items: Shape.list(
|
|
236
|
+
Shape.struct({
|
|
237
|
+
id: Shape.plain.string(),
|
|
238
|
+
metadata: Shape.record(Shape.plain.string()),
|
|
239
|
+
}),
|
|
240
|
+
),
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
const doc1 = createTypedDoc(schema)
|
|
244
|
+
const doc2 = createTypedDoc(schema)
|
|
245
|
+
|
|
246
|
+
// Push an item with empty nested metadata
|
|
247
|
+
doc1.items.push({
|
|
248
|
+
id: "item-1",
|
|
249
|
+
metadata: {},
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// Sync doc1 -> doc2
|
|
253
|
+
loro(doc2).doc.import(loro(doc1).doc.export({ mode: "snapshot" }))
|
|
254
|
+
|
|
255
|
+
// Peer 2 writes to the nested metadata
|
|
256
|
+
const item = doc2.items[0]
|
|
257
|
+
expect(item).toBeDefined()
|
|
258
|
+
item?.metadata.set("key", "value")
|
|
259
|
+
|
|
260
|
+
// Sync doc2 -> doc1
|
|
261
|
+
loro(doc1).doc.import(
|
|
262
|
+
loro(doc2).doc.export({ mode: "update", from: loro(doc1).doc.version() }),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
// Verify the nested value is visible in doc1
|
|
266
|
+
expect(doc1.items[0]?.metadata.get("key")).toBe("value")
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// Task 1.5: Test conversion API with nested empty container
|
|
270
|
+
it("convertInputToRef creates containers for empty nested values", async () => {
|
|
271
|
+
const loroCrdt = await import("loro-crdt")
|
|
272
|
+
|
|
273
|
+
const structShape = Shape.struct({
|
|
274
|
+
id: Shape.plain.string(),
|
|
275
|
+
nested: Shape.record(Shape.plain.string()),
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
// Convert a plain object with empty nested record
|
|
279
|
+
const result = convertInputToRef({ id: "test", nested: {} }, structShape)
|
|
280
|
+
|
|
281
|
+
// Result should be a LoroMap
|
|
282
|
+
expect(result).toBeInstanceOf(loroCrdt.LoroMap)
|
|
283
|
+
|
|
284
|
+
// The nested field should also be a LoroMap (not undefined or plain object)
|
|
285
|
+
const nestedContainer = (result as typeof loroCrdt.LoroMap.prototype).get(
|
|
286
|
+
"nested",
|
|
287
|
+
)
|
|
288
|
+
expect(nestedContainer).toBeDefined()
|
|
289
|
+
expect(nestedContainer).toBeInstanceOf(loroCrdt.LoroMap)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
// Tree Node Full Sync Test (from test-plan)
|
|
293
|
+
it("syncs nested containers in Tree nodes between peers", () => {
|
|
294
|
+
const schema = Shape.doc({
|
|
295
|
+
tree: Shape.tree(
|
|
296
|
+
Shape.struct({
|
|
297
|
+
id: Shape.plain.string(),
|
|
298
|
+
tags: Shape.record(Shape.plain.boolean()),
|
|
299
|
+
}),
|
|
300
|
+
),
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
const doc1 = createTypedDoc(schema)
|
|
304
|
+
const doc2 = createTypedDoc(schema)
|
|
305
|
+
|
|
306
|
+
// Create a node with empty tags in doc1
|
|
307
|
+
const node = doc1.tree.createNode({
|
|
308
|
+
id: "node-1",
|
|
309
|
+
tags: {},
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
// Get the node ID for later lookup
|
|
313
|
+
const nodeId = loro(node).container.id
|
|
314
|
+
|
|
315
|
+
// Sync doc1 -> doc2
|
|
316
|
+
loro(doc2).doc.import(loro(doc1).doc.export({ mode: "snapshot" }))
|
|
317
|
+
|
|
318
|
+
// Peer 2 writes to tags using raw Loro API (since TreeRef doesn't expose get by ID)
|
|
319
|
+
const tree2 = loro(doc2.tree).container
|
|
320
|
+
const node2Raw = tree2.getNodeByID(nodeId)
|
|
321
|
+
expect(node2Raw).toBeDefined()
|
|
322
|
+
if (node2Raw) {
|
|
323
|
+
const data2 = node2Raw.data as any
|
|
324
|
+
const tags2 = data2.get("tags") as any
|
|
325
|
+
tags2.set("important", true)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Sync doc2 -> doc1
|
|
329
|
+
loro(doc1).doc.import(
|
|
330
|
+
loro(doc2).doc.export({ mode: "update", from: loro(doc1).doc.version() }),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
// Verify the tag is visible in doc1's node
|
|
334
|
+
expect(node.data.tags.get("important")).toBe(true)
|
|
335
|
+
})
|
|
336
|
+
})
|