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,280 @@
1
+ import type { ContainerID, ListDiff, LoroDoc, LoroEvent, MapDiff } from "loro-crdt"
2
+ import { isContainer, LoroMap, LoroMovableList, LoroText } from "loro-crdt"
3
+ import { remove } from "mobx"
4
+ import {
5
+ Frozen,
6
+ fromSnapshot,
7
+ frozen,
8
+ getSnapshotModelId,
9
+ isDataModel,
10
+ isFrozenSnapshot,
11
+ isModel,
12
+ modelIdKey,
13
+ Path,
14
+ resolvePath,
15
+ runUnprotected,
16
+ } from "mobx-keystone"
17
+ import type { PlainValue } from "../plainTypes"
18
+ import { failure } from "../utils/error"
19
+ import { convertLoroDataToJson } from "./convertLoroDataToJson"
20
+
21
+ // Represents the map of potential objects to reconcile (ID -> Object)
22
+ export type ReconciliationMap = Map<string, object>
23
+
24
+ /**
25
+ * Applies a Loro event directly to the MobX model tree using proper mutations
26
+ * (splice for arrays, property assignment for objects).
27
+ * This is more efficient than converting to patches first.
28
+ */
29
+ export function applyLoroEventToMobx(
30
+ event: LoroEvent,
31
+ loroDoc: LoroDoc,
32
+ boundObject: object,
33
+ rootPath: Path,
34
+ reconciliationMap: ReconciliationMap,
35
+ newlyInsertedContainers: Set<ContainerID>
36
+ ): void {
37
+ // Skip events for containers that were just inserted as part of another container
38
+ // Their content is already included in the parent's convertLoroDataToJson call
39
+ if (newlyInsertedContainers.has(event.target)) {
40
+ return
41
+ }
42
+
43
+ // Resolve the path relative to the root
44
+ const eventPath = loroDoc.getPathToContainer(event.target)
45
+ if (!eventPath) {
46
+ return
47
+ }
48
+
49
+ // Strip the root path to get relative path
50
+ const relativePath = resolveEventPath(eventPath, rootPath)
51
+ if (relativePath === undefined) {
52
+ return
53
+ }
54
+
55
+ const { value: target } = resolvePath(boundObject, relativePath)
56
+
57
+ if (!target) {
58
+ throw failure(`cannot resolve path ${JSON.stringify(relativePath)}`)
59
+ }
60
+
61
+ // Wrap in runUnprotected since we're modifying the tree from outside a model action
62
+ runUnprotected(() => {
63
+ const diff = event.diff
64
+ if (diff.type === "map") {
65
+ applyMapEventToMobx(diff, loroDoc, event.target, target, reconciliationMap)
66
+ } else if (diff.type === "list") {
67
+ applyListEventToMobx(
68
+ diff,
69
+ loroDoc,
70
+ event.target,
71
+ target,
72
+ reconciliationMap,
73
+ newlyInsertedContainers
74
+ )
75
+ } else if (diff.type === "text") {
76
+ applyTextEventToMobx(loroDoc, event.target, target)
77
+ }
78
+ })
79
+ }
80
+
81
+ function processDeletedValue(val: unknown, reconciliationMap: ReconciliationMap) {
82
+ // Handle both Model and DataModel instances
83
+ if (isModel(val) || isDataModel(val)) {
84
+ const id = modelIdKey in val ? val[modelIdKey] : undefined
85
+ if (id) {
86
+ reconciliationMap.set(id, val)
87
+ }
88
+ }
89
+ }
90
+
91
+ function reviveValue(jsonValue: unknown, reconciliationMap: ReconciliationMap): unknown {
92
+ // Handle primitives
93
+ if (jsonValue === null || typeof jsonValue !== "object") {
94
+ return jsonValue
95
+ }
96
+
97
+ // Handle frozen
98
+ if (isFrozenSnapshot(jsonValue)) {
99
+ return frozen(jsonValue.data)
100
+ }
101
+
102
+ // If we have a reconciliation map and the value looks like a model with an ID, check if we have it
103
+ if (reconciliationMap) {
104
+ const modelId = getSnapshotModelId(jsonValue)
105
+ if (modelId) {
106
+ const existing = reconciliationMap.get(modelId)
107
+ if (existing) {
108
+ reconciliationMap.delete(modelId)
109
+ return existing
110
+ }
111
+ }
112
+ }
113
+
114
+ return fromSnapshot(jsonValue)
115
+ }
116
+
117
+ function applyMapEventToMobx(
118
+ diff: MapDiff,
119
+ loroDoc: LoroDoc,
120
+ containerTarget: ContainerID,
121
+ target: Record<string, unknown>,
122
+ reconciliationMap: ReconciliationMap
123
+ ): void {
124
+ const container = loroDoc.getContainerById(containerTarget)
125
+
126
+ if (!container || !(container instanceof LoroMap)) {
127
+ throw failure(`${containerTarget} was not a Loro map`)
128
+ }
129
+
130
+ // Process additions and updates from diff.updated
131
+ for (const key of Object.keys(diff.updated)) {
132
+ const loroValue = container.get(key)
133
+
134
+ if (loroValue === undefined) {
135
+ // Key was deleted (Loro returns undefined for deleted keys)
136
+ if (key in target) {
137
+ processDeletedValue(target[key], reconciliationMap)
138
+ // Use MobX's remove to properly delete the key from the observable object
139
+ if (isModel(target)) {
140
+ remove(target.$, key)
141
+ } else {
142
+ remove(target, key)
143
+ }
144
+ }
145
+ } else {
146
+ // Key was added or updated
147
+ if (key in target) {
148
+ processDeletedValue(target[key], reconciliationMap)
149
+ }
150
+ const jsonValue = convertLoroDataToJson(loroValue as PlainValue)
151
+ target[key] = reviveValue(jsonValue, reconciliationMap)
152
+ }
153
+ }
154
+ }
155
+
156
+ function applyListEventToMobx(
157
+ diff: ListDiff,
158
+ loroDoc: LoroDoc,
159
+ containerTarget: ContainerID,
160
+ target: unknown[],
161
+ reconciliationMap: ReconciliationMap,
162
+ newlyInsertedContainers: Set<ContainerID>
163
+ ): void {
164
+ const container = loroDoc.getContainerById(containerTarget)
165
+
166
+ if (!container || !(container instanceof LoroMovableList)) {
167
+ throw failure(`${containerTarget} was not a Loro movable list`)
168
+ }
169
+
170
+ // Process delta operations in order
171
+ let currentIndex = 0
172
+
173
+ for (const change of diff.diff) {
174
+ if (change.retain) {
175
+ currentIndex += change.retain
176
+ }
177
+
178
+ if (change.delete) {
179
+ // Capture deleted items for reconciliation
180
+ const deletedItems = target.slice(currentIndex, currentIndex + change.delete)
181
+ deletedItems.forEach((item) => {
182
+ processDeletedValue(item, reconciliationMap)
183
+ })
184
+
185
+ // Delete items at current position
186
+ target.splice(currentIndex, change.delete)
187
+ }
188
+
189
+ if (change.insert) {
190
+ // Insert items at current position
191
+ const insertedItems = change.insert
192
+ const values = insertedItems.map((loroValue) => {
193
+ // Track container IDs to avoid double-processing their events
194
+ // When a container with data is inserted, Loro fires events for both
195
+ // the container insert and the container's content, but the content
196
+ // is already included in convertLoroDataToJson
197
+ if (isContainer(loroValue)) {
198
+ newlyInsertedContainers.add(loroValue.id)
199
+ // Also recursively track any nested containers
200
+ collectNestedContainerIds(loroValue, newlyInsertedContainers)
201
+ }
202
+ const jsonValue = convertLoroDataToJson(loroValue as PlainValue)
203
+ return reviveValue(jsonValue, reconciliationMap)
204
+ })
205
+
206
+ target.splice(currentIndex, 0, ...values)
207
+ currentIndex += values.length
208
+ }
209
+ }
210
+ }
211
+
212
+ function applyTextEventToMobx(
213
+ loroDoc: LoroDoc,
214
+ containerTarget: ContainerID,
215
+ target: { deltaList?: Frozen<unknown[]> }
216
+ ): void {
217
+ const container = loroDoc.getContainerById(containerTarget)
218
+
219
+ if (!container || !(container instanceof LoroText)) {
220
+ throw failure(`${containerTarget} was not a Loro text container`)
221
+ }
222
+
223
+ // LoroTextModel has deltaList as a single Frozen<LoroTextDeltaList>, not an array
224
+ // Replace it with the current delta from the LoroText
225
+ if (!("deltaList" in target)) {
226
+ throw failure("target does not have a deltaList property - expected LoroTextModel")
227
+ }
228
+ target.deltaList = frozen(container.toDelta())
229
+ }
230
+
231
+ /**
232
+ * Recursively collects all container IDs from a Loro container.
233
+ * This is used to track containers that have been inserted and should not
234
+ * have their events processed again (since their content was already included
235
+ * in the parent's convertLoroDataToJson call).
236
+ */
237
+ function collectNestedContainerIds(container: unknown, containerIds: Set<ContainerID>): void {
238
+ if (!isContainer(container)) {
239
+ return
240
+ }
241
+
242
+ // Add this container's ID
243
+ containerIds.add(container.id)
244
+
245
+ // Handle LoroMap - iterate over values
246
+ if (container instanceof LoroMap) {
247
+ for (const key of Object.keys(container.toJSON())) {
248
+ const value = container.get(key)
249
+ collectNestedContainerIds(value, containerIds)
250
+ }
251
+ }
252
+
253
+ // Handle LoroMovableList - iterate over items
254
+ if (container instanceof LoroMovableList) {
255
+ for (let i = 0; i < container.length; i++) {
256
+ collectNestedContainerIds(container.get(i), containerIds)
257
+ }
258
+ }
259
+
260
+ // LoroText doesn't contain nested containers
261
+ }
262
+
263
+ /**
264
+ * Resolves the path from a Loro event to a mobx-keystone path.
265
+ * The event path is the path from the doc root to the container that emitted the event.
266
+ * We need to strip the path of our root container to get a path relative to our bound object.
267
+ */
268
+ function resolveEventPath(eventPath: Path, rootPath: Path): Path | undefined {
269
+ if (eventPath.length < rootPath.length) {
270
+ return undefined
271
+ }
272
+
273
+ for (let i = 0; i < rootPath.length; i++) {
274
+ if (eventPath[i] !== rootPath[i]) {
275
+ return undefined
276
+ }
277
+ }
278
+
279
+ return eventPath.slice(rootPath.length) as Path
280
+ }
@@ -0,0 +1,182 @@
1
+ import { LoroMap, LoroMovableList, LoroText } from "loro-crdt"
2
+ import { DeepChange, DeepChangeType } from "mobx-keystone"
3
+ import type { PlainValue } from "../plainTypes"
4
+ import { failure } from "../utils/error"
5
+ import type { BindableLoroContainer } from "../utils/isBindableLoroContainer"
6
+ import {
7
+ applyDeltaToLoroText,
8
+ convertJsonToLoroData,
9
+ extractTextDeltaFromSnapshot,
10
+ } from "./convertJsonToLoroData"
11
+ import { isLoroTextModelSnapshot } from "./LoroTextModel"
12
+ import type { ArrayMoveChange } from "./moveWithinArray"
13
+ import { resolveLoroPath } from "./resolveLoroPath"
14
+
15
+ /**
16
+ * Converts a snapshot value to a Loro-compatible value.
17
+ * Note: All values passed here are already snapshots (captured at change time).
18
+ */
19
+ function convertValue(v: unknown): unknown {
20
+ // Handle primitives directly
21
+ if (v === null || v === undefined || typeof v !== "object") {
22
+ return v
23
+ }
24
+ // Handle plain arrays - used for empty array init
25
+ if (Array.isArray(v) && v.length === 0) {
26
+ return new LoroMovableList()
27
+ }
28
+ // Handle LoroTextModel snapshot specially - we need to return it as-is
29
+ // so the caller can handle creating the LoroText container properly
30
+ if (isLoroTextModelSnapshot(v)) {
31
+ return v
32
+ }
33
+ // Value is already a snapshot, convert to Loro data
34
+ return convertJsonToLoroData(v as PlainValue)
35
+ }
36
+
37
+ /**
38
+ * Inserts a value into a LoroMovableList at the given index.
39
+ */
40
+ function insertIntoList(list: LoroMovableList, index: number, value: unknown): void {
41
+ if (value instanceof LoroMap || value instanceof LoroMovableList || value instanceof LoroText) {
42
+ list.insertContainer(index, value)
43
+ } else if (isLoroTextModelSnapshot(value)) {
44
+ const attachedText = list.insertContainer(index, new LoroText())
45
+ const deltas = extractTextDeltaFromSnapshot((value as any).deltaList)
46
+ if (deltas.length > 0) {
47
+ applyDeltaToLoroText(attachedText, deltas)
48
+ }
49
+ } else {
50
+ list.insert(index, value)
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Sets a value in a LoroMap at the given key.
56
+ */
57
+ function setInMap(map: LoroMap, key: string, value: unknown): void {
58
+ if (value === undefined) {
59
+ map.delete(key)
60
+ } else if (
61
+ value instanceof LoroMap ||
62
+ value instanceof LoroMovableList ||
63
+ value instanceof LoroText
64
+ ) {
65
+ map.setContainer(key, value)
66
+ } else if (isLoroTextModelSnapshot(value)) {
67
+ const attachedText = map.setContainer(key, new LoroText())
68
+ const deltas = extractTextDeltaFromSnapshot((value as any).deltaList)
69
+ if (deltas.length > 0) {
70
+ applyDeltaToLoroText(attachedText, deltas)
71
+ }
72
+ } else {
73
+ map.set(key, value)
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Applies a MobX DeepChange or an ArrayMoveChange to a Loro object.
79
+ */
80
+ export function applyMobxChangeToLoroObject(
81
+ change: DeepChange | ArrayMoveChange,
82
+ loroObject: BindableLoroContainer
83
+ ): void {
84
+ const loroContainer = resolveLoroPath(loroObject, change.path)
85
+
86
+ // If container doesn't exist at this path, throw an error
87
+ if (!loroContainer) {
88
+ throw failure(
89
+ `cannot apply change to missing Loro container at path: ${JSON.stringify(change.path)}`
90
+ )
91
+ }
92
+
93
+ switch (change.type) {
94
+ case "ArrayMove": {
95
+ if (!(loroContainer instanceof LoroMovableList)) {
96
+ throw failure(`ArrayMove change requires a LoroMovableList container`)
97
+ }
98
+ loroContainer.move(change.fromIndex, change.toIndex)
99
+ break
100
+ }
101
+
102
+ case DeepChangeType.ArraySplice: {
103
+ if (!(loroContainer instanceof LoroMovableList)) {
104
+ throw failure(`ArraySplice change requires a LoroMovableList container`)
105
+ }
106
+ if (change.removedValues.length > 0) {
107
+ loroContainer.delete(change.index, change.removedValues.length)
108
+ }
109
+ if (change.addedValues.length > 0) {
110
+ const valuesToInsert = change.addedValues.map(convertValue)
111
+ for (let i = 0; i < valuesToInsert.length; i++) {
112
+ insertIntoList(loroContainer, change.index + i, valuesToInsert[i])
113
+ }
114
+ }
115
+ break
116
+ }
117
+
118
+ case DeepChangeType.ArrayUpdate: {
119
+ if (!(loroContainer instanceof LoroMovableList)) {
120
+ throw failure(`ArrayUpdate change requires a LoroMovableList container`)
121
+ }
122
+ const converted = convertValue(change.newValue)
123
+ if (
124
+ converted instanceof LoroMap ||
125
+ converted instanceof LoroMovableList ||
126
+ converted instanceof LoroText
127
+ ) {
128
+ loroContainer.setContainer(change.index, converted)
129
+ } else if (isLoroTextModelSnapshot(converted)) {
130
+ const attachedText = loroContainer.setContainer(change.index, new LoroText())
131
+ const deltas = extractTextDeltaFromSnapshot((converted as any).deltaList)
132
+ if (deltas.length > 0) {
133
+ applyDeltaToLoroText(attachedText, deltas)
134
+ }
135
+ } else {
136
+ loroContainer.set(change.index, converted)
137
+ }
138
+ break
139
+ }
140
+
141
+ case DeepChangeType.ObjectAdd:
142
+ case DeepChangeType.ObjectUpdate: {
143
+ if (loroContainer instanceof LoroText) {
144
+ // Handle changes to LoroText properties (mainly deltaList)
145
+ if (change.key === "deltaList") {
146
+ // change.newValue is already a snapshot (captured at change time)
147
+ const deltas = extractTextDeltaFromSnapshot(change.newValue)
148
+ if (loroContainer.length > 0) {
149
+ loroContainer.delete(0, loroContainer.length)
150
+ }
151
+ if (deltas.length > 0) {
152
+ applyDeltaToLoroText(loroContainer, deltas)
153
+ }
154
+ }
155
+ // ignore other property changes on LoroText as they're not synced
156
+ } else if (loroContainer instanceof LoroMap) {
157
+ const converted = convertValue(change.newValue)
158
+ setInMap(loroContainer, change.key, converted)
159
+ } else {
160
+ throw failure(`ObjectAdd/ObjectUpdate change requires a LoroMap or LoroText container`)
161
+ }
162
+ break
163
+ }
164
+
165
+ case DeepChangeType.ObjectRemove: {
166
+ if (loroContainer instanceof LoroText) {
167
+ // ignore removes on LoroText properties
168
+ } else if (loroContainer instanceof LoroMap) {
169
+ loroContainer.delete(change.key)
170
+ } else {
171
+ throw failure(`ObjectRemove change requires a LoroMap or LoroText container`)
172
+ }
173
+ break
174
+ }
175
+
176
+ default: {
177
+ // Exhaustive check - TypeScript will error if we miss a case
178
+ const _exhaustiveCheck: never = change
179
+ throw failure(`unsupported change type: ${(_exhaustiveCheck as any).type}`)
180
+ }
181
+ }
182
+ }