mobx-keystone-yjs 1.5.5 → 1.6.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.
@@ -0,0 +1,3 @@
1
+ import { DeepChange } from 'mobx-keystone';
2
+ import * as Y from "yjs";
3
+ export declare function applyMobxChangeToYjsObject(change: DeepChange, yjsObject: Y.Map<any> | Y.Array<any> | Y.Text): void;
@@ -0,0 +1,8 @@
1
+ import * as Y from "yjs";
2
+ export type ReconciliationMap = Map<string, object>;
3
+ /**
4
+ * Applies a Y.js event directly to the MobX model tree using proper mutations
5
+ * (splice for arrays, property assignment for objects).
6
+ * This is more efficient than converting to patches first.
7
+ */
8
+ export declare function applyYjsEventToMobx(event: Y.YEvent<any>, boundObject: object, reconciliationMap: ReconciliationMap): void;
@@ -1,6 +1,17 @@
1
- import { YjsData } from './convertYjsDataToJson';
2
1
  import { PlainArray, PlainObject, PlainValue } from '../plainTypes';
2
+ import { YjsData } from './convertYjsDataToJson';
3
3
  import * as Y from "yjs";
4
+ /**
5
+ * Options for applying JSON data to Y.js data structures.
6
+ */
7
+ export interface ApplyJsonToYjsOptions {
8
+ /**
9
+ * The mode to use when applying JSON data to Y.js data structures.
10
+ * - `add`: Creates new Y.js containers for objects/arrays (default, backwards compatible)
11
+ * - `merge`: Recursively merges values, preserving existing container references where possible
12
+ */
13
+ mode?: "add" | "merge";
14
+ }
4
15
  /**
5
16
  * Converts a plain value to a Y.js data structure.
6
17
  * Objects are converted to Y.Maps, arrays to Y.Arrays, primitives are untouched.
@@ -9,9 +20,17 @@ import * as Y from "yjs";
9
20
  export declare function convertJsonToYjsData(v: PlainValue): YjsData;
10
21
  /**
11
22
  * Applies a JSON array to a Y.Array, using the convertJsonToYjsData to convert the values.
23
+ *
24
+ * @param dest The destination Y.Array.
25
+ * @param source The source JSON array.
26
+ * @param options Options for applying the JSON data.
12
27
  */
13
- export declare const applyJsonArrayToYArray: (dest: Y.Array<any>, source: PlainArray) => void;
28
+ export declare const applyJsonArrayToYArray: (dest: Y.Array<any>, source: PlainArray, options?: ApplyJsonToYjsOptions) => void;
14
29
  /**
15
30
  * Applies a JSON object to a Y.Map, using the convertJsonToYjsData to convert the values.
31
+ *
32
+ * @param dest The destination Y.Map.
33
+ * @param source The source JSON object.
34
+ * @param options Options for applying the JSON data.
16
35
  */
17
- export declare const applyJsonObjectToYMap: (dest: Y.Map<any>, source: PlainObject) => void;
36
+ export declare const applyJsonObjectToYMap: (dest: Y.Map<any>, source: PlainObject, options?: ApplyJsonToYjsOptions) => void;
@@ -1 +1,14 @@
1
- export declare function resolveYjsPath(yjsObject: unknown, path: readonly (string | number)[]): unknown;
1
+ import * as Y from "yjs";
2
+ /**
3
+ * Resolves a path within a Yjs object structure.
4
+ * Returns the Yjs container at the specified path.
5
+ *
6
+ * When a Y.Text is encountered during path resolution (either at the start
7
+ * or mid-path), it is returned immediately since Y.Text doesn't support
8
+ * nested path traversal.
9
+ *
10
+ * @param yjsObject The root Yjs object
11
+ * @param path Array of keys/indices to traverse
12
+ * @returns The Yjs container at the path, or Y.Text if encountered during traversal
13
+ */
14
+ export declare function resolveYjsPath(yjsObject: Y.Map<unknown> | Y.Array<unknown> | Y.Text, path: readonly (string | number)[]): unknown;
@@ -0,0 +1,24 @@
1
+ import * as Y from "yjs";
2
+ /**
3
+ * WeakMap that tracks which snapshot each Y.js container was last synced from.
4
+ * This is used during reconciliation to skip containers that are already up-to-date.
5
+ *
6
+ * The key is the Y.js container (Y.Map or Y.Array).
7
+ * The value is the snapshot (plain object or array) that was last synced to it.
8
+ */
9
+ export declare const yjsContainerToSnapshot: WeakMap<Y.Map<any> | Y.Array<any>, unknown>;
10
+ /**
11
+ * Updates the snapshot tracking for a Y.js container.
12
+ * Call this after syncing a snapshot to a Y.js container.
13
+ */
14
+ export declare function setYjsContainerSnapshot(container: Y.Map<any> | Y.Array<any>, snapshot: unknown): void;
15
+ /**
16
+ * Gets the last synced snapshot for a Y.js container.
17
+ * Returns undefined if the container has never been synced.
18
+ */
19
+ export declare function getYjsContainerSnapshot(container: Y.Map<any> | Y.Array<any>): unknown;
20
+ /**
21
+ * Checks if a Y.js container is up-to-date with the given snapshot.
22
+ * Uses reference equality to check if the snapshot is the same.
23
+ */
24
+ export declare function isYjsContainerUpToDate(container: Y.Map<any> | Y.Array<any>, snapshot: unknown): boolean;
@@ -1,4 +1,5 @@
1
1
  export { bindYjsToMobxKeystone } from './binding/bindYjsToMobxKeystone';
2
+ export type { ApplyJsonToYjsOptions } from './binding/convertJsonToYjsData';
2
3
  export { applyJsonArrayToYArray, applyJsonObjectToYMap, convertJsonToYjsData, } from './binding/convertJsonToYjsData';
3
4
  export { YjsTextModel, yjsTextModelId } from './binding/YjsTextModel';
4
5
  export type { YjsBindingContext } from './binding/yjsBindingContext';
package/package.json CHANGED
@@ -1,88 +1,90 @@
1
- {
2
- "name": "mobx-keystone-yjs",
3
- "version": "1.5.5",
4
- "description": "Yjs bindings for mobx-keystone",
5
- "keywords": [
6
- "mobx",
7
- "mobx-keystone",
8
- "yjs",
9
- "crdt",
10
- "state management"
11
- ],
12
- "repository": {
13
- "type": "git",
14
- "url": "https://github.com/xaviergonz/mobx-keystone.git"
15
- },
16
- "bugs": {
17
- "url": "https://github.com/xaviergonz/mobx-keystone/issues"
18
- },
19
- "homepage": "https://mobx-keystone.js.org",
20
- "license": "MIT",
21
- "author": "Javier González Garcés",
22
- "source": "./src/index.ts",
23
- "exports": {
24
- "./package.json": "./package.json",
25
- ".": {
26
- "types": "./dist/types/index.d.ts",
27
- "script": "./dist/mobx-keystone-yjs.umd.js",
28
- "import": "./dist/mobx-keystone-yjs.esm.mjs",
29
- "require": "./dist/mobx-keystone-yjs.umd.js",
30
- "default": "./dist/mobx-keystone-yjs.esm.mjs"
31
- }
32
- },
33
- "esmodule": "./dist/mobx-keystone-yjs.esm.js",
34
- "module": "./dist/mobx-keystone-yjs.esm.js",
35
- "jsnext:main": "./dist/mobx-keystone-yjs.esm.js",
36
- "react-native": "./dist/mobx-keystone-yjs.umd.js",
37
- "umd:main": "./dist/mobx-keystone-yjs.umd.js",
38
- "unpkg": "./dist/mobx-keystone-yjs.umd.js",
39
- "jsdelivr": "./dist/mobx-keystone-yjs.umd.js",
40
- "main": "./dist/mobx-keystone-yjs.umd.js",
41
- "types": "./dist/types/index.d.ts",
42
- "typings": "./dist/types/index.d.ts",
43
- "sideEffects": false,
44
- "files": [
45
- "src",
46
- "dist",
47
- "LICENSE",
48
- "CHANGELOG.md",
49
- "README.md"
50
- ],
51
- "scripts": {
52
- "quick-build": "tsc",
53
- "quick-build-tests": "tsc -p test",
54
- "copy-root-files": "shx cp ../../LICENSE .",
55
- "build": "yarn quick-build && yarn copy-root-files && shx rm -rf dist && vite build && shx cp dist/mobx-keystone-yjs.esm.mjs dist/mobx-keystone-yjs.esm.js",
56
- "test": "jest",
57
- "test:ci": "yarn test -i",
58
- "test:perf": "yarn build && yarn test:perf:run"
59
- },
60
- "peerDependencies": {
61
- "mobx": "^6.0.0 || ^5.0.0 || ^4.0.0",
62
- "mobx-keystone": "^1.9.0",
63
- "yjs": "^13.0.0"
64
- },
65
- "devDependencies": {
66
- "@babel/core": "^7.28.5",
67
- "@babel/plugin-proposal-class-properties": "^7.18.6",
68
- "@babel/plugin-proposal-decorators": "^7.28.0",
69
- "@babel/preset-env": "^7.28.5",
70
- "@babel/preset-typescript": "^7.28.5",
71
- "@types/jest": "^30.0.0",
72
- "@types/node": "^25.0.3",
73
- "babel-jest": "^30.2.0",
74
- "jest": "^30.2.0",
75
- "mobx-keystone": "workspace:packages/lib",
76
- "shx": "^0.4.0",
77
- "spec.ts": "^1.1.3",
78
- "ts-jest": "^29.4.6",
79
- "ts-node": "^10.9.2",
80
- "typescript": "^5.9.3"
81
- },
82
- "dependencies": {
83
- "tslib": "^2.8.1"
84
- },
85
- "directories": {
86
- "test": "test"
87
- }
88
- }
1
+ {
2
+ "name": "mobx-keystone-yjs",
3
+ "version": "1.6.0",
4
+ "description": "Yjs bindings for mobx-keystone",
5
+ "keywords": [
6
+ "mobx",
7
+ "mobx-keystone",
8
+ "yjs",
9
+ "crdt",
10
+ "state",
11
+ "state-management",
12
+ "reactive",
13
+ "collaborative"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/xaviergonz/mobx-keystone.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/xaviergonz/mobx-keystone/issues"
21
+ },
22
+ "homepage": "https://mobx-keystone.js.org",
23
+ "license": "MIT",
24
+ "author": "Javier González Garcés",
25
+ "source": "./src/index.ts",
26
+ "exports": {
27
+ "./package.json": "./package.json",
28
+ ".": {
29
+ "types": "./dist/types/index.d.ts",
30
+ "script": "./dist/mobx-keystone-yjs.umd.js",
31
+ "import": "./dist/mobx-keystone-yjs.esm.mjs",
32
+ "require": "./dist/mobx-keystone-yjs.umd.js",
33
+ "default": "./dist/mobx-keystone-yjs.esm.mjs"
34
+ }
35
+ },
36
+ "esmodule": "./dist/mobx-keystone-yjs.esm.js",
37
+ "module": "./dist/mobx-keystone-yjs.esm.js",
38
+ "jsnext:main": "./dist/mobx-keystone-yjs.esm.js",
39
+ "react-native": "./dist/mobx-keystone-yjs.umd.js",
40
+ "umd:main": "./dist/mobx-keystone-yjs.umd.js",
41
+ "unpkg": "./dist/mobx-keystone-yjs.umd.js",
42
+ "jsdelivr": "./dist/mobx-keystone-yjs.umd.js",
43
+ "main": "./dist/mobx-keystone-yjs.umd.js",
44
+ "types": "./dist/types/index.d.ts",
45
+ "typings": "./dist/types/index.d.ts",
46
+ "sideEffects": false,
47
+ "files": [
48
+ "src",
49
+ "dist",
50
+ "LICENSE",
51
+ "CHANGELOG.md",
52
+ "README.md"
53
+ ],
54
+ "scripts": {
55
+ "quick-build": "tsc",
56
+ "quick-build-tests": "tsc -p test",
57
+ "copy-root-files": "shx cp ../../LICENSE .",
58
+ "build": "yarn quick-build && yarn copy-root-files && shx rm -rf dist && vite build && shx cp dist/mobx-keystone-yjs.esm.mjs dist/mobx-keystone-yjs.esm.js",
59
+ "test": "jest",
60
+ "test:ci": "yarn test -i"
61
+ },
62
+ "peerDependencies": {
63
+ "mobx": "^6.0.0 || ^5.0.0 || ^4.0.0",
64
+ "mobx-keystone": "^1.12.0",
65
+ "yjs": "^13.0.0"
66
+ },
67
+ "devDependencies": {
68
+ "@babel/core": "^7.28.5",
69
+ "@babel/plugin-proposal-class-properties": "^7.18.6",
70
+ "@babel/plugin-proposal-decorators": "^7.28.0",
71
+ "@babel/preset-env": "^7.28.5",
72
+ "@babel/preset-typescript": "^7.28.5",
73
+ "@types/jest": "^30.0.0",
74
+ "@types/node": "^25.0.3",
75
+ "babel-jest": "^30.2.0",
76
+ "jest": "^30.2.0",
77
+ "mobx-keystone": "workspace:packages/lib",
78
+ "shx": "^0.4.0",
79
+ "spec.ts": "^1.1.3",
80
+ "ts-jest": "^29.4.6",
81
+ "ts-node": "^10.9.2",
82
+ "typescript": "^5.9.3"
83
+ },
84
+ "dependencies": {
85
+ "tslib": "^2.8.1"
86
+ },
87
+ "directories": {
88
+ "test": "test"
89
+ }
90
+ }
@@ -0,0 +1,77 @@
1
+ import { DeepChange, DeepChangeType } from "mobx-keystone"
2
+ import * as Y from "yjs"
3
+ import { failure } from "../utils/error"
4
+ import { isYjsValueDeleted } from "../utils/isYjsValueDeleted"
5
+ import { convertJsonToYjsData } from "./convertJsonToYjsData"
6
+ import { resolveYjsPath } from "./resolveYjsPath"
7
+
8
+ /**
9
+ * Converts a snapshot value to a Yjs-compatible value.
10
+ * Note: All values passed here are already snapshots (captured at change time).
11
+ */
12
+ function convertValue(v: unknown): any {
13
+ // Handle primitives directly
14
+ if (v === null || v === undefined || typeof v !== "object") {
15
+ return v
16
+ }
17
+ // Handle plain arrays - used for empty array init
18
+ if (Array.isArray(v) && v.length === 0) {
19
+ return new Y.Array()
20
+ }
21
+ // Value is already a snapshot, convert to Yjs data
22
+ return convertJsonToYjsData(v as any)
23
+ }
24
+
25
+ export function applyMobxChangeToYjsObject(
26
+ change: DeepChange,
27
+ yjsObject: Y.Map<any> | Y.Array<any> | Y.Text
28
+ ): void {
29
+ // Check if the YJS object is deleted
30
+ if (isYjsValueDeleted(yjsObject)) {
31
+ throw failure("cannot apply patch to deleted Yjs value")
32
+ }
33
+
34
+ const yjsContainer = resolveYjsPath(yjsObject, change.path)
35
+
36
+ if (!yjsContainer) {
37
+ // Container not found, skip this change
38
+ return
39
+ }
40
+
41
+ if (yjsContainer instanceof Y.Array) {
42
+ if (change.type === DeepChangeType.ArraySplice) {
43
+ // splice
44
+ yjsContainer.delete(change.index, change.removedValues.length)
45
+ if (change.addedValues.length > 0) {
46
+ const valuesToInsert = change.addedValues.map(convertValue)
47
+ yjsContainer.insert(change.index, valuesToInsert)
48
+ }
49
+ } else if (change.type === DeepChangeType.ArrayUpdate) {
50
+ // update
51
+ yjsContainer.delete(change.index, 1)
52
+ yjsContainer.insert(change.index, [convertValue(change.newValue)])
53
+ } else {
54
+ throw failure(`unsupported array change type: ${change.type}`)
55
+ }
56
+ } else if (yjsContainer instanceof Y.Map) {
57
+ if (change.type === DeepChangeType.ObjectAdd || change.type === DeepChangeType.ObjectUpdate) {
58
+ const key = change.key
59
+ if (change.newValue === undefined) {
60
+ yjsContainer.delete(key)
61
+ } else {
62
+ yjsContainer.set(key, convertValue(change.newValue))
63
+ }
64
+ } else if (change.type === DeepChangeType.ObjectRemove) {
65
+ const key = change.key
66
+ yjsContainer.delete(key)
67
+ } else {
68
+ throw failure(`unsupported object change type: ${change.type}`)
69
+ }
70
+ } else if (yjsContainer instanceof Y.Text) {
71
+ // Y.Text is handled differently - init changes for text are managed by YjsTextModel
72
+ // Skip init changes for Y.Text containers
73
+ return
74
+ } else {
75
+ throw failure(`unsupported Yjs container type: ${yjsContainer}`)
76
+ }
77
+ }
@@ -0,0 +1,173 @@
1
+ import { remove } from "mobx"
2
+ import {
3
+ Frozen,
4
+ fromSnapshot,
5
+ frozen,
6
+ getSnapshot,
7
+ getSnapshotModelId,
8
+ isFrozenSnapshot,
9
+ isModel,
10
+ Path,
11
+ resolvePath,
12
+ runUnprotected,
13
+ } from "mobx-keystone"
14
+ import * as Y from "yjs"
15
+ import { failure } from "../utils/error"
16
+ import { convertYjsDataToJson } from "./convertYjsDataToJson"
17
+
18
+ // Represents the map of potential objects to reconcile (ID -> Object)
19
+ export type ReconciliationMap = Map<string, object>
20
+
21
+ /**
22
+ * Applies a Y.js event directly to the MobX model tree using proper mutations
23
+ * (splice for arrays, property assignment for objects).
24
+ * This is more efficient than converting to patches first.
25
+ */
26
+ export function applyYjsEventToMobx(
27
+ event: Y.YEvent<any>,
28
+ boundObject: object,
29
+ reconciliationMap: ReconciliationMap
30
+ ): void {
31
+ const path = event.path as Path
32
+ const { value: target } = resolvePath(boundObject, path)
33
+
34
+ if (!target) {
35
+ throw failure(`cannot resolve path ${JSON.stringify(path)}`)
36
+ }
37
+
38
+ // Wrap in runUnprotected since we're modifying the tree from outside a model action
39
+ runUnprotected(() => {
40
+ if (event instanceof Y.YMapEvent) {
41
+ applyYMapEventToMobx(event, target, reconciliationMap)
42
+ } else if (event instanceof Y.YArrayEvent) {
43
+ applyYArrayEventToMobx(event, target, reconciliationMap)
44
+ } else if (event instanceof Y.YTextEvent) {
45
+ applyYTextEventToMobx(event, target)
46
+ }
47
+ })
48
+ }
49
+
50
+ function processDeletedValue(val: unknown, reconciliationMap: ReconciliationMap) {
51
+ if (val && typeof val === "object" && isModel(val)) {
52
+ const sn = getSnapshot(val)
53
+ const id = getSnapshotModelId(sn)
54
+ if (id) {
55
+ reconciliationMap.set(id, val)
56
+ }
57
+ }
58
+ }
59
+
60
+ function reviveValue(jsonValue: any, reconciliationMap: ReconciliationMap): any {
61
+ // Handle primitives
62
+ if (jsonValue === null || typeof jsonValue !== "object") {
63
+ return jsonValue
64
+ }
65
+
66
+ // Handle frozen
67
+ if (isFrozenSnapshot(jsonValue)) {
68
+ return frozen(jsonValue.data)
69
+ }
70
+
71
+ // If we have a reconciliation map and the value looks like a model with an ID, check if we have it
72
+ if (reconciliationMap && jsonValue && typeof jsonValue === "object") {
73
+ const modelId = getSnapshotModelId(jsonValue)
74
+ if (modelId) {
75
+ const existing = reconciliationMap.get(modelId)
76
+ if (existing) {
77
+ reconciliationMap.delete(modelId)
78
+ return existing
79
+ }
80
+ }
81
+ }
82
+
83
+ return fromSnapshot(jsonValue)
84
+ }
85
+
86
+ function applyYMapEventToMobx(
87
+ event: Y.YMapEvent<any>,
88
+ target: Record<string, any>,
89
+ reconciliationMap: ReconciliationMap
90
+ ): void {
91
+ const source = event.target
92
+
93
+ event.changes.keys.forEach((change, key) => {
94
+ switch (change.action) {
95
+ case "add":
96
+ case "update": {
97
+ const yjsValue = source.get(key)
98
+ const jsonValue = convertYjsDataToJson(yjsValue)
99
+
100
+ // If updating, the old value is overwritten (deleted conceptually)
101
+ if (change.action === "update") {
102
+ processDeletedValue(target[key], reconciliationMap)
103
+ }
104
+
105
+ target[key] = reviveValue(jsonValue, reconciliationMap)
106
+ break
107
+ }
108
+
109
+ case "delete": {
110
+ processDeletedValue(target[key], reconciliationMap)
111
+ // Use MobX's remove to properly delete the key from the observable object
112
+ // This triggers the "remove" interceptor in mobx-keystone's tweaker
113
+ if (isModel(target)) {
114
+ remove(target.$, key)
115
+ } else {
116
+ remove(target, key)
117
+ }
118
+ break
119
+ }
120
+
121
+ default:
122
+ throw failure(`unsupported Yjs map event action: ${change.action}`)
123
+ }
124
+ })
125
+ }
126
+
127
+ function applyYArrayEventToMobx(
128
+ event: Y.YArrayEvent<any>,
129
+ target: any[],
130
+ reconciliationMap: ReconciliationMap
131
+ ): void {
132
+ // Process delta operations in order
133
+ let currentIndex = 0
134
+
135
+ for (const change of event.changes.delta) {
136
+ if (change.retain) {
137
+ currentIndex += change.retain
138
+ }
139
+
140
+ if (change.delete) {
141
+ // Capture deleted items for reconciliation
142
+ const deletedItems = target.slice(currentIndex, currentIndex + change.delete)
143
+ deletedItems.forEach((item) => {
144
+ processDeletedValue(item, reconciliationMap)
145
+ })
146
+
147
+ // Delete items at current position
148
+ target.splice(currentIndex, change.delete)
149
+ }
150
+
151
+ if (change.insert) {
152
+ // Insert items at current position
153
+ const insertedItems = Array.isArray(change.insert) ? change.insert : [change.insert]
154
+ const values = insertedItems.map((yjsValue) => {
155
+ const jsonValue = convertYjsDataToJson(yjsValue)
156
+ return reviveValue(jsonValue, reconciliationMap)
157
+ })
158
+
159
+ target.splice(currentIndex, 0, ...values)
160
+ currentIndex += values.length
161
+ }
162
+ }
163
+ }
164
+
165
+ function applyYTextEventToMobx(
166
+ event: Y.YTextEvent,
167
+ target: { deltaList?: Frozen<unknown[]>[] }
168
+ ): void {
169
+ // YjsTextModel handles text events by appending delta to deltaList
170
+ if (target?.deltaList) {
171
+ target.deltaList.push(frozen(event.delta))
172
+ }
173
+ }