mobx-keystone-loro 1.0.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/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/dist/mobx-keystone-loro.esm.js +957 -0
- package/dist/mobx-keystone-loro.esm.mjs +957 -0
- package/dist/mobx-keystone-loro.umd.js +957 -0
- package/dist/types/binding/LoroTextModel.d.ts +72 -0
- package/dist/types/binding/applyLoroEventToMobx.d.ts +9 -0
- package/dist/types/binding/applyMobxChangeToLoroObject.d.ts +7 -0
- package/dist/types/binding/bindLoroToMobxKeystone.d.ts +33 -0
- package/dist/types/binding/convertJsonToLoroData.d.ts +51 -0
- package/dist/types/binding/convertLoroDataToJson.d.ts +11 -0
- package/dist/types/binding/loroBindingContext.d.ts +39 -0
- package/dist/types/binding/loroSnapshotTracking.d.ts +26 -0
- package/dist/types/binding/moveWithinArray.d.ts +36 -0
- package/dist/types/binding/resolveLoroPath.d.ts +11 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/plainTypes.d.ts +6 -0
- package/dist/types/utils/error.d.ts +4 -0
- package/dist/types/utils/getOrCreateLoroCollectionAtom.d.ts +7 -0
- package/dist/types/utils/isBindableLoroContainer.d.ts +9 -0
- package/package.json +92 -0
- package/src/binding/LoroTextModel.ts +211 -0
- package/src/binding/applyLoroEventToMobx.ts +280 -0
- package/src/binding/applyMobxChangeToLoroObject.ts +182 -0
- package/src/binding/bindLoroToMobxKeystone.ts +353 -0
- package/src/binding/convertJsonToLoroData.ts +315 -0
- package/src/binding/convertLoroDataToJson.ts +68 -0
- package/src/binding/loroBindingContext.ts +46 -0
- package/src/binding/loroSnapshotTracking.ts +36 -0
- package/src/binding/moveWithinArray.ts +112 -0
- package/src/binding/resolveLoroPath.ts +37 -0
- package/src/index.ts +16 -0
- package/src/plainTypes.ts +7 -0
- package/src/utils/error.ts +12 -0
- package/src/utils/getOrCreateLoroCollectionAtom.ts +17 -0
- package/src/utils/isBindableLoroContainer.ts +13 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isContainer, LoroMap, LoroMovableList, LoroText } from "loro-crdt"
|
|
2
|
+
import { modelSnapshotOutWithMetadata, toFrozenSnapshot } from "mobx-keystone"
|
|
3
|
+
import type { PlainValue } from "../plainTypes"
|
|
4
|
+
import { failure } from "../utils/error"
|
|
5
|
+
import { type BindableLoroContainer } from "../utils/isBindableLoroContainer"
|
|
6
|
+
import { LoroTextModel } from "./LoroTextModel"
|
|
7
|
+
|
|
8
|
+
type LoroValue = BindableLoroContainer | PlainValue
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Converts Loro data to JSON-compatible format for mobx-keystone snapshots.
|
|
12
|
+
*
|
|
13
|
+
* @param value The Loro value to convert
|
|
14
|
+
* @returns JSON-compatible value
|
|
15
|
+
*/
|
|
16
|
+
export function convertLoroDataToJson(value: LoroValue): PlainValue {
|
|
17
|
+
if (value === null) {
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (typeof value !== "object") {
|
|
22
|
+
if (value === undefined) {
|
|
23
|
+
throw new Error("undefined values are not supported by Loro")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return value as PlainValue
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (isContainer(value)) {
|
|
30
|
+
if (value instanceof LoroMap) {
|
|
31
|
+
const result: Record<string, PlainValue> = {}
|
|
32
|
+
for (const [k, v] of value.entries()) {
|
|
33
|
+
result[k] = convertLoroDataToJson(v as LoroValue)
|
|
34
|
+
}
|
|
35
|
+
return result
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (value instanceof LoroMovableList) {
|
|
39
|
+
const result: PlainValue[] = []
|
|
40
|
+
for (let i = 0; i < value.length; i++) {
|
|
41
|
+
result.push(convertLoroDataToJson(value.get(i) as LoroValue))
|
|
42
|
+
}
|
|
43
|
+
return result
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (value instanceof LoroText) {
|
|
47
|
+
const deltas = value.toDelta()
|
|
48
|
+
// Return a LoroTextModel-compatible snapshot
|
|
49
|
+
return modelSnapshotOutWithMetadata(LoroTextModel, {
|
|
50
|
+
deltaList: toFrozenSnapshot(deltas),
|
|
51
|
+
}) as unknown as PlainValue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
throw failure(`unsupported bindable Loro container type`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Plain object or array
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
return value.map((item) => convertLoroDataToJson(item as LoroValue))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const result: Record<string, PlainValue> = {}
|
|
63
|
+
for (const [k, v] of Object.entries(value)) {
|
|
64
|
+
result[k] = convertLoroDataToJson(v as LoroValue)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return result
|
|
68
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { LoroDoc } from "loro-crdt"
|
|
2
|
+
import { type AnyType, createContext } from "mobx-keystone"
|
|
3
|
+
import type { BindableLoroContainer } from "../utils/isBindableLoroContainer"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Context for the Loro binding, providing access to the Loro document
|
|
7
|
+
* and binding state from within mobx-keystone models.
|
|
8
|
+
*/
|
|
9
|
+
export interface LoroBindingContext {
|
|
10
|
+
/**
|
|
11
|
+
* The Loro document being bound.
|
|
12
|
+
*/
|
|
13
|
+
loroDoc: LoroDoc
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The root Loro object being bound.
|
|
17
|
+
*/
|
|
18
|
+
loroObject: BindableLoroContainer
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The mobx-keystone model type being used.
|
|
22
|
+
*/
|
|
23
|
+
mobxKeystoneType: AnyType
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* String used as origin for Loro transactions to identify changes
|
|
27
|
+
* coming from mobx-keystone.
|
|
28
|
+
*/
|
|
29
|
+
loroOrigin: string
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The bound mobx-keystone object (once created).
|
|
33
|
+
*/
|
|
34
|
+
boundObject: unknown
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether changes are currently being applied from Loro to mobx-keystone.
|
|
38
|
+
* Used to prevent infinite loops.
|
|
39
|
+
*/
|
|
40
|
+
isApplyingLoroChangesToMobxKeystone: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Context for accessing the Loro binding from within mobx-keystone models.
|
|
45
|
+
*/
|
|
46
|
+
export const loroBindingContext = createContext<LoroBindingContext | undefined>(undefined)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { LoroMap, LoroMovableList } from "loro-crdt"
|
|
2
|
+
|
|
3
|
+
type LoroContainer = LoroMap | LoroMovableList
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* WeakMap that tracks which snapshot each Loro container was last synced from.
|
|
7
|
+
* This is used during reconciliation to skip containers that are already up-to-date.
|
|
8
|
+
*
|
|
9
|
+
* The key is the Loro container (LoroMap or LoroMovableList).
|
|
10
|
+
* The value is the snapshot (plain object or array) that was last synced to it.
|
|
11
|
+
*/
|
|
12
|
+
export const loroContainerToSnapshot = new WeakMap<LoroContainer, unknown>()
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Updates the snapshot tracking for a Loro container.
|
|
16
|
+
* Call this after syncing a snapshot to a Loro container.
|
|
17
|
+
*/
|
|
18
|
+
export function setLoroContainerSnapshot(container: LoroContainer, snapshot: unknown): void {
|
|
19
|
+
loroContainerToSnapshot.set(container, snapshot)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Gets the last synced snapshot for a Loro container.
|
|
24
|
+
* Returns undefined if the container has never been synced.
|
|
25
|
+
*/
|
|
26
|
+
export function getLoroContainerSnapshot(container: LoroContainer): unknown {
|
|
27
|
+
return loroContainerToSnapshot.get(container)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Checks if a Loro container is up-to-date with the given snapshot.
|
|
32
|
+
* Uses reference equality to check if the snapshot is the same.
|
|
33
|
+
*/
|
|
34
|
+
export function isLoroContainerUpToDate(container: LoroContainer, snapshot: unknown): boolean {
|
|
35
|
+
return loroContainerToSnapshot.get(container) === snapshot
|
|
36
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { DeepChange } from "mobx-keystone"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Synthetic change type for array moves.
|
|
5
|
+
*/
|
|
6
|
+
export interface ArrayMoveChange {
|
|
7
|
+
type: "ArrayMove"
|
|
8
|
+
path: readonly (string | number)[]
|
|
9
|
+
fromIndex: number
|
|
10
|
+
toIndex: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Tracks the currently active move operation.
|
|
15
|
+
* When set, the next two splice operations on this array are intercepted.
|
|
16
|
+
*/
|
|
17
|
+
let activeMoveContext:
|
|
18
|
+
| {
|
|
19
|
+
array: unknown[]
|
|
20
|
+
fromIndex: number
|
|
21
|
+
toIndex: number
|
|
22
|
+
path: readonly (string | number)[] | undefined // Captured from first splice
|
|
23
|
+
receivedFirstSplice: boolean
|
|
24
|
+
}
|
|
25
|
+
| undefined
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Moves an item within an array from one index to another.
|
|
29
|
+
*
|
|
30
|
+
* When used on a mobx-keystone array bound to a Loro movable list,
|
|
31
|
+
* this translates to a native Loro move operation for optimal CRDT merging.
|
|
32
|
+
*
|
|
33
|
+
* For unbound arrays, performs a standard splice-based move.
|
|
34
|
+
*
|
|
35
|
+
* @param array The array to move within
|
|
36
|
+
* @param fromIndex The index of the item to move
|
|
37
|
+
* @param toIndex The target index to move the item to
|
|
38
|
+
*/
|
|
39
|
+
export function moveWithinArray<T>(array: T[], fromIndex: number, toIndex: number): void {
|
|
40
|
+
// Validate indices
|
|
41
|
+
if (fromIndex < 0 || fromIndex >= array.length) {
|
|
42
|
+
throw new Error(`fromIndex ${fromIndex} is out of bounds (array length: ${array.length})`)
|
|
43
|
+
}
|
|
44
|
+
if (toIndex < 0 || toIndex > array.length) {
|
|
45
|
+
throw new Error(`toIndex ${toIndex} is out of bounds (array length: ${array.length})`)
|
|
46
|
+
}
|
|
47
|
+
if (fromIndex === toIndex) {
|
|
48
|
+
return // No-op
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Set up the move context before mutations
|
|
52
|
+
activeMoveContext = {
|
|
53
|
+
array,
|
|
54
|
+
fromIndex,
|
|
55
|
+
toIndex,
|
|
56
|
+
path: undefined,
|
|
57
|
+
receivedFirstSplice: false,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
// Perform the actual splice operations
|
|
62
|
+
// This will trigger onDeepChange for each splice
|
|
63
|
+
const [item] = array.splice(fromIndex, 1)
|
|
64
|
+
const adjustedTarget = toIndex > fromIndex ? toIndex - 1 : toIndex
|
|
65
|
+
array.splice(adjustedTarget, 0, item)
|
|
66
|
+
} finally {
|
|
67
|
+
activeMoveContext = undefined
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check if a change is part of an active move operation and process it.
|
|
73
|
+
* This is called for ArraySplice changes on the target array.
|
|
74
|
+
*
|
|
75
|
+
* @param change The deep change (must be ArraySplice type)
|
|
76
|
+
* @returns The ArrayMoveChange if the move is complete (second splice),
|
|
77
|
+
* undefined if intercepted but not complete (first splice)
|
|
78
|
+
*/
|
|
79
|
+
export function processChangeForMove(change: DeepChange): ArrayMoveChange | undefined {
|
|
80
|
+
// We know we're in a move context and this is an ArraySplice on the target array
|
|
81
|
+
const ctx = activeMoveContext!
|
|
82
|
+
|
|
83
|
+
if (!ctx.receivedFirstSplice) {
|
|
84
|
+
// First splice - capture the path and mark as received
|
|
85
|
+
ctx.path = change.path
|
|
86
|
+
ctx.receivedFirstSplice = true
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Second splice - the move is complete.
|
|
91
|
+
// Note: We adjust toIndex here for Loro's move() semantics, which expects
|
|
92
|
+
// the target position after removal. This is intentionally separate from
|
|
93
|
+
// the adjustment on line 64, which is for the splice operation on the MobX array.
|
|
94
|
+
// Both adjustments are needed because:
|
|
95
|
+
// - Line 64: splice() inserts at a position in the already-shortened array
|
|
96
|
+
// - Here: Loro's move(from, to) also expects `to` as the position after removal
|
|
97
|
+
const adjustedToIndex = ctx.toIndex > ctx.fromIndex ? ctx.toIndex - 1 : ctx.toIndex
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
type: "ArrayMove",
|
|
101
|
+
path: ctx.path!,
|
|
102
|
+
fromIndex: ctx.fromIndex,
|
|
103
|
+
toIndex: adjustedToIndex,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check if we're currently in a move context for a specific array.
|
|
109
|
+
*/
|
|
110
|
+
export function isInMoveContextForArray(array: unknown[]): boolean {
|
|
111
|
+
return activeMoveContext !== undefined && activeMoveContext.array === array
|
|
112
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { LoroMap, LoroMovableList } from "loro-crdt"
|
|
2
|
+
import type { Path } from "mobx-keystone"
|
|
3
|
+
import { failure } from "../utils/error"
|
|
4
|
+
import { getOrCreateLoroCollectionAtom } from "../utils/getOrCreateLoroCollectionAtom"
|
|
5
|
+
import type { BindableLoroContainer } from "../utils/isBindableLoroContainer"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolves a path within a Loro object structure.
|
|
9
|
+
* Returns the Loro container at the specified path.
|
|
10
|
+
*
|
|
11
|
+
* @param loroObject The root Loro object
|
|
12
|
+
* @param path Array of keys/indices to traverse
|
|
13
|
+
* @returns The Loro container at the path
|
|
14
|
+
*/
|
|
15
|
+
export function resolveLoroPath(loroObject: BindableLoroContainer, path: Path): unknown {
|
|
16
|
+
let currentLoroObject: unknown = loroObject
|
|
17
|
+
|
|
18
|
+
path.forEach((pathPart, i) => {
|
|
19
|
+
if (currentLoroObject instanceof LoroMap) {
|
|
20
|
+
getOrCreateLoroCollectionAtom(currentLoroObject).reportObserved()
|
|
21
|
+
const key = String(pathPart)
|
|
22
|
+
currentLoroObject = currentLoroObject.get(key)
|
|
23
|
+
} else if (currentLoroObject instanceof LoroMovableList) {
|
|
24
|
+
getOrCreateLoroCollectionAtom(currentLoroObject).reportObserved()
|
|
25
|
+
const key = Number(pathPart)
|
|
26
|
+
currentLoroObject = currentLoroObject.get(key)
|
|
27
|
+
} else {
|
|
28
|
+
throw failure(
|
|
29
|
+
`LoroMap or LoroMovableList was expected at path ${JSON.stringify(
|
|
30
|
+
path.slice(0, i)
|
|
31
|
+
)} in order to resolve path ${JSON.stringify(path)}, but got ${currentLoroObject} instead`
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
return currentLoroObject
|
|
37
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { bindLoroToMobxKeystone } from "./binding/bindLoroToMobxKeystone"
|
|
2
|
+
export type { ApplyJsonToLoroOptions } from "./binding/convertJsonToLoroData"
|
|
3
|
+
export {
|
|
4
|
+
applyJsonArrayToLoroMovableList,
|
|
5
|
+
applyJsonObjectToLoroMap,
|
|
6
|
+
convertJsonToLoroData,
|
|
7
|
+
} from "./binding/convertJsonToLoroData"
|
|
8
|
+
export {
|
|
9
|
+
isLoroTextModelSnapshot,
|
|
10
|
+
LoroTextModel,
|
|
11
|
+
loroTextModelType,
|
|
12
|
+
} from "./binding/LoroTextModel"
|
|
13
|
+
export type { LoroBindingContext } from "./binding/loroBindingContext"
|
|
14
|
+
export { loroBindingContext } from "./binding/loroBindingContext"
|
|
15
|
+
export { moveWithinArray } from "./binding/moveWithinArray"
|
|
16
|
+
export { MobxKeystoneLoroError } from "./utils/error"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class MobxKeystoneLoroError extends Error {
|
|
2
|
+
constructor(msg: string) {
|
|
3
|
+
super(msg)
|
|
4
|
+
|
|
5
|
+
// Set the prototype explicitly for better instanceof support
|
|
6
|
+
Object.setPrototypeOf(this, MobxKeystoneLoroError.prototype)
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function failure(message: string): never {
|
|
11
|
+
throw new MobxKeystoneLoroError(message)
|
|
12
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createAtom, type IAtom } from "mobx"
|
|
2
|
+
import type { BindableLoroContainer } from "./isBindableLoroContainer"
|
|
3
|
+
|
|
4
|
+
const atomMap = new WeakMap<BindableLoroContainer, IAtom>()
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Gets or creates a MobX atom for a Loro collection.
|
|
8
|
+
* This is used to track reactivity for computed properties that read from Loro containers.
|
|
9
|
+
*/
|
|
10
|
+
export function getOrCreateLoroCollectionAtom(collection: BindableLoroContainer): IAtom {
|
|
11
|
+
let atom = atomMap.get(collection)
|
|
12
|
+
if (!atom) {
|
|
13
|
+
atom = createAtom(`loroCollectionAtom`)
|
|
14
|
+
atomMap.set(collection, atom)
|
|
15
|
+
}
|
|
16
|
+
return atom
|
|
17
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { LoroMap, LoroMovableList, LoroText } from "loro-crdt"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A bindable Loro container (Map, MovableList, or Text).
|
|
5
|
+
*/
|
|
6
|
+
export type BindableLoroContainer = LoroMap | LoroMovableList | LoroText
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Checks if a value is a bindable Loro container.
|
|
10
|
+
*/
|
|
11
|
+
export function isBindableLoroContainer(value: unknown): value is BindableLoroContainer {
|
|
12
|
+
return value instanceof LoroMap || value instanceof LoroMovableList || value instanceof LoroText
|
|
13
|
+
}
|