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.
Files changed (37) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +119 -0
  4. package/dist/mobx-keystone-loro.esm.js +957 -0
  5. package/dist/mobx-keystone-loro.esm.mjs +957 -0
  6. package/dist/mobx-keystone-loro.umd.js +957 -0
  7. package/dist/types/binding/LoroTextModel.d.ts +72 -0
  8. package/dist/types/binding/applyLoroEventToMobx.d.ts +9 -0
  9. package/dist/types/binding/applyMobxChangeToLoroObject.d.ts +7 -0
  10. package/dist/types/binding/bindLoroToMobxKeystone.d.ts +33 -0
  11. package/dist/types/binding/convertJsonToLoroData.d.ts +51 -0
  12. package/dist/types/binding/convertLoroDataToJson.d.ts +11 -0
  13. package/dist/types/binding/loroBindingContext.d.ts +39 -0
  14. package/dist/types/binding/loroSnapshotTracking.d.ts +26 -0
  15. package/dist/types/binding/moveWithinArray.d.ts +36 -0
  16. package/dist/types/binding/resolveLoroPath.d.ts +11 -0
  17. package/dist/types/index.d.ts +8 -0
  18. package/dist/types/plainTypes.d.ts +6 -0
  19. package/dist/types/utils/error.d.ts +4 -0
  20. package/dist/types/utils/getOrCreateLoroCollectionAtom.d.ts +7 -0
  21. package/dist/types/utils/isBindableLoroContainer.d.ts +9 -0
  22. package/package.json +92 -0
  23. package/src/binding/LoroTextModel.ts +211 -0
  24. package/src/binding/applyLoroEventToMobx.ts +280 -0
  25. package/src/binding/applyMobxChangeToLoroObject.ts +182 -0
  26. package/src/binding/bindLoroToMobxKeystone.ts +353 -0
  27. package/src/binding/convertJsonToLoroData.ts +315 -0
  28. package/src/binding/convertLoroDataToJson.ts +68 -0
  29. package/src/binding/loroBindingContext.ts +46 -0
  30. package/src/binding/loroSnapshotTracking.ts +36 -0
  31. package/src/binding/moveWithinArray.ts +112 -0
  32. package/src/binding/resolveLoroPath.ts +37 -0
  33. package/src/index.ts +16 -0
  34. package/src/plainTypes.ts +7 -0
  35. package/src/utils/error.ts +12 -0
  36. package/src/utils/getOrCreateLoroCollectionAtom.ts +17 -0
  37. 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,7 @@
1
+ export type PlainPrimitive = string | number | boolean | null | undefined
2
+
3
+ export type PlainValue = PlainPrimitive | PlainObject | PlainArray
4
+
5
+ export type PlainObject = { [key: string]: PlainValue }
6
+
7
+ export type PlainArray = PlainValue[]
@@ -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
+ }