mobx-keystone-yjs 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.
@@ -0,0 +1,2 @@
1
+ export { MobxKeystoneYjsError } from "./utils/error";
2
+ export { bindYjsToMobxKeystone } from "./binding/bindYjsToMobxKeystone";
@@ -0,0 +1,7 @@
1
+ export type JSONPrimitive = string | number | boolean | null;
2
+ export type JSONValue = JSONPrimitive | JSONObject | JSONArray;
3
+ export type JSONObject = {
4
+ [key: string]: JSONValue;
5
+ };
6
+ export interface JSONArray extends Array<JSONValue> {
7
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A mobx-keystone-yjs error.
3
+ */
4
+ export declare class MobxKeystoneYjsError extends Error {
5
+ constructor(msg: string);
6
+ }
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "mobx-keystone-yjs",
3
+ "version": "1.0.0",
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
+ "import": "./dist/mobx-keystone-yjs.esm.mjs",
27
+ "require": "./dist/mobx-keystone-yjs.umd.js",
28
+ "script": "./dist/mobx-keystone-yjs.umd.js",
29
+ "default": "./dist/mobx-keystone-yjs.esm.mjs",
30
+ "types": "./dist/types/index.d.ts"
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
+ "lint": "cd ../.. && yarn eslint \"packages/mobx-keystone-yjs/src/**/*.ts\" \"packages/mobx-keystone-yjs/test/**/*.ts\""
60
+ },
61
+ "peerDependencies": {
62
+ "mobx": "^6.0.0 || ^5.0.0 || ^4.0.0",
63
+ "mobx-keystone": "^1.8.1",
64
+ "yjs": "^13.0.0"
65
+ },
66
+ "devDependencies": {
67
+ "@types/jest": "^29.5.11",
68
+ "@types/node": "^20.10.5",
69
+ "babel-jest": "^29.7.0",
70
+ "jest": "^29.7.0",
71
+ "mobx": "^6.12.0",
72
+ "mobx-keystone": "workspace:packages/lib",
73
+ "rollup-plugin-typescript2": "^0.36.0",
74
+ "shx": "^0.3.4",
75
+ "spec.ts": "^1.1.3",
76
+ "ts-jest": "^29.1.1",
77
+ "ts-node": "^10.9.2",
78
+ "typescript": "^5.3.3",
79
+ "vite": "^5.0.10"
80
+ },
81
+ "dependencies": {
82
+ "tslib": "^2.6.2"
83
+ },
84
+ "directories": {
85
+ "test": "test"
86
+ }
87
+ }
@@ -0,0 +1,92 @@
1
+ import { Patch } from "mobx-keystone"
2
+ import { failure } from "../utils/error"
3
+ import * as Y from "yjs"
4
+ import { toYDataType } from "./toYDataType"
5
+
6
+ export function applyMobxKeystonePatchToYjsObject(patch: Patch, yjs: unknown): void {
7
+ if (patch.path.length > 1) {
8
+ const [key, ...rest] = patch.path
9
+
10
+ if (yjs instanceof Y.Map) {
11
+ const child = yjs.get(String(key))
12
+ if (child === undefined) {
13
+ throw failure(
14
+ `invalid patch path, key "${key}" not found in Yjs map - patch: ${JSON.stringify(patch)}`
15
+ )
16
+ }
17
+ applyMobxKeystonePatchToYjsObject({ ...patch, path: rest }, child)
18
+ } else if (yjs instanceof Y.Array) {
19
+ const child = yjs.get(Number(key))
20
+ if (child === undefined) {
21
+ throw failure(
22
+ `invalid patch path, key "${key}" not found in Yjs array - patch: ${JSON.stringify(
23
+ patch
24
+ )}`
25
+ )
26
+ }
27
+ applyMobxKeystonePatchToYjsObject({ ...patch, path: rest }, child)
28
+ } else {
29
+ throw failure(
30
+ `invalid patch path, key "${key}" not found in unknown Yjs object - patch: ${JSON.stringify(
31
+ patch
32
+ )}`
33
+ )
34
+ }
35
+ } else if (patch.path.length === 1) {
36
+ if (yjs instanceof Y.Map) {
37
+ const key = String(patch.path[0])
38
+
39
+ switch (patch.op) {
40
+ case "add":
41
+ case "replace": {
42
+ yjs.set(key, toYDataType(patch.value))
43
+ break
44
+ }
45
+ case "remove": {
46
+ yjs.delete(key)
47
+ break
48
+ }
49
+ default: {
50
+ throw failure(`invalid patch operation for map`)
51
+ }
52
+ }
53
+ } else if (yjs instanceof Y.Array) {
54
+ const key = patch.path[0]
55
+
56
+ switch (patch.op) {
57
+ case "replace": {
58
+ if (key === "length") {
59
+ if (yjs.length > patch.value) {
60
+ const toDelete = yjs.length - patch.value
61
+ yjs.delete(patch.value, toDelete)
62
+ } else if (yjs.length < patch.value) {
63
+ const toInsert = patch.value - yjs.length
64
+ yjs.insert(yjs.length, Array(toInsert).fill(undefined))
65
+ }
66
+ } else {
67
+ yjs.delete(Number(key))
68
+ yjs.insert(Number(key), [toYDataType(patch.value)])
69
+ }
70
+ break
71
+ }
72
+ case "add": {
73
+ yjs.insert(Number(key), [toYDataType(patch.value)])
74
+ break
75
+ }
76
+ case "remove": {
77
+ yjs.delete(Number(key))
78
+ break
79
+ }
80
+ default: {
81
+ throw failure(`invalid patch operation for array`)
82
+ }
83
+ }
84
+ } else {
85
+ throw failure(
86
+ `invalid patch path, the Yjs object is of an unkown type, so key "${patch.path[0]}" cannot be found in it`
87
+ )
88
+ }
89
+ } else {
90
+ throw failure(`invalid patch path, it cannot be empty`)
91
+ }
92
+ }
@@ -0,0 +1,142 @@
1
+ import {
2
+ AnyDataModel,
3
+ AnyModel,
4
+ AnyStandardType,
5
+ ModelClass,
6
+ Patch,
7
+ TypeToData,
8
+ applyPatches,
9
+ fromSnapshot,
10
+ getParentToChildPath,
11
+ onGlobalPatches,
12
+ onPatches,
13
+ onSnapshot,
14
+ } from "mobx-keystone"
15
+ import * as Y from "yjs"
16
+ import { applyMobxKeystonePatchToYjsObject } from "./applyMobxKeystonePatchToYjsObject"
17
+ import { convertYjsEventToPatches } from "./convertYjsEventToPatches"
18
+
19
+ export function bindYjsToMobxKeystone<
20
+ TType extends AnyStandardType | ModelClass<AnyModel> | ModelClass<AnyDataModel>,
21
+ >({
22
+ yjsDoc,
23
+ yjsObject,
24
+ mobxKeystoneType,
25
+ }: {
26
+ yjsDoc: Y.Doc
27
+ yjsObject: Y.Map<unknown> | Y.Array<unknown>
28
+ mobxKeystoneType: TType
29
+ }): {
30
+ boundObject: TypeToData<TType>
31
+ dispose(): void
32
+ yjsOrigin: symbol
33
+ } {
34
+ const yjsJson = yjsObject.toJSON()
35
+
36
+ const initializationGlobalPatches: { target: object; patches: Patch[] }[] = []
37
+
38
+ const createBoundObject = () => {
39
+ const disposeOnGlobalPatches = onGlobalPatches((target, patches) => {
40
+ initializationGlobalPatches.push({ target, patches })
41
+ })
42
+
43
+ try {
44
+ return fromSnapshot(mobxKeystoneType, yjsJson as any)
45
+ } finally {
46
+ disposeOnGlobalPatches()
47
+ }
48
+ }
49
+
50
+ const boundObject = createBoundObject()
51
+
52
+ let applyingMobxKeystoneChanges = 0
53
+ const yjsOrigin = Symbol("bindYjsToMobxKeystoneTransactionOrigin")
54
+
55
+ // bind any changes from yjs to mobx-keystone
56
+ const observeDeepCb = (events: Y.YEvent<any>[]) => {
57
+ const patches: Patch[] = []
58
+ events.forEach((event) => {
59
+ if (event.transaction.origin !== yjsOrigin) {
60
+ patches.push(...convertYjsEventToPatches(event))
61
+ }
62
+ })
63
+
64
+ if (patches.length > 0) {
65
+ applyingMobxKeystoneChanges++
66
+ try {
67
+ applyPatches(boundObject, patches)
68
+ } finally {
69
+ applyingMobxKeystoneChanges--
70
+ }
71
+ }
72
+ }
73
+
74
+ yjsObject.observeDeep(observeDeepCb)
75
+
76
+ // bind any changes from mobx-keystone to yjs
77
+ let pendingPatches: Patch[] = []
78
+ const disposeOnPatches = onPatches(boundObject, (patches) => {
79
+ if (applyingMobxKeystoneChanges > 0) {
80
+ return
81
+ }
82
+
83
+ pendingPatches.push(...patches)
84
+ })
85
+
86
+ // this is only used so we can transact all patches to the snapshot boundary
87
+ const disposeOnSnapshot = onSnapshot(boundObject, () => {
88
+ if (pendingPatches.length === 0) {
89
+ return
90
+ }
91
+
92
+ const patches = pendingPatches
93
+ pendingPatches = []
94
+
95
+ yjsDoc.transact(() => {
96
+ patches.forEach((patch) => {
97
+ applyMobxKeystonePatchToYjsObject(patch, yjsObject)
98
+ })
99
+ }, yjsOrigin)
100
+ })
101
+
102
+ // sync initial patches, that might include setting defaults, IDs, etc
103
+ yjsDoc.transact(() => {
104
+ // we need to skip initializations until we hit the initialization of the bound object
105
+ // this is because default objects might be created and initialized before the main object
106
+ // but we just need to catch when those are actually assigned to the bound object
107
+ let boundObjectFound = false
108
+
109
+ initializationGlobalPatches.forEach(({ target, patches }) => {
110
+ if (!boundObjectFound) {
111
+ if (target !== boundObject) {
112
+ return // skip
113
+ }
114
+ boundObjectFound = true
115
+ }
116
+
117
+ const parentToChildPath = getParentToChildPath(boundObject, target)
118
+ // this is undefined only if target is not a child of boundModel
119
+ if (parentToChildPath !== undefined) {
120
+ patches.forEach((patch) => {
121
+ applyMobxKeystonePatchToYjsObject(
122
+ {
123
+ ...patch,
124
+ path: [...parentToChildPath, ...patch.path],
125
+ },
126
+ yjsObject
127
+ )
128
+ })
129
+ }
130
+ })
131
+ }, yjsOrigin)
132
+
133
+ return {
134
+ boundObject,
135
+ dispose: () => {
136
+ disposeOnPatches()
137
+ disposeOnSnapshot()
138
+ yjsObject.unobserveDeep(observeDeepCb)
139
+ },
140
+ yjsOrigin,
141
+ }
142
+ }
@@ -0,0 +1,85 @@
1
+ import { Patch } from "mobx-keystone"
2
+ import * as Y from "yjs"
3
+ import { JSONArray, JSONObject, JSONValue } from "../jsonTypes"
4
+ import { failure } from "../utils/error"
5
+
6
+ export function convertYjsEventToPatches(event: Y.YEvent<any>): Patch[] {
7
+ const patches: Patch[] = []
8
+
9
+ if (event instanceof Y.YMapEvent) {
10
+ const source = event.target as Y.Map<any>
11
+
12
+ event.changes.keys.forEach((change, key) => {
13
+ const path = [...event.path, key]
14
+
15
+ switch (change.action) {
16
+ case "add":
17
+ patches.push({
18
+ op: "add",
19
+ path,
20
+ value: toPlainValue(source.get(key)),
21
+ })
22
+ break
23
+
24
+ case "update":
25
+ patches.push({
26
+ op: "replace",
27
+ path,
28
+ value: toPlainValue(source.get(key)),
29
+ })
30
+ break
31
+
32
+ case "delete":
33
+ patches.push({
34
+ op: "remove",
35
+ path,
36
+ })
37
+ break
38
+
39
+ default:
40
+ throw failure(`unsupported Yjs map event action: ${change.action}`)
41
+ }
42
+ })
43
+ } else if (event instanceof Y.YArrayEvent) {
44
+ let retain = 0
45
+ event.changes.delta.forEach((change) => {
46
+ if (change.retain) {
47
+ retain += change.retain
48
+ }
49
+
50
+ if (change.delete) {
51
+ // remove X items at retain position
52
+ const path = [...event.path, retain]
53
+ for (let i = 0; i < change.delete; i++) {
54
+ patches.push({
55
+ op: "remove",
56
+ path,
57
+ })
58
+ }
59
+ }
60
+
61
+ if (change.insert) {
62
+ const newValues = Array.isArray(change.insert) ? change.insert : [change.insert]
63
+ newValues.forEach((v) => {
64
+ const path = [...event.path, retain]
65
+ patches.push({
66
+ op: "add",
67
+ path,
68
+ value: toPlainValue(v),
69
+ })
70
+ retain++
71
+ })
72
+ }
73
+ })
74
+ }
75
+
76
+ return patches
77
+ }
78
+
79
+ function toPlainValue(v: Y.Map<any> | Y.Array<any> | JSONValue) {
80
+ if (v instanceof Y.Map || v instanceof Y.Array) {
81
+ return v.toJSON() as JSONObject | JSONArray
82
+ } else {
83
+ return v
84
+ }
85
+ }
@@ -0,0 +1,41 @@
1
+ import * as Y from "yjs"
2
+ import { JSONValue, JSONArray, JSONObject, JSONPrimitive } from "../jsonTypes"
3
+
4
+ function isJSONPrimitive(v: JSONValue): v is JSONPrimitive {
5
+ const t = typeof v
6
+ return t === "string" || t === "number" || t === "boolean" || v === null
7
+ }
8
+
9
+ function isJSONArray(v: JSONValue): v is JSONArray {
10
+ return Array.isArray(v)
11
+ }
12
+
13
+ function isJSONObject(v: JSONValue): v is JSONObject {
14
+ return !isJSONArray(v) && typeof v === "object"
15
+ }
16
+
17
+ export function toYDataType(v: JSONValue) {
18
+ if (isJSONPrimitive(v)) {
19
+ return v
20
+ } else if (isJSONArray(v)) {
21
+ const arr = new Y.Array()
22
+ applyJsonArray(arr, v)
23
+ return arr
24
+ } else if (isJSONObject(v)) {
25
+ const map = new Y.Map()
26
+ applyJsonObject(map, v)
27
+ return map
28
+ } else {
29
+ return undefined
30
+ }
31
+ }
32
+
33
+ function applyJsonArray(dest: Y.Array<unknown>, source: JSONArray) {
34
+ dest.push(source.map(toYDataType))
35
+ }
36
+
37
+ function applyJsonObject(dest: Y.Map<unknown>, source: JSONObject) {
38
+ Object.entries(source).forEach(([k, v]) => {
39
+ dest.set(k, toYDataType(v))
40
+ })
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { MobxKeystoneYjsError } from "./utils/error"
2
+ export { bindYjsToMobxKeystone } from "./binding/bindYjsToMobxKeystone"
@@ -0,0 +1,4 @@
1
+ export type JSONPrimitive = string | number | boolean | null
2
+ export type JSONValue = JSONPrimitive | JSONObject | JSONArray
3
+ export type JSONObject = { [key: string]: JSONValue }
4
+ export interface JSONArray extends Array<JSONValue> {}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A mobx-keystone-yjs error.
3
+ */
4
+ export class MobxKeystoneYjsError extends Error {
5
+ constructor(msg: string) {
6
+ super(msg)
7
+
8
+ // Set the prototype explicitly.
9
+ Object.setPrototypeOf(this, MobxKeystoneYjsError.prototype)
10
+ }
11
+ }
12
+
13
+ /**
14
+ * @internal
15
+ */
16
+ export function failure(msg: string) {
17
+ return new MobxKeystoneYjsError(msg)
18
+ }