├── .editorconfig ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .netlify └── state.json ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── .yarn └── releases │ └── yarn-4.9.1.cjs ├── .yarnrc.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── TODO.md ├── apps ├── benchmark │ ├── .gitignore │ ├── package.json │ ├── src │ │ ├── bench.ts │ │ ├── index.ts │ │ ├── models │ │ │ ├── es6.ts │ │ │ ├── ks-nonTypeChecked.ts │ │ │ ├── ks-typeChecked.ts │ │ │ ├── mobx.ts │ │ │ └── mst.ts │ │ └── sleep.ts │ └── tsconfig.json └── site │ ├── .gitignore │ ├── babel.config.js │ ├── docs │ ├── actionMiddlewares │ │ ├── customMiddlewares.mdx │ │ ├── onActionMiddleware.mdx │ │ ├── readonlyMiddleware.mdx │ │ ├── transactionMiddleware.mdx │ │ └── undoMiddleware.mdx │ ├── classModels.mdx │ ├── computedTrees.mdx │ ├── contexts.mdx │ ├── dataModels.mdx │ ├── drafts.mdx │ ├── examples │ │ ├── clientServer │ │ │ ├── app.tsx │ │ │ ├── appInstance.tsx │ │ │ ├── clientServer.mdx │ │ │ └── server.ts │ │ ├── todoList │ │ │ ├── app.tsx │ │ │ ├── logs.tsx │ │ │ ├── store.ts │ │ │ └── todoList.mdx │ │ └── yjsBinding │ │ │ ├── app.tsx │ │ │ ├── appInstance.tsx │ │ │ └── yjsBinding.mdx │ ├── frozen.mdx │ ├── installation.mdx │ ├── integrations │ │ ├── reduxCompatibility.mdx │ │ └── yjsBinding.mdx │ ├── intro.mdx │ ├── mapsSetsDates.mdx │ ├── mstComparison.mdx │ ├── patches.mdx │ ├── references.mdx │ ├── rootStores.mdx │ ├── runtimeTypeChecking.mdx │ ├── sandboxes.mdx │ ├── snapshots.mdx │ ├── standardAndStandaloneActions.mdx │ └── treeLikeStructure.mdx │ ├── docusaurus.config.ts │ ├── package.json │ ├── sidebars.ts │ ├── src │ ├── components │ │ ├── ArrowUpIcon.tsx │ │ ├── CheckIcon.tsx │ │ └── RedXIcon.tsx │ ├── css │ │ └── custom.css │ ├── pages │ │ └── example-apps │ │ │ └── yjs-binding.tsx │ └── types.d.ts │ ├── static │ ├── .nojekyll │ └── img │ │ ├── favicon.ico │ │ ├── logo.png │ │ └── logo.svg │ └── tsconfig.json ├── biome.json ├── netlify.toml ├── package.json ├── packages ├── lib │ ├── .gitignore │ ├── babel.config.js │ ├── env.js │ ├── env.ts │ ├── jest.config.ts │ ├── package.json │ ├── perf_bench │ │ ├── fixtures │ │ │ ├── fixture-data.ts │ │ │ └── fixture-models.ts │ │ ├── memtest.ts │ │ ├── report.ts │ │ ├── scenarios.ts │ │ ├── timer.ts │ │ └── tsconfig.json │ ├── src │ │ ├── action │ │ │ ├── applyAction.ts │ │ │ ├── applyDelete.ts │ │ │ ├── applyMethodCall.ts │ │ │ ├── applySet.ts │ │ │ ├── builtInActions.ts │ │ │ ├── context.ts │ │ │ ├── hookActions.ts │ │ │ ├── index.ts │ │ │ ├── isModelAction.ts │ │ │ ├── middleware.ts │ │ │ ├── modelAction.ts │ │ │ ├── modelFlow.ts │ │ │ ├── modelFlowPromiseGenerator.ts │ │ │ ├── pendingActions.ts │ │ │ ├── protection.ts │ │ │ ├── runUnprotected.ts │ │ │ └── wrapInAction.ts │ │ ├── actionMiddlewares │ │ │ ├── actionSerialization │ │ │ │ ├── actionSerialization.ts │ │ │ │ ├── applySerializedAction.ts │ │ │ │ ├── arraySerializer.ts │ │ │ │ ├── core.ts │ │ │ │ ├── dateSerializer.ts │ │ │ │ ├── index.ts │ │ │ │ ├── mapSerializer.ts │ │ │ │ ├── objectPathSerializer.ts │ │ │ │ ├── objectSnapshotSerializer.ts │ │ │ │ ├── plainObjectSerializer.ts │ │ │ │ ├── primitiveSerializer.ts │ │ │ │ └── setSerializer.ts │ │ │ ├── actionTrackingMiddleware.ts │ │ │ ├── index.ts │ │ │ ├── onActionMiddleware.ts │ │ │ ├── readonlyMiddleware.ts │ │ │ ├── transactionMiddleware.ts │ │ │ ├── undoMiddleware.ts │ │ │ └── utils.ts │ │ ├── computedTree │ │ │ ├── computedTree.ts │ │ │ └── index.ts │ │ ├── context │ │ │ ├── context.ts │ │ │ └── index.ts │ │ ├── dataModel │ │ │ ├── BaseDataModel.ts │ │ │ ├── DataModel.ts │ │ │ ├── DataModelConstructorOptions.ts │ │ │ ├── actions.ts │ │ │ ├── getDataModelMetadata.ts │ │ │ ├── index.ts │ │ │ ├── newDataModel.ts │ │ │ └── utils.ts │ │ ├── frozen │ │ │ ├── Frozen.ts │ │ │ └── index.ts │ │ ├── globalConfig │ │ │ ├── globalConfig.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── model │ │ │ ├── BaseModel.ts │ │ │ ├── Model.ts │ │ │ ├── ModelConstructorOptions.ts │ │ │ ├── getModelMetadata.ts │ │ │ ├── index.ts │ │ │ ├── metadata.ts │ │ │ ├── newModel.ts │ │ │ └── utils.ts │ │ ├── modelShared │ │ │ ├── BaseModelShared.ts │ │ │ ├── index.ts │ │ │ ├── modelClassInitializer.ts │ │ │ ├── modelDecorator.ts │ │ │ ├── modelInfo.ts │ │ │ ├── modelPropsInfo.ts │ │ │ ├── modelSymbols.ts │ │ │ ├── newModel.ts │ │ │ ├── prop.ts │ │ │ ├── sharedInternalModel.ts │ │ │ └── utils.ts │ │ ├── parent │ │ │ ├── core.ts │ │ │ ├── coreObjectChildren.ts │ │ │ ├── detach.ts │ │ │ ├── findChildren.ts │ │ │ ├── findParent.ts │ │ │ ├── getChildrenObjects.ts │ │ │ ├── index.ts │ │ │ ├── onChildAttachedTo.ts │ │ │ ├── path.ts │ │ │ ├── path2.ts │ │ │ ├── pathTypes.ts │ │ │ ├── setParent.ts │ │ │ └── walkTree.ts │ │ ├── patch │ │ │ ├── Patch.ts │ │ │ ├── applyPatches.ts │ │ │ ├── emitPatch.ts │ │ │ ├── index.ts │ │ │ ├── jsonPatch.ts │ │ │ └── patchRecorder.ts │ │ ├── redux │ │ │ ├── connectReduxDevTools.ts │ │ │ ├── index.ts │ │ │ └── redux.ts │ │ ├── ref │ │ │ ├── Ref.ts │ │ │ ├── core.ts │ │ │ ├── customRef.ts │ │ │ ├── index.ts │ │ │ └── rootRef.ts │ │ ├── rootStore │ │ │ ├── attachDetach.ts │ │ │ ├── index.ts │ │ │ └── rootStore.ts │ │ ├── snapshot │ │ │ ├── SnapshotOf.ts │ │ │ ├── SnapshotterAndReconcilerPriority.ts │ │ │ ├── applySnapshot.ts │ │ │ ├── clone.ts │ │ │ ├── fromArraySnapshot.ts │ │ │ ├── fromFrozenSnapshot.ts │ │ │ ├── fromModelSnapshot.ts │ │ │ ├── fromPlainObjectSnapshot.ts │ │ │ ├── fromSnapshot.ts │ │ │ ├── getSnapshot.ts │ │ │ ├── index.ts │ │ │ ├── internal.ts │ │ │ ├── onSnapshot.ts │ │ │ ├── reconcileArraySnapshot.ts │ │ │ ├── reconcileFrozenSnapshot.ts │ │ │ ├── reconcileModelSnapshot.ts │ │ │ ├── reconcilePlainObjectSnapshot.ts │ │ │ ├── reconcileSnapshot.ts │ │ │ ├── registerDefaultReconcilers.ts │ │ │ └── registerDefaultSnapshotters.ts │ │ ├── standardActions │ │ │ ├── actions.ts │ │ │ ├── arrayActions.ts │ │ │ ├── index.ts │ │ │ ├── objectActions.ts │ │ │ └── standaloneActions.ts │ │ ├── transforms │ │ │ ├── ImmutableDate.ts │ │ │ ├── asMap.ts │ │ │ ├── asSet.ts │ │ │ ├── bigint.ts │ │ │ ├── date.ts │ │ │ └── index.ts │ │ ├── treeUtils │ │ │ ├── deepEquals.ts │ │ │ ├── draft.ts │ │ │ ├── index.ts │ │ │ └── sandbox.ts │ │ ├── tweaker │ │ │ ├── TweakerPriority.ts │ │ │ ├── core.ts │ │ │ ├── index.ts │ │ │ ├── registerDefaultTweakers.ts │ │ │ ├── tweak.ts │ │ │ ├── tweakArray.ts │ │ │ ├── tweakFrozen.ts │ │ │ ├── tweakModel.ts │ │ │ ├── tweakPlainObject.ts │ │ │ ├── typeChecking.ts │ │ │ └── withoutTypeChecking.ts │ │ ├── types │ │ │ ├── TypeCheckError.ts │ │ │ ├── TypeChecker.ts │ │ │ ├── arrayBased │ │ │ │ ├── typesArray.ts │ │ │ │ └── typesTuple.ts │ │ │ ├── getTypeInfo.ts │ │ │ ├── index.ts │ │ │ ├── objectBased │ │ │ │ ├── typesArraySet.ts │ │ │ │ ├── typesDataModelData.ts │ │ │ │ ├── typesModel.ts │ │ │ │ ├── typesObject.ts │ │ │ │ ├── typesObjectMap.ts │ │ │ │ ├── typesRecord.ts │ │ │ │ └── typesRef.ts │ │ │ ├── primitiveBased │ │ │ │ ├── typesEnum.ts │ │ │ │ ├── typesPrimitive.ts │ │ │ │ └── typesRefinedPrimitive.ts │ │ │ ├── registerDefaultStandardTypeResolvers.ts │ │ │ ├── resolveTypeChecker.ts │ │ │ ├── schemas.ts │ │ │ ├── tProp.ts │ │ │ ├── typeCheck.ts │ │ │ ├── types.ts │ │ │ └── utility │ │ │ │ ├── typesMaybe.ts │ │ │ │ ├── typesOr.ts │ │ │ │ ├── typesRefinement.ts │ │ │ │ ├── typesTag.ts │ │ │ │ └── typesUnchecked.ts │ │ ├── utils │ │ │ ├── AnyFunction.ts │ │ │ ├── ModelPool.ts │ │ │ ├── chainFns.ts │ │ │ ├── decorators.ts │ │ │ ├── index.ts │ │ │ ├── mapUtils.ts │ │ │ ├── setIfDifferent.ts │ │ │ ├── tag.ts │ │ │ └── types.ts │ │ └── wrappers │ │ │ ├── ArraySet.ts │ │ │ ├── ObjectMap.ts │ │ │ ├── asMap.ts │ │ │ ├── asSet.ts │ │ │ └── index.ts │ ├── swc.config.js │ ├── test │ │ ├── action │ │ │ ├── action.test.ts │ │ │ ├── applyDelete.test.ts │ │ │ ├── applyMethodCall.test.ts │ │ │ ├── applySet.test.ts │ │ │ └── flow.test.ts │ │ ├── actionMiddlewares │ │ │ ├── __snapshots__ │ │ │ │ ├── actionTrackingMiddleware-flow.test.ts.snap │ │ │ │ ├── actionTrackingMiddleware-sync.test.ts.snap │ │ │ │ └── undoMiddleware.test.ts.snap │ │ │ ├── actionSerialization.test.ts │ │ │ ├── actionTrackingMiddleware-flow.test.ts │ │ │ ├── actionTrackingMiddleware-sync.test.ts │ │ │ ├── applySerializedAction.test.ts │ │ │ ├── onActionMiddleware.test.ts │ │ │ ├── readonlyMiddleware.test.ts │ │ │ ├── transactionMiddleware.test.ts │ │ │ └── undoMiddleware.test.ts │ │ ├── commonSetup.ts │ │ ├── computedTree │ │ │ └── computedTree.test.ts │ │ ├── context │ │ │ └── context.test.ts │ │ ├── dataModel │ │ │ ├── dataModel.test.ts │ │ │ └── propTransform.test.ts │ │ ├── frozen │ │ │ └── frozen.test.ts │ │ ├── model │ │ │ ├── __snapshots__ │ │ │ │ └── onChildAttachedTo.test.ts.snap │ │ │ ├── create.test.ts │ │ │ ├── defaultProps.test.ts │ │ │ ├── factory.test.ts │ │ │ ├── ids.test.ts │ │ │ ├── modelDecorator.test.ts │ │ │ ├── onChildAttachedTo.test.ts │ │ │ ├── propTransform.test.ts │ │ │ ├── propsWithSameName.test.ts │ │ │ ├── recursive.test.ts │ │ │ ├── setter.test.ts │ │ │ ├── subclassing.test.ts │ │ │ ├── tweak.test.ts │ │ │ └── valueType.test.ts │ │ ├── parent │ │ │ ├── __snapshots__ │ │ │ │ └── getChildrenObjects.test.ts.snap │ │ │ ├── getChildrenObjects.test.ts │ │ │ ├── parent.test.ts │ │ │ └── walkTree.test.ts │ │ ├── patch │ │ │ ├── applyPatches.test.ts │ │ │ ├── jsonPatch.test.ts │ │ │ └── patch.test.ts │ │ ├── redux │ │ │ ├── __snapshots__ │ │ │ │ └── connectReduxDevTools.test.ts.snap │ │ │ ├── connectReduxDevTools.test.ts │ │ │ └── redux.test.ts │ │ ├── ref │ │ │ ├── customRef.test.ts │ │ │ └── rootRef.test.ts │ │ ├── rootStore │ │ │ └── rootStore.test.ts │ │ ├── snapshot │ │ │ ├── clone.test.ts │ │ │ ├── fromSnapshot.test.ts │ │ │ ├── getSnapshot.test.ts │ │ │ ├── modelProcessor.test.ts │ │ │ ├── noModelType.test.ts │ │ │ ├── propProcessor.test.ts │ │ │ └── snapshot.test.ts │ │ ├── spread │ │ │ └── spread.test.ts │ │ ├── standardActions │ │ │ ├── arrayActions.test.ts │ │ │ ├── objectActions.test.ts │ │ │ └── standaloneActions.test.ts │ │ ├── testbed.ts │ │ ├── treeUtils │ │ │ ├── deepEquals.test.ts │ │ │ ├── draft.test.ts │ │ │ └── sandbox.test.ts │ │ ├── tsconfig.experimental-decorators.json │ │ ├── tsconfig.json │ │ ├── tsconfig.mobx4.json │ │ ├── tsconfig.mobx5.json │ │ ├── tweaker │ │ │ └── tweak.test.ts │ │ ├── types │ │ │ ├── __snapshots__ │ │ │ │ └── typeChecking.test.ts.snap │ │ │ └── typeChecking.test.ts │ │ ├── utils.ts │ │ └── wrappers │ │ │ ├── ArraySet.test.ts │ │ │ ├── ObjectMap.test.ts │ │ │ ├── asMap.test.ts │ │ │ └── asSet.test.ts │ ├── tsconfig.json │ ├── typedocconfig.js │ └── vite.config.mts └── mobx-keystone-yjs │ ├── .gitignore │ ├── CHANGELOG.md │ ├── README.md │ ├── babel.config.js │ ├── jest.config.ts │ ├── package.json │ ├── src │ ├── binding │ │ ├── YjsTextModel.ts │ │ ├── applyMobxKeystonePatchToYjsObject.ts │ │ ├── bindYjsToMobxKeystone.ts │ │ ├── convertJsonToYjsData.ts │ │ ├── convertYjsDataToJson.ts │ │ ├── convertYjsEventToPatches.ts │ │ ├── resolveYjsPath.ts │ │ └── yjsBindingContext.ts │ ├── index.ts │ ├── plainTypes.ts │ └── utils │ │ ├── error.ts │ │ └── getOrCreateYjsCollectionAtom.ts │ ├── test │ ├── binding │ │ ├── YjsTextModel.test.ts │ │ └── binding.test.ts │ ├── commonSetup.ts │ ├── tsconfig.json │ └── utils.ts │ ├── tsconfig.json │ └── vite.config.mts ├── turbo.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 2 6 | indent_style = space 7 | insert_final_newline = true 8 | end_of_line = lf 9 | trim_trailing_whitespace = true 10 | max_line_length = 100 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules/** 2 | /.idea 3 | /*.log 4 | .turbo 5 | /**/.DS_Store 6 | 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/releases 10 | !.yarn/plugins 11 | !.yarn/sdks 12 | !.yarn/versions 13 | .pnp.* 14 | -------------------------------------------------------------------------------- /.netlify/state.json: -------------------------------------------------------------------------------- 1 | { 2 | "siteId": "c5f60bcb-c1ff-4d04-ad14-1fc34ddbb429" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome", "arcanis.vscode-zipfs"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "lib - Jest debug current file", 11 | "program": "${workspaceFolder}/packages/lib/node_modules/jest/bin/jest.js", 12 | "args": ["--verbose", "-i", "--no-cache", "--testPathPattern", "${fileBasename}"], 13 | "console": "integratedTerminal", 14 | "internalConsoleOptions": "neverOpen", 15 | "skipFiles": ["**/node_modules/**", "/**"], 16 | "cwd": "${workspaceFolder}/packages/lib" 17 | }, 18 | { 19 | "type": "node", 20 | "request": "launch", 21 | "name": "mobx-keystone-yjs - Jest debug current file", 22 | "program": "${workspaceFolder}/packages/mobx-keystone-yjs/node_modules/jest/bin/jest.js", 23 | "args": ["--verbose", "-i", "--no-cache", "--testPathPattern", "${fileBasename}"], 24 | "console": "integratedTerminal", 25 | "internalConsoleOptions": "neverOpen", 26 | "skipFiles": ["**/node_modules/**", "/**"], 27 | "cwd": "${workspaceFolder}/packages/mobx-keystone-yjs" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "biomejs.biome", 3 | "editor.formatOnSave": true, 4 | "typescript.tsdk": "./node_modules/typescript/lib", 5 | "peacock.color": "#68217A", 6 | "workbench.colorCustomizations": { 7 | "titleBar.activeBackground": "#68217a", 8 | "titleBar.inactiveBackground": "#68217a99", 9 | "titleBar.activeForeground": "#e7e7e7", 10 | "titleBar.inactiveForeground": "#e7e7e799", 11 | "sash.hoverBorder": "#8a2ca2", 12 | "commandCenter.border": "#e7e7e799" 13 | }, 14 | "search.exclude": { 15 | "**/.yarn": true, 16 | "**/.pnp.*": true 17 | }, 18 | "[typescript]": { 19 | "editor.defaultFormatter": "biomejs.biome" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableGlobalCache: false 2 | 3 | nmHoistingLimits: workspaces 4 | 5 | nodeLinker: node-modules 6 | 7 | yarnPath: .yarn/releases/yarn-4.9.1.cjs 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributor Guide 2 | 3 | Welcome to a community of developers just like you, striving to create the best experience around mobx-keystone. We welcome anyone who wants to contribute or provide constructive feedback, no matter the age or level of experience. 4 | 5 | Here are some ways to contribute to the project, from easiest to most difficult: 6 | 7 | - [Reporting bugs](#reporting-bugs) 8 | - [Improving the documentation](#improving-the-documentation) 9 | - [Responding to issues](#responding-to-issues) 10 | - [Small bug fixes](#small-bug-fixes) 11 | 12 | ## Issues 13 | 14 | ### Reporting bugs 15 | 16 | If you encounter a bug, please file an issue on GitHub via the repository you think contains the bug. If an issue you have is already reported, please add additional information or add a 👍 reaction to indicate your agreement. 17 | 18 | Include in the issue a link to your reproduction. A couple good options are a small Github repo or a CodeSandbox. 19 | 20 | ### Improving the documentation 21 | 22 | Improving the documentation, examples, and other open source content can be the easiest way to contribute to the library. If you see a piece of content that can be better, open a PR with an improvement, no matter how small! If you would like to suggest a big change or major rewrite, we’d love to hear your ideas but please open an issue for discussion before writing the PR. 23 | 24 | ### Responding to issues 25 | 26 | In addition to reporting issues, a great way to contribute to MobX is to respond to other peoples' issues and try to identify the problem or help them work around it. If you’re interested in taking a more active role in this process, please go ahead and respond to issues. 27 | 28 | ### Small bug fixes 29 | 30 | For a small bug fix change (less than 20 lines of code changed), feel free to open a pull request. We’ll try to merge it as fast as possible and ideally publish a new release on the same day. The only requirement is, make sure you also add a test that verifies the bug you are trying to fix. 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Javier González Garcés 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - Maybe we should ofer specialized versions of array and our custom map that are auto transformed into 4 | arrays / objects and do things like make indexOf use ids for models? 5 | That way we could also remove toTreeNode. 6 | 7 | - (8) clear place to put effects and the like? 8 | - in theory that would be afterAttachToRootModel 9 | 10 | - (6) should we add something to distinguish actions run as apply (from those who are not) in mwares? (applySnapshot, applyPatches) 11 | - in theory the user could use the filter function + isSpecialAction 12 | 13 | - (5) action recorder abstraction? 14 | 15 | - (4) check out mst api for missing features 16 | -------------------------------------------------------------------------------- /apps/benchmark/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | -------------------------------------------------------------------------------- /apps/benchmark/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "benchmark", 4 | "type": "module", 5 | "version": "0.0.0", 6 | "description": "Benchmark for mobx-keystone", 7 | "main": "dist/index.js", 8 | "license": "MIT", 9 | "scripts": { 10 | "build": "shx rm -rf dist && tsc -p .", 11 | "bench-only": "cross-env NODE_ENV=production node dist/index.js", 12 | "bench": "yarn build && yarn bench-only" 13 | }, 14 | "peerDependencies": { 15 | "mobx": "^6.0.0" 16 | }, 17 | "dependencies": { 18 | "benchmark": "^2.1.4", 19 | "chalk": "^5.4.1", 20 | "mobx-keystone": "workspace:packages/lib", 21 | "mobx-state-tree": "^7.0.2", 22 | "tslib": "^2.8.1" 23 | }, 24 | "devDependencies": { 25 | "@types/benchmark": "^2.1.5", 26 | "cross-env": "^7.0.3", 27 | "shx": "^0.4.0", 28 | "typescript": "^5.8.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/benchmark/src/bench.ts: -------------------------------------------------------------------------------- 1 | import Benchmark from "benchmark" 2 | import chalk from "chalk" 3 | 4 | export type ExtrasToRun = ("es6" | "mobx")[] 5 | 6 | export function bench( 7 | name: string, 8 | mobxKeyStoneImpl: Function, 9 | mstImpl: Function, 10 | es6Impl: Function, 11 | mobxImpl: Function, 12 | extrasToRun: ExtrasToRun 13 | ) { 14 | let suite = new Benchmark.Suite(name) 15 | 16 | let results: Record = {} 17 | 18 | const keystone = chalk.green("mobx-keystone") 19 | const mst = chalk.red("mobx-state-tree") 20 | const es6 = chalk.magenta("raw es6") 21 | const mobx = chalk.blue("raw mobx") 22 | 23 | suite = suite.add(keystone, mobxKeyStoneImpl) 24 | 25 | const runMst = true 26 | if (runMst) { 27 | suite = suite.add(mst, mstImpl) 28 | } 29 | 30 | if (extrasToRun.includes("mobx")) { 31 | suite = suite.add(mobx, mobxImpl) 32 | } 33 | if (extrasToRun.includes("es6")) { 34 | suite = suite.add(es6, es6Impl) 35 | } 36 | 37 | // add listeners 38 | suite 39 | .on("error", (error: any) => { 40 | console.error(error) 41 | }) 42 | .on("start", () => { 43 | console.log(chalk.cyan(name)) 44 | results = {} 45 | }) 46 | .on("cycle", (event: Benchmark.Event) => { 47 | results[event.target.name!] = event.target 48 | console.log(String(event.target)) 49 | }) 50 | .on("complete", () => { 51 | if (runMst) { 52 | const keystoneSpeed = results[keystone].hz! 53 | const mstSpeed = results[mst].hz! 54 | const fastest = keystoneSpeed > mstSpeed ? keystone : mst 55 | 56 | const ratio = Math.max(keystoneSpeed, mstSpeed) / Math.min(keystoneSpeed, mstSpeed) 57 | 58 | console.log( 59 | `Fastest between mobx-keystone and mobx-state-tree is ${fastest} by ${ratio.toFixed(2)}x` 60 | ) 61 | } 62 | 63 | if (extrasToRun.includes("mobx")) { 64 | const mobxRatio = results[mobx].hz! / results[keystone].hz! 65 | console.log(`${mobx} is faster than mobx-keystone by ${mobxRatio.toFixed(2)}x`) 66 | } 67 | 68 | if (extrasToRun.includes("es6")) { 69 | const es6Ratio = results[es6].hz! / results[keystone].hz! 70 | console.log(`${es6} is faster than mobx-keystone by ${es6Ratio.toFixed(2)}x`) 71 | } 72 | 73 | console.log() 74 | }) 75 | .run({ async: false }) 76 | } 77 | -------------------------------------------------------------------------------- /apps/benchmark/src/models/ks-nonTypeChecked.ts: -------------------------------------------------------------------------------- 1 | import { computed } from "mobx" 2 | import { model, Model, modelAction, prop } from "mobx-keystone" 3 | 4 | @model("SmallModel") 5 | export class SmallModel extends Model({ 6 | a: prop("a"), 7 | b: prop("b"), 8 | c: prop("c"), 9 | d: prop("d"), 10 | }) { 11 | @computed 12 | get a2() { 13 | return this.a + this.a 14 | } 15 | @computed 16 | get b2() { 17 | return this.b + this.b 18 | } 19 | @computed 20 | get c2() { 21 | return this.c + this.c 22 | } 23 | @computed 24 | get d2() { 25 | return this.d + this.d 26 | } 27 | 28 | @modelAction 29 | setA(x: string) { 30 | this.a = x 31 | } 32 | @modelAction 33 | setB(x: string) { 34 | this.b = x 35 | } 36 | @modelAction 37 | setC(x: string) { 38 | this.c = x 39 | } 40 | @modelAction 41 | setD(x: string) { 42 | this.d = x 43 | } 44 | } 45 | 46 | @model("BigModel") 47 | export class BigModel extends Model({ 48 | aa: prop(() => new SmallModel({})), 49 | bb: prop(() => new SmallModel({})), 50 | cc: prop(() => new SmallModel({})), 51 | dd: prop(() => new SmallModel({})), 52 | a: prop("a"), 53 | b: prop("b"), 54 | c: prop("c"), 55 | d: prop("d"), 56 | }) { 57 | @computed 58 | get a2() { 59 | return this.a + this.a 60 | } 61 | @computed 62 | get b2() { 63 | return this.b + this.b 64 | } 65 | @computed 66 | get c2() { 67 | return this.c + this.c 68 | } 69 | @computed 70 | get d2() { 71 | return this.d + this.d 72 | } 73 | 74 | @modelAction 75 | setAA(s: SmallModel) { 76 | this.aa = s 77 | } 78 | @modelAction 79 | setBB(s: SmallModel) { 80 | this.bb = s 81 | } 82 | @modelAction 83 | setCC(s: SmallModel) { 84 | this.cc = s 85 | } 86 | @modelAction 87 | setDD(s: SmallModel) { 88 | this.dd = s 89 | } 90 | @modelAction 91 | setA(x: string) { 92 | this.a = x 93 | } 94 | @modelAction 95 | setB(x: string) { 96 | this.b = x 97 | } 98 | @modelAction 99 | setC(x: string) { 100 | this.c = x 101 | } 102 | @modelAction 103 | setD(x: string) { 104 | this.d = x 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /apps/benchmark/src/models/ks-typeChecked.ts: -------------------------------------------------------------------------------- 1 | import { computed } from "mobx" 2 | import { model, Model, modelAction, tProp, types } from "mobx-keystone" 3 | 4 | @model("TcSmallModel") 5 | export class TcSmallModel extends Model({ 6 | a: tProp(types.string, "a"), 7 | b: tProp(types.string, "b"), 8 | c: tProp(types.string, "c"), 9 | d: tProp(types.string, "d"), 10 | }) { 11 | @computed 12 | get a2() { 13 | return this.a + this.a 14 | } 15 | @computed 16 | get b2() { 17 | return this.b + this.b 18 | } 19 | @computed 20 | get c2() { 21 | return this.c + this.c 22 | } 23 | @computed 24 | get d2() { 25 | return this.d + this.d 26 | } 27 | 28 | @modelAction 29 | setA(x: string) { 30 | this.a = x 31 | } 32 | @modelAction 33 | setB(x: string) { 34 | this.b = x 35 | } 36 | @modelAction 37 | setC(x: string) { 38 | this.c = x 39 | } 40 | @modelAction 41 | setD(x: string) { 42 | this.d = x 43 | } 44 | } 45 | 46 | @model("TcBigModel") 47 | export class TcBigModel extends Model({ 48 | aa: tProp(types.model(TcSmallModel), () => new TcSmallModel({})), 49 | bb: tProp(types.model(TcSmallModel), () => new TcSmallModel({})), 50 | cc: tProp(types.model(TcSmallModel), () => new TcSmallModel({})), 51 | dd: tProp(types.model(TcSmallModel), () => new TcSmallModel({})), 52 | a: tProp(types.string, "a"), 53 | b: tProp(types.string, "b"), 54 | c: tProp(types.string, "c"), 55 | d: tProp(types.string, "d"), 56 | }) { 57 | @computed 58 | get a2() { 59 | return this.a + this.a 60 | } 61 | @computed 62 | get b2() { 63 | return this.b + this.b 64 | } 65 | @computed 66 | get c2() { 67 | return this.c + this.c 68 | } 69 | @computed 70 | get d2() { 71 | return this.d + this.d 72 | } 73 | 74 | @modelAction 75 | setAA(s: TcSmallModel) { 76 | this.aa = s 77 | } 78 | @modelAction 79 | setBB(s: TcSmallModel) { 80 | this.bb = s 81 | } 82 | @modelAction 83 | setCC(s: TcSmallModel) { 84 | this.cc = s 85 | } 86 | @modelAction 87 | setDD(s: TcSmallModel) { 88 | this.dd = s 89 | } 90 | @modelAction 91 | setA(x: string) { 92 | this.a = x 93 | } 94 | @modelAction 95 | setB(x: string) { 96 | this.b = x 97 | } 98 | @modelAction 99 | setC(x: string) { 100 | this.c = x 101 | } 102 | @modelAction 103 | setD(x: string) { 104 | this.d = x 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /apps/benchmark/src/models/mst.ts: -------------------------------------------------------------------------------- 1 | import { types } from "mobx-state-tree" 2 | 3 | export const mstSmallModel = types 4 | .model("SmallModel", { 5 | a: "a", 6 | b: "b", 7 | c: "c", 8 | d: "d", 9 | }) 10 | .views((self) => ({ 11 | get a2() { 12 | return self.a + self.a 13 | }, 14 | get b2() { 15 | return self.b + self.b 16 | }, 17 | get c2() { 18 | return self.c + self.c 19 | }, 20 | get D2() { 21 | return self.d + self.d 22 | }, 23 | })) 24 | .actions((self) => ({ 25 | setA(x: string) { 26 | self.a = x 27 | }, 28 | setB(x: string) { 29 | self.b = x 30 | }, 31 | setC(x: string) { 32 | self.c = x 33 | }, 34 | setD(x: string) { 35 | self.d = x 36 | }, 37 | })) 38 | 39 | export const mstBigModel = types 40 | .model("BigModel", { 41 | aa: types.optional(mstSmallModel, () => ({})), 42 | bb: types.optional(mstSmallModel, () => ({})), 43 | cc: types.optional(mstSmallModel, () => ({})), 44 | dd: types.optional(mstSmallModel, () => ({})), 45 | a: "a", 46 | b: "b", 47 | c: "c", 48 | d: "d", 49 | }) 50 | .views((self) => ({ 51 | get a2() { 52 | return self.a + self.a 53 | }, 54 | get b2() { 55 | return self.b + self.b 56 | }, 57 | get c2() { 58 | return self.c + self.c 59 | }, 60 | get D2() { 61 | return self.d + self.d 62 | }, 63 | })) 64 | .actions((self) => ({ 65 | setAA(s: any) { 66 | self.aa = s 67 | }, 68 | setBB(s: any) { 69 | self.bb = s 70 | }, 71 | setCC(s: any) { 72 | self.cc = s 73 | }, 74 | setDD(s: any) { 75 | self.dd = s 76 | }, 77 | setA(x: string) { 78 | self.a = x 79 | }, 80 | setB(x: string) { 81 | self.b = x 82 | }, 83 | setC(x: string) { 84 | self.c = x 85 | }, 86 | setD(x: string) { 87 | self.d = x 88 | }, 89 | })) 90 | -------------------------------------------------------------------------------- /apps/benchmark/src/sleep.ts: -------------------------------------------------------------------------------- 1 | export function sleep(ms: number) { 2 | return new Promise((resolve) => { 3 | setTimeout(resolve, ms) 4 | }) 5 | } 6 | -------------------------------------------------------------------------------- /apps/benchmark/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "target": "es2021", 5 | "module": "ESNext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "rootDir": "./src", 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "noImplicitThis": true, 12 | "alwaysStrict": true, 13 | "noUnusedLocals": false, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "moduleResolution": "node", 18 | "esModuleInterop": true, 19 | "experimentalDecorators": true, 20 | "emitDecoratorMetadata": false, 21 | "skipLibCheck": true, 22 | "outDir": "dist", 23 | "useDefineForClassFields": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /apps/site/.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .cache-loader 3 | .docusaurus 4 | /build 5 | /node_modules 6 | /static/api 7 | /copy-to-build 8 | -------------------------------------------------------------------------------- /apps/site/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve("@docusaurus/core/lib/babel/preset")], 3 | plugins: [[`@babel/plugin-proposal-decorators`, { version: "legacy" }]], 4 | } 5 | -------------------------------------------------------------------------------- /apps/site/docs/actionMiddlewares/readonlyMiddleware.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: readonlyMiddleware 3 | slug: /action-middlewares/readonly-middleware 4 | --- 5 | 6 | ## Overview 7 | 8 | Attaches an action middleware that will throw when any action is started over the node or any of the child nodes, thus effectively making the subtree readonly. It will return an object with a `dispose` function to remove the middleware and an `allowWrite` function that will allow actions to be started inside the provided code block. 9 | 10 | Example: 11 | 12 | ```ts 13 | // given a model instance named `todo` 14 | const { dispose, allowWrite } = readonlyMiddleware(todo) 15 | 16 | // this will throw 17 | todo.setDone(false) 18 | await todo.setDoneAsync(false) 19 | 20 | // this will work 21 | allowWrite(() => todo.setDone(false)) 22 | // note: for async always use one action invocation per `allowWrite`! 23 | await allowWrite(() => todo.setDoneAsync(false)) 24 | ``` 25 | -------------------------------------------------------------------------------- /apps/site/docs/actionMiddlewares/transactionMiddleware.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: transactionMiddleware 3 | slug: /action-middlewares/transaction-middleware 4 | --- 5 | 6 | ## Overview 7 | 8 | The transaction middleware allows you to mark model actions/flows as transactions, this is, if such action/flow throws then any changes performed during such will be reverted before the exception is actually thrown. 9 | 10 | There are two ways to mark an action/flow as a transaction. As a decorator and programmatically. 11 | 12 | As a decorator: 13 | 14 | ```ts 15 | @model("MyApp/MyBalance") 16 | class MyBalance extends Model({ 17 | balance: prop(), 18 | }) { 19 | @transaction 20 | @modelAction 21 | addMoney(cents: number) { 22 | this.balance += cents 23 | // imagine that something else goes wrong 24 | // in this case balance will be reverted to the value that 25 | // was there before the action started 26 | throw new Error("...") 27 | } 28 | } 29 | ``` 30 | 31 | Programmatically: 32 | 33 | ```ts 34 | @model("MyApp/MyModel") 35 | class MyBalance extends Model({ 36 | balance: prop(), 37 | }) { 38 | @modelAction 39 | addMoney(cents: number) { 40 | this.balance += cents 41 | // imagine that something else goes wrong 42 | // in this case balance will be reverted to the value that 43 | // was there before the action started 44 | throw new Error("...") 45 | } 46 | 47 | // we could for example add it on init (for all instances) 48 | onInit() { 49 | transactionMiddleware({ 50 | model: this, 51 | actionName: "addMoney", 52 | }) 53 | } 54 | } 55 | 56 | // or for a particular instance 57 | const myBalance = new MyBalance({ balance: 100 }) 58 | transactionMiddleware({ 59 | model: myBalance, 60 | actionName: "addMoney", 61 | }) 62 | ``` 63 | -------------------------------------------------------------------------------- /apps/site/docs/examples/clientServer/app.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react" 2 | import { AppInstance } from "./appInstance" 3 | 4 | // we will expose both app instances in the ui 5 | 6 | export const App = observer(() => { 7 | return ( 8 |
9 |
10 |

App Instance #1

11 | 12 |
13 | 14 |
15 |

App Instance #2

16 | 17 |
18 |
19 | ) 20 | }) 21 | -------------------------------------------------------------------------------- /apps/site/docs/examples/clientServer/clientServer.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Client/Server Example 3 | slug: /examples/client-server 4 | --- 5 | 6 | import BrowserOnly from "@docusaurus/BrowserOnly" 7 | import CodeBlock from "@theme/CodeBlock" 8 | 9 | import appSource from "!!raw-loader!./app.tsx" 10 | import serverSource from "!!raw-loader!./server.ts" 11 | import appInstanceSource from "!!raw-loader!./appInstance.tsx" 12 | 13 | In this example we will be synchronizing two separate root stores via action capturing and applying, which will simulate how to instances of an app talk with a server to keep in sync. We will use pessimistic updates, this is, we will cancel the local action and then actually run the action when the server tells the client to do so. 14 | 15 | Note: This example has an artificial delay to simulate network latency. 16 | 17 | 18 | {() => { 19 | const { App } = require("./app.tsx") 20 | return 21 | }} 22 | 23 | 24 | ## Code 25 | 26 | ### `server.ts` 27 | 28 | 29 | {serverSource} 30 | 31 | 32 | ### `appInstance.tsx` 33 | 34 | 35 | {appInstanceSource} 36 | 37 | 38 | ### `app.tsx` 39 | 40 | 41 | {appSource} 42 | 43 | -------------------------------------------------------------------------------- /apps/site/docs/examples/clientServer/server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | applySerializedActionAndTrackNewModelIds, 3 | getSnapshot, 4 | SerializedActionCall, 5 | SerializedActionCallWithModelIdOverrides, 6 | } from "mobx-keystone" 7 | import { createRootStore } from "../todoList/store" 8 | 9 | type MsgListener = (actionCall: SerializedActionCallWithModelIdOverrides) => void 10 | 11 | class Server { 12 | private serverRootStore = createRootStore() 13 | private msgListeners: MsgListener[] = [] 14 | 15 | getInitialState() { 16 | return getSnapshot(this.serverRootStore) 17 | } 18 | 19 | onMessage(listener: MsgListener) { 20 | this.msgListeners.push(listener) 21 | } 22 | 23 | sendMessage(actionCall: SerializedActionCall) { 24 | // the timeouts are just to simulate network delays 25 | setTimeout(() => { 26 | // apply the action over the server root store 27 | // sometimes applying actions might fail (for example on invalid operations 28 | // such as when one client asks to delete a model from an array and other asks to mutate it) 29 | // so we try / catch it 30 | let serializedActionCallToReplicate: SerializedActionCallWithModelIdOverrides | undefined 31 | try { 32 | // we use this to apply the action on the server side and keep track of new model IDs being 33 | // generated, so the clients will have the chance to keep those in sync 34 | const applyActionResult = applySerializedActionAndTrackNewModelIds( 35 | this.serverRootStore, 36 | actionCall 37 | ) 38 | serializedActionCallToReplicate = applyActionResult.serializedActionCall 39 | } catch (err) { 40 | console.error("error applying action to server:", err) 41 | } 42 | 43 | if (serializedActionCallToReplicate) { 44 | setTimeout(() => { 45 | // and distribute message, which includes new model IDs to keep them in sync 46 | this.msgListeners.forEach((listener) => { 47 | listener(serializedActionCallToReplicate) 48 | }) 49 | }, 500) 50 | } 51 | }, 500) 52 | } 53 | } 54 | 55 | export const server = new Server() 56 | -------------------------------------------------------------------------------- /apps/site/docs/examples/todoList/todoList.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Todo List Example 3 | slug: /examples/todo-list 4 | --- 5 | 6 | import BrowserOnly from "@docusaurus/BrowserOnly" 7 | import CodeBlock from "@theme/CodeBlock" 8 | 9 | import storeSource from "!!raw-loader!./store.ts" 10 | import appSource from "!!raw-loader!./app.tsx" 11 | import logsSource from "!!raw-loader!./logs.tsx" 12 | 13 | 14 | {() => { 15 | const { App } = require("./app.tsx") 16 | return 17 | }} 18 | 19 | 20 | ## Code 21 | 22 | ### `store.ts` 23 | 24 | 25 | {storeSource} 26 | 27 | 28 | ### `app.tsx` 29 | 30 | 31 | {appSource} 32 | 33 | 34 | ### `logs.tsx` 35 | 36 | 37 | {logsSource} 38 | 39 | -------------------------------------------------------------------------------- /apps/site/docs/examples/yjsBinding/app.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react" 2 | import { AppInstance } from "./appInstance" 3 | 4 | let iframeResizerChildInited = false 5 | 6 | function initIframeResizerChild() { 7 | if (!iframeResizerChildInited) { 8 | iframeResizerChildInited = true 9 | void import("@iframe-resizer/child") 10 | } 11 | } 12 | 13 | export const App = observer(() => { 14 | initIframeResizerChild() 15 | 16 | return ( 17 | <> 18 |
24 |

App Instance

25 | 26 |
27 | 28 | ) 29 | }) 30 | -------------------------------------------------------------------------------- /apps/site/docs/examples/yjsBinding/yjsBinding.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Y.js Binding Example 3 | slug: /examples/yjs-binding 4 | --- 5 | 6 | import CodeBlock from "@theme/CodeBlock" 7 | import IframeResizer from "@iframe-resizer/react" 8 | 9 | import appSource from "!!raw-loader!./app.tsx" 10 | import appInstanceSource from "!!raw-loader!./appInstance.tsx" 11 | 12 | In this example we will be synchronizing two separate root stores via the `mobx-keystone-yjs` package (documentation [here](../../integrations/yjsBinding.mdx)). This package ensures a `Y.js` document is kept in sync with a `mobx-keystone` store and vice-versa. This is useful for example when you when you want to have multiple clients that keep in sync with each other using a peer-to-peer connection, an intermediate server, etc. Another nice feature of CRDTs is that they are conflict-free, so you don't have to worry about conflicts when two clients edit the same data at the same time, even if they were offline while doing such changes. Due to this all updates become optimistic updates and perceived performance is great since it does not require a server confirming the operation. 13 | 14 | Note: This example uses `y-webrtc` to share state using WebRTC (P2P). 15 | 16 |
17 | 18 | 19 |
20 | ## Code 21 | 22 | ### `appInstance.tsx` 23 | 24 | 25 | {appInstanceSource} 26 | 27 | 28 | ### `app.tsx` 29 | 30 | 31 | {appSource} 32 | 33 | -------------------------------------------------------------------------------- /apps/site/docs/frozen.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Frozen Data 3 | slug: /frozen-data 4 | --- 5 | 6 | ## Overview 7 | 8 | When performance is key and there are big chunks of data that don't change at all we can use `frozen`. 9 | 10 | `frozen` basically wraps a plain chunk of data (composed of plain objects, arrays, primitives, or a mix of them) inside a model-like structure, turns such data into immutable (in dev mode) and therefore keeps it still observable (by reference), snapshotable, patchable and reactive, but skipping the overhead of turning every single object inside the frozen data into separate tree nodes. 11 | 12 | Additionally, in dev mode only (for performance reasons), frozen will deeply freeze the object passed to it (ensuring it stays immutable) and will ensure the structure passed is compatible with JSON in order for it to be properly snapshotable. 13 | 14 | As an example, say that your app uses lists of lots of points (polygons), and you know that once a polygon is added to your store the polygon itself won't change. In order to make it faster it could be modeled like this: 15 | 16 | ```ts 17 | type Polygon = { x: number; y: number }[] 18 | 19 | // not frozen polygon, `getSnaphot` for example still won't work on it 20 | const myPolygon = [ 21 | { x: 10, y: 10 }, 22 | { x: 20, y: 10 }, 23 | ] 24 | 25 | // now `myPolygon` will be frozen, in dev mode it cannot be changed anymore 26 | // and things like `getSnapshot` will work over it 27 | const myFrozenPolygon = frozen(myPolygon) 28 | 29 | // to access the frozen object data we have to use `data` 30 | const firstPoint = myFrozenPolygon.data[0] 31 | ``` 32 | -------------------------------------------------------------------------------- /apps/site/docs/installation.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation 3 | slug: /installation 4 | --- 5 | 6 | import Tabs from "@theme/Tabs" 7 | import TabItem from "@theme/TabItem" 8 | 9 | 10 | 11 | 12 | ```bash 13 | yarn add mobx-keystone 14 | ``` 15 | 16 | 17 | 18 | 19 | ```bash 20 | npm install --save mobx-keystone 21 | ``` 22 | 23 | 24 | 25 | 26 | This library requires a more or less modern JavaScript environment to work, namely one with support for: 27 | 28 | - MobX 6, 5, or 4 (with its gotchas) 29 | - Proxies 30 | - Symbols 31 | - WeakMap/WeakSet 32 | 33 | In other words, it should work on mostly anything except _it won't work in Internet Explorer_. 34 | 35 | If you are using TypeScript, then version 4.2.0+ is recommended, though it _might_ work with older versions. 36 | 37 | ## Transpiler configuration 38 | 39 | This library uses JavaScript decorators and class properties which are supported via the following transpiler configurations: 40 | 41 | 42 | 43 | 44 | ```json title="tsconfig.json" 45 | { 46 | // ... 47 | "compilerOptions": { 48 | // ... 49 | "experimentalDecorators": true, 50 | // MobX 5/6 51 | "useDefineForClassFields": true 52 | // MobX 4 53 | "useDefineForClassFields": false 54 | } 55 | } 56 | ``` 57 | 58 | 59 | 60 | 61 | ```json title="babel.config.json" 62 | { 63 | "presets": [ 64 | // ... 65 | // If you use TypeScript 66 | ["@babel/preset-typescript", { "allowDeclareFields": true }] 67 | ], 68 | "plugins": [ 69 | ["@babel/plugin-proposal-decorators", { "version": "legacy" }], 70 | // MobX 5/6 71 | ["@babel/plugin-proposal-class-properties"] 72 | // MobX 4 73 | ["@babel/plugin-proposal-class-properties", { "loose": true }] 74 | ] 75 | } 76 | ``` 77 | 78 | 79 | 80 | 81 | ```json title=".swcrc" 82 | { 83 | "jsc": { 84 | "parser": { 85 | "syntax": "typescript", // "ecmascript" if you use JavaScript 86 | "decorators": true 87 | }, 88 | "transform": { 89 | // Optional if you use TypeScript 90 | // Required if you use JavaScript 91 | "legacyDecorator": true 92 | }, 93 | // MobX 5/6 94 | "loose": false 95 | // MobX 4 96 | "loose": true 97 | } 98 | } 99 | ``` 100 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /apps/site/docs/integrations/reduxCompatibility.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Redux Compatibility 3 | slug: /integrations/redux-compatibility 4 | --- 5 | 6 | ## `asReduxStore` 7 | 8 | It is possible to transform a `mobx-keystone` tree node into a Redux compatible store. 9 | 10 | ```ts 11 | const todoListReduxStore = asReduxStore(todoList) 12 | 13 | // or with Redux middlewares 14 | const todoListReduxStore = asReduxStore(todoList, middleware1, middleware2) 15 | ``` 16 | 17 | Such store will have most of the usual Redux store methods: 18 | 19 | - `getState()` is a thin wrapper over `getSnapshot(storeTarget)`. 20 | - `dispatch(action)` accepts an action in the form `{ type: "applyAction"; payload: ActionCall }`, which can be constructed by using `actionCallToReduxAction(actionCall)` and will call `applyAction` with the store target and the action call from the payload. 21 | - `subscribe(listener)` will use `onSnapshot(storeTarget, listener)` and return a disposer. 22 | 23 | ## `connectReduxDevTools` 24 | 25 | It is also possible to connect a store to some Redux DevTools monitor thanks to the `connectReduxDevTools` function and the `remotedev` package. 26 | 27 | ```ts 28 | import * as remotedev from "remotedev" 29 | // or 30 | const remotedev = require("remotedev") 31 | 32 | // create a connection to the monitor (for example with `connectViaExtension`) 33 | const connection = remotedev.connectViaExtension({ 34 | name: "my cool store", 35 | }) 36 | 37 | connectReduxDevTools(remotedev, connection, todoList) 38 | ``` 39 | 40 | This function also accepts an optional options object with the following properties: 41 | 42 | - `logArgsNearName` - if it should show the arguments near the action name (default is `true`). 43 | 44 | If you want to see it in action feel free to check the [Todo List Example](../examples/todoList/todoList.mdx), open the Redux DevTools and perform some actions. 45 | -------------------------------------------------------------------------------- /apps/site/docs/standardAndStandaloneActions.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Standard and Standalone Actions 3 | slug: /standard-and-standalone-actions 4 | --- 5 | 6 | ## Standalone Actions 7 | 8 | Sometimes you might need to define a "model" action but without an associated model. Say for example that you need an array swap method that needs to be processed by middlewares (e.g. [`undoMiddleware`](./actionMiddlewares/undoMiddleware.mdx)). One way to achieve this is to use standalone actions like this: 9 | 10 | ```ts 11 | const arraySwap = standaloneAction( 12 | "myApp/arraySwap", 13 | (array: T[], index1: number, index2: number): void => { 14 | if (index2 < index1) { 15 | ;[index1, index2] = [index2, index1] 16 | } 17 | // since a same node cannot be in two places at once we will remove 18 | // both then reinsert them 19 | const [v1] = array.splice(index1, 1) 20 | const [v2] = array.splice(index2 - 1, 1) 21 | array.splice(index1, 0, v2) 22 | array.splice(index2, 0, v1) 23 | } 24 | ) 25 | ``` 26 | 27 | Note the following prerequisites apply to standalone actions: 28 | 29 | - The name provided must be unique across your whole application. 30 | - The first argument (the target) must always be an existing tree node. 31 | 32 | ## Standard Actions 33 | 34 | In order to work over objects and arrays without requiring declaring custom actions you can use the already predefined `objectActions` and `arrayActions` (note these also work over class models). 35 | 36 | `objectActions` work over any kinds of objects (including models themselves) and offer: 37 | 38 | - `set(obj, key, value)` to set a key. 39 | - `delete(obj, key)` to delete a key. 40 | - `assign(obj, partialObj)` to assign values (similar to `Object.assign`). 41 | - `call(methodName, ...args)` to call a method. 42 | 43 | `arrayActions` work over arrays and offer: 44 | 45 | - `set(array, index, value)` to set an index. 46 | - `delete(array, index)` to delete an index. 47 | - `setLength(array, length)` to set a new length. 48 | - `swap(array, index1, index2)` to swap two array elements. 49 | 50 | Plus the usual array mutation methods (`pop`, `push`, etc.). 51 | -------------------------------------------------------------------------------- /apps/site/docusaurus.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@docusaurus/types" 2 | import type * as Preset from "@docusaurus/preset-classic" 3 | 4 | const docsRouteBasePath = "/" 5 | 6 | const config: Config = { 7 | title: "mobx-keystone", 8 | tagline: 9 | "A MobX powered state management solution based on data trees with first-class support for TypeScript, snapshots, patches and much more", 10 | url: "https://mobx-keystone.js.org", 11 | baseUrl: "/", 12 | onBrokenLinks: "ignore", // because of /api/ links 13 | onBrokenMarkdownLinks: "warn", 14 | favicon: "img/favicon.ico", 15 | organizationName: "xaviergonz", 16 | projectName: "mobx-keystone", 17 | presets: [ 18 | [ 19 | "@docusaurus/preset-classic", 20 | { 21 | docs: { 22 | sidebarPath: require.resolve("./sidebars.js"), 23 | editUrl: "https://github.com/xaviergonz/mobx-keystone/edit/master/apps/site/", 24 | routeBasePath: docsRouteBasePath, 25 | }, 26 | blog: false, 27 | sitemap: {}, 28 | theme: { 29 | customCss: require.resolve("./src/css/custom.css"), 30 | }, 31 | } satisfies Preset.Options, 32 | ], 33 | ], 34 | plugins: [ 35 | [ 36 | "@easyops-cn/docusaurus-search-local", 37 | { 38 | hashed: true, 39 | indexDocs: true, 40 | docsRouteBasePath, 41 | indexBlog: false, 42 | indexPages: false, 43 | }, 44 | ], 45 | ], 46 | 47 | themeConfig: { 48 | navbar: { 49 | style: "dark", 50 | title: "mobx-keystone", 51 | logo: { 52 | alt: "mobx-keystone", 53 | src: "img/logo.png", 54 | }, 55 | items: [ 56 | { 57 | type: "doc", 58 | docId: "intro", 59 | position: "right", 60 | label: "Documentation", 61 | }, 62 | { 63 | href: "/api/", 64 | target: "_blank", 65 | label: "API", 66 | position: "right", 67 | }, 68 | { 69 | href: "https://github.com/xaviergonz/mobx-keystone", 70 | label: "GitHub", 71 | position: "right", 72 | }, 73 | ], 74 | }, 75 | footer: { 76 | style: "dark", 77 | copyright: `Copyright © ${new Date().getFullYear()} Javier González Garcés`, 78 | }, 79 | docs: { 80 | sidebar: { 81 | hideable: true, 82 | }, 83 | }, 84 | } satisfies Preset.ThemeConfig, 85 | } 86 | 87 | module.exports = config 88 | -------------------------------------------------------------------------------- /apps/site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "site", 4 | "version": "0.0.0", 5 | "description": "Documentation site for mobx-keystone", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "docusaurus start", 9 | "build": "docusaurus build && shx cp -r ./copy-to-build/. ./build", 10 | "serve": "docusaurus serve" 11 | }, 12 | "dependencies": { 13 | "@docusaurus/core": "^3.7.0", 14 | "@docusaurus/preset-classic": "^3.7.0", 15 | "@easyops-cn/docusaurus-search-local": "^0.49.2", 16 | "@iframe-resizer/child": "^5.4.6", 17 | "@iframe-resizer/react": "^5.4.6", 18 | "bootstrap-icons": "^1.11.3", 19 | "bufferutil": "^4.0.9", 20 | "mobx-keystone": "workspace:packages/lib", 21 | "mobx-keystone-yjs": "workspace:packages/mobx-keystone-yjs", 22 | "mobx-react": "^9.2.0", 23 | "nanoid": "^3.3.11", 24 | "react": "^19.1.0", 25 | "react-dom": "^19.1.0", 26 | "remotedev": "^0.2.9", 27 | "utf-8-validate": "^6.0.5", 28 | "y-webrtc": "^10.3.0" 29 | }, 30 | "peerDependencies": { 31 | "mobx": "^6.0.0", 32 | "yjs": "^13.0.0" 33 | }, 34 | "devDependencies": { 35 | "@babel/plugin-proposal-decorators": "^7.27.1", 36 | "@docusaurus/module-type-aliases": "^3.7.0", 37 | "@docusaurus/tsconfig": "^3.7.0", 38 | "@svgr/webpack": "^8.1.0", 39 | "@types/react": "^19.1.2", 40 | "@types/react-dom": "^19.1.3", 41 | "@types/uuid": "^10.0.0", 42 | "raw-loader": "^4.0.2", 43 | "shx": "^0.4.0", 44 | "typescript": "^5.8.3" 45 | }, 46 | "browserslist": { 47 | "production": [ 48 | ">0.5%", 49 | "not dead", 50 | "not op_mini all" 51 | ], 52 | "development": [ 53 | "last 1 chrome version", 54 | "last 1 firefox version", 55 | "last 1 safari version" 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /apps/site/sidebars.ts: -------------------------------------------------------------------------------- 1 | import type { SidebarsConfig } from "@docusaurus/plugin-content-docs" 2 | 3 | const sidebars: SidebarsConfig = { 4 | docs: [ 5 | "intro", 6 | "installation", 7 | "mstComparison", 8 | "classModels", 9 | "dataModels", 10 | "standardAndStandaloneActions", 11 | "treeLikeStructure", 12 | "rootStores", 13 | "snapshots", 14 | "patches", 15 | "mapsSetsDates", 16 | { 17 | type: "category", 18 | label: "Action Middlewares", 19 | items: [ 20 | "actionMiddlewares/onActionMiddleware", 21 | "actionMiddlewares/transactionMiddleware", 22 | "actionMiddlewares/undoMiddleware", 23 | "actionMiddlewares/readonlyMiddleware", 24 | "actionMiddlewares/customMiddlewares", 25 | ], 26 | }, 27 | "contexts", 28 | "references", 29 | "frozen", 30 | "runtimeTypeChecking", 31 | "drafts", 32 | "sandboxes", 33 | "computedTrees", 34 | { 35 | type: "category", 36 | label: "Integrations", 37 | items: ["integrations/reduxCompatibility", "integrations/yjsBinding"], 38 | }, 39 | { 40 | type: "category", 41 | label: "Examples", 42 | items: [ 43 | "examples/todoList/todoList", 44 | "examples/clientServer/clientServer", 45 | "examples/yjsBinding/yjsBinding", 46 | ], 47 | }, 48 | ], 49 | } 50 | 51 | export default sidebars 52 | -------------------------------------------------------------------------------- /apps/site/src/components/ArrowUpIcon.tsx: -------------------------------------------------------------------------------- 1 | import CaretUpFillSVG from "bootstrap-icons/icons/caret-up-fill.svg" 2 | 3 | export function ArrowUpIcon() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /apps/site/src/components/CheckIcon.tsx: -------------------------------------------------------------------------------- 1 | import CheckCircleSVG from "bootstrap-icons/icons/check-circle.svg" 2 | 3 | export function CheckIcon() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /apps/site/src/components/RedXIcon.tsx: -------------------------------------------------------------------------------- 1 | import XCircleSVG from "bootstrap-icons/icons/x-circle.svg" 2 | 3 | export function RedXIcon() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /apps/site/src/css/custom.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --ifm-color-primary: #ff6600; 3 | --ifm-code-font-size: 85%; 4 | --ifm-footer-background-color: #f5f6f7; 5 | --ifm-navbar-search-input-icon: url('data:image/svg+xml;utf8,'); 6 | --collapse-button-bg-color-dark: rgba(255, 255, 255, 0.05) !important; 7 | --search-local-modal-background: #202020; 8 | } 9 | 10 | .navbar--dark { 11 | --ifm-navbar-background-color: #2d3539; 12 | --ifm-menu-color-background-hover: rgba(255, 255, 255, 0.08); 13 | --ifm-menu-color-background-active: rgba(255, 255, 255, 0.1); 14 | --ifm-navbar-search-input-background-color: rgba(255, 255, 255, 0.25); 15 | } 16 | 17 | .navbar__search-input:focus { 18 | outline: var(--ifm-color-primary) solid 2px; 19 | } 20 | 21 | .footer--dark { 22 | --ifm-footer-background-color: #2d3539; 23 | } 24 | 25 | svg.icon { 26 | display: inline; 27 | vertical-align: middle; 28 | } 29 | -------------------------------------------------------------------------------- /apps/site/src/pages/example-apps/yjs-binding.tsx: -------------------------------------------------------------------------------- 1 | import BrowserOnly from "@docusaurus/BrowserOnly" 2 | import { App } from "../../../docs/examples/yjsBinding/app" 3 | 4 | const Page = () => ( 5 | <> 6 | {() => } 7 | 8 | ) 9 | 10 | export default Page 11 | -------------------------------------------------------------------------------- /apps/site/src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | import * as React from "react" 3 | 4 | const ReactComponent: React.FunctionComponent> 5 | export default ReactComponent 6 | } 7 | -------------------------------------------------------------------------------- /apps/site/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xaviergonz/mobx-keystone/de729db85d94f290212895bdd17800c2b02a80ad/apps/site/static/.nojekyll -------------------------------------------------------------------------------- /apps/site/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xaviergonz/mobx-keystone/de729db85d94f290212895bdd17800c2b02a80ad/apps/site/static/img/favicon.ico -------------------------------------------------------------------------------- /apps/site/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xaviergonz/mobx-keystone/de729db85d94f290212895bdd17800c2b02a80ad/apps/site/static/img/logo.png -------------------------------------------------------------------------------- /apps/site/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /apps/site/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@docusaurus/tsconfig", 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "experimentalDecorators": true, 7 | "strict": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | redirects = [ 2 | {from = "/public/api/*", to = "/public/api/:splat"}, 3 | {from = "/*", to = "/", status = 200} 4 | ] 5 | 6 | [build] 7 | command = "yarn build-netlify" 8 | publish = "apps/site/build" 9 | environment = { NODE_VERSION = "23.11.0", YARN_VERSION = "4.9.1" } 10 | 11 | [dev] 12 | publish = "apps/site/build" 13 | port = 3333 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "root", 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "scripts": { 7 | "lib:build": "turbo run mobx-keystone#build", 8 | "lib:build-docs": "turbo run mobx-keystone#build-docs", 9 | "lib:test": "turbo run mobx-keystone#test", 10 | "lib:test:ci": "turbo run mobx-keystone#test:ci", 11 | "yjs-lib:build": "turbo run mobx-keystone-yjs#build", 12 | "yjs-lib:test": "turbo run mobx-keystone-yjs#test", 13 | "yjs-lib:test:ci": "turbo run mobx-keystone-yjs#test:ci", 14 | "site:start": "turbo run site#start", 15 | "site:build": "turbo run site#build", 16 | "site:serve": "turbo run site#serve", 17 | "build-netlify": "yarn site:build", 18 | "netlify-dev": "yarn build-netlify && netlify dev", 19 | "lint": "biome lint" 20 | }, 21 | "workspaces": [ 22 | "packages/*", 23 | "apps/*" 24 | ], 25 | "dependencies": { 26 | "mobx": "^6.13.7", 27 | "yjs": "^13.6.26" 28 | }, 29 | "devDependencies": { 30 | "@biomejs/biome": "^1.9.4", 31 | "globals": "^16.0.0", 32 | "netlify-cli": "^20.1.1", 33 | "turbo": "^2.5.2", 34 | "typescript": "^5.8.3" 35 | }, 36 | "packageManager": "yarn@4.9.1" 37 | } 38 | -------------------------------------------------------------------------------- /packages/lib/.gitignore: -------------------------------------------------------------------------------- 1 | /*.log 2 | /node_modules/ 3 | /dist/ 4 | /coverage/ 5 | /README.md 6 | /LICENSE 7 | /CHANGELOG.md 8 | /*.local 9 | /.swc 10 | /api-docs/ 11 | -------------------------------------------------------------------------------- /packages/lib/babel.config.js: -------------------------------------------------------------------------------- 1 | const { mobxVersion } = require("./env") 2 | 3 | module.exports = { 4 | presets: [ 5 | ["@babel/preset-env", { targets: { node: "current" } }], 6 | ["@babel/preset-typescript", { allowDeclareFields: true }], 7 | ], 8 | plugins: [ 9 | ["@babel/plugin-proposal-decorators", { version: "legacy" }], 10 | ["@babel/plugin-proposal-class-properties", { loose: mobxVersion <= 5 }], 11 | ], 12 | } 13 | -------------------------------------------------------------------------------- /packages/lib/env.js: -------------------------------------------------------------------------------- 1 | // if this file is changed then env.ts needs to be changed 2 | 3 | module.exports = { 4 | mobxVersion: Number(process.env.MOBX_VERSION || "6"), 5 | compiler: process.env.COMPILER || "tsc", 6 | } 7 | -------------------------------------------------------------------------------- /packages/lib/env.ts: -------------------------------------------------------------------------------- 1 | // if this file is changed then env.js needs to be changed 2 | 3 | export const mobxVersion = Number(process.env.MOBX_VERSION || "6") as 4 | 5 | 6 4 | export const compiler = process.env.COMPILER || "tsc" // tsc | tsc-experimental-decorators | babel | swc 5 | -------------------------------------------------------------------------------- /packages/lib/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "jest" 2 | 3 | import { mobxVersion, compiler } from "./env" 4 | 5 | const tsconfigFiles = { 6 | 6: compiler === "tsc" ? "tsconfig.json" : "tsconfig.experimental-decorators.json", 7 | 5: "tsconfig.mobx5.json", 8 | 4: "tsconfig.mobx4.json", 9 | } 10 | 11 | const mobxModuleNames = { 12 | 6: "mobx", 13 | 5: "mobx-v5", 14 | 4: "mobx-v4", 15 | } 16 | 17 | const tsconfigFile = tsconfigFiles[mobxVersion] 18 | const mobxModuleName = mobxModuleNames[mobxVersion] 19 | 20 | const config: Config = { 21 | setupFilesAfterEnv: ["./test/commonSetup.ts"], 22 | moduleNameMapper: { 23 | "^mobx$": mobxModuleName, 24 | }, 25 | prettierPath: null, 26 | } 27 | 28 | switch (compiler) { 29 | case "tsc": 30 | case "tsc-experimental-decorators": 31 | Object.assign(config, { 32 | preset: "ts-jest", 33 | testEnvironment: "node", 34 | transform: { 35 | "^.+\\.ts$": ["ts-jest", { tsconfig: `./test/${tsconfigFile}` }], 36 | }, 37 | }) 38 | break 39 | 40 | case "babel": 41 | break 42 | 43 | case "swc": 44 | Object.assign(config, { 45 | transform: { 46 | "^.+\\.ts$": ["@swc/jest", require("./swc.config.js")], 47 | }, 48 | }) 49 | break 50 | 51 | default: 52 | throw new Error("$COMPILER must be one of {tsc,tsc-experimental-decorators,babel,swc}") 53 | } 54 | 55 | export default config 56 | -------------------------------------------------------------------------------- /packages/lib/perf_bench/fixtures/fixture-models.ts: -------------------------------------------------------------------------------- 1 | import { computed } from "mobx" 2 | import { Frozen, Model, model, prop } from "../.." 3 | 4 | // tiny 5 | @model("Treasure") 6 | export class Treasure extends Model({ 7 | trapped: prop(), 8 | gold: prop(() => 0), 9 | }) {} 10 | 11 | // medium 12 | export const HeroRoles = ["warrior", "wizard", "cleric", "thief"] as const 13 | export type HeroRolesType = (typeof HeroRoles)[number] 14 | 15 | @model("Hero") 16 | export class Hero extends Model({ 17 | id: prop(), 18 | name: prop(), 19 | description: prop(), 20 | level: prop(() => 1), 21 | role: prop(), 22 | }) { 23 | @computed 24 | get descriptionLength() { 25 | return this.description.length 26 | } 27 | } 28 | 29 | // large 30 | @model("Monster") 31 | export class Monster extends Model({ 32 | id: prop(), 33 | freestyle: prop>(), 34 | level: prop(), 35 | maxHp: prop(), 36 | hp: prop(), 37 | warning: prop(), 38 | createdAt: prop(), 39 | treasures: prop(() => []), 40 | eatenHeroes: prop(() => []), 41 | hasFangs: prop(() => false), 42 | hasClaws: prop(() => false), 43 | hasWings: prop(() => false), 44 | hasGrowl: prop(() => false), 45 | stenchLevel: prop(() => 0), 46 | fearsFire: prop(() => false), 47 | fearsWater: prop(() => false), 48 | fearsWarriors: prop(() => false), 49 | fearsClerics: prop(() => false), 50 | fearsMages: prop(() => false), 51 | fearsThieves: prop(() => false), 52 | fearsProgrammers: prop(() => true), 53 | }) { 54 | @computed 55 | get isAlive() { 56 | return this.hp > 0 57 | } 58 | 59 | @computed 60 | get isFlashingRed() { 61 | return this.hp > 0 && this.hp < this.maxHp && this.hp === 1 62 | } 63 | 64 | @computed 65 | get weight() { 66 | const victimWeight = this.eatenHeroes ? this.eatenHeroes.length : 0 67 | const fangWeight = this.hasFangs ? 10 : 5 68 | const wingWeight = this.hasWings ? 12 : 4 69 | return (victimWeight + fangWeight + wingWeight) * this.level > 5 ? 2 : 1 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/lib/perf_bench/scenarios.ts: -------------------------------------------------------------------------------- 1 | import { fromSnapshot } from ".." 2 | import { createHeros, createMonsters, createTreasure } from "./fixtures/fixture-data" 3 | import { Hero, Monster, Treasure } from "./fixtures/fixture-models" 4 | import { start } from "./timer" 5 | 6 | /** 7 | * Covers models with a trivial number of fields. 8 | * 9 | * @param count The number of records to create. 10 | */ 11 | export function smallScenario(count: number) { 12 | const data = createTreasure(count) // ready? 13 | const time = start() 14 | const converted = data.map((d) => fromSnapshot(d)) // go 15 | const elapsed = time() 16 | const sanity = converted.length === count 17 | return { count, elapsed, sanity } 18 | } 19 | /** 20 | * Covers models with a moderate number of fields + 1 computed field. 21 | * 22 | * @param count The number of records to create. 23 | */ 24 | export function mediumScenario(count: number) { 25 | const data = createHeros(count) // ready? 26 | const time = start() 27 | const converted = data.map((d) => fromSnapshot(d)) // go 28 | const elapsed = time() 29 | const sanity = converted.length === count 30 | return { count, elapsed, sanity } 31 | } 32 | /** 33 | * Covers models with a large number of fields. 34 | * 35 | * @param count The number of records to create. 36 | * @param smallChildren The number of small children contained within. 37 | * @param mediumChildren The number of medium children contained within. 38 | */ 39 | export function largeScenario(count: number, smallChildren: number, mediumChildren: number) { 40 | const data = createMonsters(count, smallChildren, mediumChildren) // ready? 41 | const time = start() 42 | for (let i = 0; i < data.length; i++) { 43 | fromSnapshot(data[i]) // go 44 | } 45 | const elapsed = time() 46 | const sanity = true 47 | return { count, elapsed, sanity } 48 | } 49 | -------------------------------------------------------------------------------- /packages/lib/perf_bench/timer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Start a timer which return a function, which when called show the 3 | * number of milliseconds since it started. 4 | * 5 | * Passing true will give the current lap time. 6 | * 7 | * Example: 8 | * ```ts 9 | * const time = start() 10 | * // 1 second later 11 | * time() // 1.00 12 | * // 1 more second later 13 | * time() // 2.00 14 | * time(true) // 1.00 15 | * ``` 16 | */ 17 | export const start = () => { 18 | const started = process.hrtime() 19 | let last: [number, number] = [started[0], started[1]] 20 | return (lapTime = false) => { 21 | const final = process.hrtime(lapTime ? last : started) 22 | return Math.round((final[0] * 1e9 + final[1]) / 1e6) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/lib/perf_bench/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2020", 5 | "module": "commonjs", 6 | "rootDir": ".." 7 | }, 8 | "include": [".", "../src"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/lib/src/action/applyDelete.ts: -------------------------------------------------------------------------------- 1 | import { remove } from "mobx" 2 | import { assertTweakedObject } from "../tweaker/core" 3 | import { lazy } from "../utils" 4 | import { BuiltInAction } from "./builtInActions" 5 | import { ActionContextActionType } from "./context" 6 | import { wrapInAction } from "./wrapInAction" 7 | 8 | /** 9 | * Deletes an object field wrapped in an action. 10 | * 11 | * @param node Target object. 12 | * @param fieldName Field name. 13 | */ 14 | export function applyDelete(node: O, fieldName: K): void { 15 | assertTweakedObject(node, "node", true) 16 | 17 | wrappedInternalApplyDelete().call(node, fieldName as string | number) 18 | } 19 | 20 | /** 21 | * @internal 22 | */ 23 | export function internalApplyDelete(this: O, fieldName: string | number): void { 24 | remove(this, String(fieldName)) 25 | } 26 | 27 | const wrappedInternalApplyDelete = lazy(() => 28 | wrapInAction({ 29 | nameOrNameFn: BuiltInAction.ApplyDelete, 30 | fn: internalApplyDelete, 31 | actionType: ActionContextActionType.Sync, 32 | }) 33 | ) 34 | -------------------------------------------------------------------------------- /packages/lib/src/action/applyMethodCall.ts: -------------------------------------------------------------------------------- 1 | import { AnyFunction } from "../utils/AnyFunction" 2 | import { assertTweakedObject } from "../tweaker/core" 3 | import { lazy } from "../utils" 4 | import { BuiltInAction } from "./builtInActions" 5 | import { ActionContextActionType } from "./context" 6 | import { wrapInAction } from "./wrapInAction" 7 | 8 | /** 9 | * Calls an object method wrapped in an action. 10 | * 11 | * @param node Target object. 12 | * @param methodName Method name. 13 | */ 14 | export function applyMethodCall( 15 | node: O, 16 | methodName: K, 17 | ...args: FN extends AnyFunction ? Parameters : never 18 | ): FN extends AnyFunction ? ReturnType : never { 19 | assertTweakedObject(node, "node") 20 | 21 | return wrappedInternalApplyMethodCall().call(node, methodName as string | number, args) 22 | } 23 | 24 | /** 25 | * @internal 26 | */ 27 | export function internalApplyMethodCall(this: any, methodName: string | number, args: any[]): any { 28 | return this[methodName](...args) 29 | } 30 | 31 | const wrappedInternalApplyMethodCall = lazy(() => 32 | wrapInAction({ 33 | nameOrNameFn: BuiltInAction.ApplyMethodCall, 34 | fn: internalApplyMethodCall, 35 | actionType: ActionContextActionType.Sync, 36 | }) 37 | ) 38 | -------------------------------------------------------------------------------- /packages/lib/src/action/applySet.ts: -------------------------------------------------------------------------------- 1 | import { isObservable } from "mobx" 2 | import { isModel } from "../model/utils" 3 | import { assertTweakedObject } from "../tweaker/core" 4 | import { lazy } from "../utils" 5 | import { setIfDifferent } from "../utils/setIfDifferent" 6 | import { BuiltInAction } from "./builtInActions" 7 | import { ActionContextActionType } from "./context" 8 | import { wrapInAction } from "./wrapInAction" 9 | 10 | /** 11 | * Sets an object field wrapped in an action. 12 | * 13 | * @param node Target object. 14 | * @param fieldName Field name. 15 | * @param value Value to set. 16 | */ 17 | export function applySet( 18 | node: O, 19 | fieldName: K, 20 | value: V 21 | ): void { 22 | assertTweakedObject(node, "node", true) 23 | 24 | wrappedInternalApplySet().call(node, fieldName as string | number, value) 25 | } 26 | 27 | function internalApplySet(this: O, fieldName: string | number, value: any): void { 28 | // we need to check if it is a model since models can become observable objects 29 | // (e.g. by having a computed value) 30 | if (!isModel(this) && isObservable(this)) { 31 | setIfDifferent(this, fieldName, value) 32 | } else { 33 | ;(this as any)[fieldName] = value 34 | } 35 | } 36 | 37 | const wrappedInternalApplySet = lazy(() => 38 | wrapInAction({ 39 | nameOrNameFn: BuiltInAction.ApplySet, 40 | fn: internalApplySet, 41 | actionType: ActionContextActionType.Sync, 42 | }) 43 | ) 44 | -------------------------------------------------------------------------------- /packages/lib/src/action/builtInActions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A built-in action. 3 | */ 4 | export enum BuiltInAction { 5 | /** 6 | * applyPatches 7 | */ 8 | ApplyPatches = "$$applyPatches", 9 | /** 10 | * applySnapshot 11 | */ 12 | ApplySnapshot = "$$applySnapshot", 13 | /** 14 | * detach 15 | */ 16 | Detach = "$$detach", 17 | /** 18 | * applySet 19 | */ 20 | ApplySet = "$$applySet", 21 | /** 22 | * applyDelete 23 | */ 24 | ApplyDelete = "$$applyDelete", 25 | /** 26 | * applyMethodCall 27 | */ 28 | ApplyMethodCall = "$$applyMethodCall", 29 | } 30 | 31 | const builtInActionValues: ReadonlySet = new Set(Object.values(BuiltInAction)) 32 | 33 | /** 34 | * Returns if a given action name is a built-in action, this is, one of: 35 | * - applyPatches() 36 | * - applySnapshot() 37 | * - detach() 38 | * 39 | * @param actionName Action name to check. 40 | * @returns true if it is a built-in action, false otherwise. 41 | */ 42 | export function isBuiltInAction(actionName: string): actionName is BuiltInAction { 43 | return builtInActionValues.has(actionName) 44 | } 45 | -------------------------------------------------------------------------------- /packages/lib/src/action/hookActions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A hook action. 3 | */ 4 | export enum HookAction { 5 | /** 6 | * onInit hook 7 | */ 8 | OnInit = "$$onInit", 9 | /** 10 | * onLazyInit hook 11 | */ 12 | OnLazyInit = "$$onLazyInit", 13 | /** 14 | * onAttachedToRootStore hook 15 | */ 16 | OnAttachedToRootStore = "$$onAttachedToRootStore", 17 | /** 18 | * disposer for onAttachedToRootStore hook 19 | */ 20 | OnAttachedToRootStoreDisposer = "$$onAttachedToRootStoreDisposer", 21 | } 22 | 23 | const hookActionValues: ReadonlySet = new Set(Object.values(HookAction)) 24 | 25 | /** 26 | * Returns if a given action name corresponds to a hook, this is, one of: 27 | * - onInit() hook 28 | * - onLazyInit() hook 29 | * - onAttachedToRootStore() hook 30 | * - disposer returned by a onAttachedToRootStore() hook 31 | * 32 | * @param actionName Action name to check. 33 | * @returns true if it is a hook, false otherwise. 34 | */ 35 | export function isHookAction(actionName: string): actionName is HookAction { 36 | return hookActionValues.has(actionName) 37 | } 38 | -------------------------------------------------------------------------------- /packages/lib/src/action/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./applyAction" 2 | export * from "./applyDelete" 3 | export * from "./applyMethodCall" 4 | export * from "./applySet" 5 | export * from "./builtInActions" 6 | export * from "./context" 7 | export * from "./hookActions" 8 | export * from "./isModelAction" 9 | export * from "./middleware" 10 | export * from "./modelAction" 11 | export * from "./modelFlow" 12 | export * from "./runUnprotected" 13 | -------------------------------------------------------------------------------- /packages/lib/src/action/isModelAction.ts: -------------------------------------------------------------------------------- 1 | import { AnyFunction } from "../utils/AnyFunction" 2 | 3 | /** 4 | * @internal 5 | */ 6 | export const modelActionSymbol = Symbol("modelAction") 7 | 8 | /** 9 | * Returns if the given function is a model action or not. 10 | * 11 | * @param fn Function to check. 12 | * @returns 13 | */ 14 | export function isModelAction(fn: AnyFunction): boolean { 15 | return typeof fn === "function" && modelActionSymbol in fn 16 | } 17 | -------------------------------------------------------------------------------- /packages/lib/src/action/modelAction.ts: -------------------------------------------------------------------------------- 1 | import { failure } from "../utils" 2 | import { decorateWrapMethodOrField } from "../utils/decorators" 3 | import { ActionContextActionType } from "./context" 4 | import { isModelAction } from "./isModelAction" 5 | import { wrapInAction } from "./wrapInAction" 6 | 7 | /** 8 | * Decorator that turns a function into a model action. 9 | */ 10 | export function modelAction(...args: any[]): void { 11 | // biome-ignore lint/correctness/noVoidTypeReturn: 12 | return decorateWrapMethodOrField("modelAction", args, (data, fn) => { 13 | if (isModelAction(fn)) { 14 | return fn 15 | } else { 16 | if (typeof fn !== "function") { 17 | throw failure("modelAction has to be used over functions") 18 | } 19 | 20 | return wrapInAction({ 21 | nameOrNameFn: data.actionName, 22 | fn, 23 | actionType: ActionContextActionType.Sync, 24 | overrideContext: data.overrideContext, 25 | }) 26 | } 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /packages/lib/src/action/pendingActions.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentActionContext } from "./context" 2 | import { getActionProtection } from "./protection" 3 | 4 | const pendingActions: (() => void)[] = [] 5 | 6 | function isActionRunning(): boolean { 7 | return !getActionProtection() || !!getCurrentActionContext() 8 | } 9 | 10 | /** 11 | * @internal 12 | */ 13 | export function enqueuePendingAction(action: () => void): void { 14 | // delay action until all current actions are finished 15 | if (isActionRunning()) { 16 | pendingActions.push(action) 17 | } else { 18 | action() 19 | } 20 | } 21 | 22 | let pendingActionsRunning = false 23 | 24 | /** 25 | * @internal 26 | */ 27 | export function tryRunPendingActions(): void { 28 | if (isActionRunning() || pendingActionsRunning) { 29 | return 30 | } 31 | 32 | pendingActionsRunning = true 33 | 34 | try { 35 | while (pendingActions.length > 0) { 36 | const nextAction = pendingActions.shift()! 37 | nextAction() 38 | } 39 | } finally { 40 | pendingActionsRunning = false 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/lib/src/action/protection.ts: -------------------------------------------------------------------------------- 1 | import { failure } from "../utils" 2 | import { getCurrentActionContext } from "./context" 3 | 4 | function canWrite(): boolean { 5 | return !getActionProtection() || !!getCurrentActionContext() 6 | } 7 | 8 | /** 9 | * @internal 10 | */ 11 | export function assertCanWrite() { 12 | if (!canWrite()) { 13 | throw failure("data changes must be performed inside model actions") 14 | } 15 | } 16 | 17 | let actionProtection = true 18 | 19 | /** 20 | * @internal 21 | * 22 | * Gets if the action protection is currently enabled or not. 23 | * 24 | * @returns 25 | */ 26 | export function getActionProtection() { 27 | return actionProtection 28 | } 29 | 30 | /** 31 | * @internal 32 | * 33 | * Sets if the action protection is currently enabled or not. 34 | */ 35 | export function setActionProtection(protection: boolean) { 36 | actionProtection = protection 37 | } 38 | -------------------------------------------------------------------------------- /packages/lib/src/action/runUnprotected.ts: -------------------------------------------------------------------------------- 1 | import { action } from "mobx" 2 | import { tryRunPendingActions } from "./pendingActions" 3 | import { getActionProtection, setActionProtection } from "./protection" 4 | 5 | /** 6 | * Runs a block in unprocted mode, as if it were run inside a model action. 7 | * Consider using a proper model action instead since these kind of actions are not recorded. 8 | * 9 | * @template T Return type. 10 | * @param name Mobx action name. 11 | * @param fn Action block. 12 | * @returns 13 | */ 14 | export function runUnprotected(name: string, fn: () => T): T 15 | 16 | /** 17 | * Runs a block in unprocted mode, as if it were run inside a model action. 18 | * Consider using a proper model action instead since these kind of actions are not recorded. 19 | * 20 | * @template T Return type. 21 | * @param fn Action block. 22 | * @returns 23 | */ 24 | export function runUnprotected(fn: () => T): T 25 | 26 | // base case 27 | export function runUnprotected(arg1: any, arg2?: any): T { 28 | const name = typeof arg1 === "string" ? arg1 : undefined 29 | const fn: () => T = typeof arg1 === "string" ? arg2 : arg1 30 | 31 | const innerAction = () => { 32 | const oldActionProtection = getActionProtection() 33 | setActionProtection(false) 34 | 35 | try { 36 | return fn() 37 | } finally { 38 | setActionProtection(oldActionProtection) 39 | 40 | tryRunPendingActions() 41 | } 42 | } 43 | 44 | if (name) { 45 | return action(name, innerAction)() 46 | } else { 47 | return action(innerAction)() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/lib/src/actionMiddlewares/actionSerialization/arraySerializer.ts: -------------------------------------------------------------------------------- 1 | import type { IObservableArray } from "mobx" 2 | import { isArray, namespace } from "../../utils" 3 | import { ActionCallArgumentSerializer, cannotSerialize } from "./core" 4 | 5 | export const arraySerializer: ActionCallArgumentSerializer, any[]> = { 6 | id: `${namespace}/array`, 7 | 8 | serialize(value, serialize) { 9 | if (!isArray(value)) { 10 | return cannotSerialize 11 | } 12 | 13 | // this will also transform observable arrays into non-observable ones 14 | return value.map(serialize) 15 | }, 16 | 17 | deserialize(arr, deserialize) { 18 | return arr.map(deserialize) 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /packages/lib/src/actionMiddlewares/actionSerialization/core.ts: -------------------------------------------------------------------------------- 1 | export const cannotSerialize = Symbol("cannotSerialize") 2 | 3 | /** 4 | * Serializer of action call arguments. 5 | */ 6 | export interface ActionCallArgumentSerializer { 7 | /** 8 | * Serializer ID, must be unique. 9 | */ 10 | id: string 11 | 12 | /** 13 | * Serializes an action call argument, returning `cannotSerialize` if not possible. 14 | * 15 | * @param value Value to serialize. 16 | * @param targetRoot Target root, if provided. 17 | * @param serializeChild Serialize a child. 18 | * @returns 19 | */ 20 | serialize( 21 | value: unknown, 22 | serializeChild: (v: unknown) => unknown, 23 | targetRoot: object | undefined 24 | ): TSerialized | typeof cannotSerialize 25 | 26 | /** 27 | * Deserializes an action call argument. 28 | * 29 | * @param value Value to deserialize. 30 | * @param targetRoot Target root, if provided. 31 | * @param deserializeChild Deserialize a child. 32 | * @returns 33 | */ 34 | deserialize( 35 | value: TSerialized, 36 | deserializeChild: (v: unknown) => unknown, 37 | targetRoot: object | undefined 38 | ): TOriginal 39 | } 40 | -------------------------------------------------------------------------------- /packages/lib/src/actionMiddlewares/actionSerialization/dateSerializer.ts: -------------------------------------------------------------------------------- 1 | import { namespace } from "../../utils" 2 | import { ActionCallArgumentSerializer, cannotSerialize } from "./core" 3 | 4 | export const dateSerializer: ActionCallArgumentSerializer = { 5 | id: `${namespace}/dateAsTimestamp`, 6 | 7 | serialize(date) { 8 | if (!(date instanceof Date)) { 9 | return cannotSerialize 10 | } 11 | return +date 12 | }, 13 | 14 | deserialize(timestamp) { 15 | return new Date(timestamp) 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /packages/lib/src/actionMiddlewares/actionSerialization/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./actionSerialization" 2 | export * from "./applySerializedAction" 3 | export * from "./core" 4 | -------------------------------------------------------------------------------- /packages/lib/src/actionMiddlewares/actionSerialization/mapSerializer.ts: -------------------------------------------------------------------------------- 1 | import { isObservableMap, ObservableMap } from "mobx" 2 | import { namespace } from "../../utils" 3 | import { ActionCallArgumentSerializer, cannotSerialize } from "./core" 4 | 5 | export const mapSerializer: ActionCallArgumentSerializer< 6 | Map | ObservableMap, 7 | [any, any][] 8 | > = { 9 | id: `${namespace}/mapAsArray`, 10 | 11 | serialize(map, serialize) { 12 | if (!(map instanceof Map || isObservableMap(map))) { 13 | return cannotSerialize 14 | } 15 | 16 | const arr: [any, any][] = [] 17 | 18 | const iter = map.keys() 19 | let cur = iter.next() 20 | while (!cur.done) { 21 | const k = cur.value 22 | const v = map.get(k) 23 | arr.push([serialize(k), serialize(v)]) 24 | cur = iter.next() 25 | } 26 | 27 | return arr 28 | }, 29 | 30 | deserialize(arr, deserialize) { 31 | const map = new Map() 32 | 33 | const len = arr.length 34 | for (let i = 0; i < len; i++) { 35 | const k = arr[i][0] 36 | const v = arr[i][1] 37 | map.set(deserialize(k), deserialize(v)) 38 | } 39 | 40 | return map 41 | }, 42 | } 43 | -------------------------------------------------------------------------------- /packages/lib/src/actionMiddlewares/actionSerialization/objectPathSerializer.ts: -------------------------------------------------------------------------------- 1 | import { fastGetRootPath, resolvePathCheckingIds } from "../../parent/path" 2 | import { Path } from "../../parent/pathTypes" 3 | import { isTweakedObject } from "../../tweaker/core" 4 | import { failure, namespace } from "../../utils" 5 | import { rootPathToTargetPathIds } from "../utils" 6 | import { ActionCallArgumentSerializer, cannotSerialize } from "./core" 7 | 8 | interface ObjectPath { 9 | targetPath: Path 10 | targetPathIds: (string | null)[] 11 | } 12 | 13 | export const objectPathSerializer: ActionCallArgumentSerializer = { 14 | id: `${namespace}/objectPath`, 15 | 16 | serialize(value, _, targetRoot) { 17 | if (typeof value !== "object" || value === null || !isTweakedObject(value, false)) { 18 | return cannotSerialize 19 | } 20 | 21 | // try to serialize a ref to its path if possible instead 22 | if (targetRoot) { 23 | const rootPath = fastGetRootPath(value, false) 24 | if (rootPath.root === targetRoot) { 25 | return { 26 | targetPath: rootPath.path, 27 | targetPathIds: rootPathToTargetPathIds(rootPath), 28 | } as ObjectPath 29 | } 30 | } 31 | 32 | return cannotSerialize 33 | }, 34 | 35 | deserialize(ref, _, targetRoot) { 36 | // try to resolve the node back 37 | if (targetRoot) { 38 | const result = resolvePathCheckingIds(targetRoot, ref.targetPath, ref.targetPathIds) 39 | if (result.resolved) { 40 | return result.value 41 | } 42 | } 43 | 44 | throw failure( 45 | `object at path ${JSON.stringify(ref.targetPath)} with ids ${JSON.stringify( 46 | ref.targetPathIds 47 | )} could not be resolved` 48 | ) 49 | }, 50 | } 51 | -------------------------------------------------------------------------------- /packages/lib/src/actionMiddlewares/actionSerialization/objectSnapshotSerializer.ts: -------------------------------------------------------------------------------- 1 | import { fromSnapshot } from "../../snapshot/fromSnapshot" 2 | import { getSnapshot } from "../../snapshot/getSnapshot" 3 | import { isTweakedObject } from "../../tweaker/core" 4 | import { namespace } from "../../utils" 5 | import { ActionCallArgumentSerializer, cannotSerialize } from "./core" 6 | 7 | export const objectSnapshotSerializer: ActionCallArgumentSerializer = { 8 | id: `${namespace}/objectSnapshot`, 9 | 10 | serialize(value) { 11 | if (typeof value !== "object" || value === null || !isTweakedObject(value, false)) { 12 | return cannotSerialize 13 | } 14 | 15 | return getSnapshot(value) 16 | }, 17 | 18 | deserialize(snapshot) { 19 | return fromSnapshot(snapshot) 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /packages/lib/src/actionMiddlewares/actionSerialization/plainObjectSerializer.ts: -------------------------------------------------------------------------------- 1 | import { isObservableObject } from "mobx" 2 | import { isPlainObject, namespace } from "../../utils" 3 | import { ActionCallArgumentSerializer, cannotSerialize } from "./core" 4 | 5 | export const plainObjectSerializer: ActionCallArgumentSerializer = { 6 | id: `${namespace}/plainObject`, 7 | 8 | serialize(value, serialize) { 9 | if (!(isPlainObject(value) || isObservableObject(value))) { 10 | return cannotSerialize 11 | } 12 | 13 | // this will make observable objects non-observable ones 14 | return mapObjectFields(value, serialize) 15 | }, 16 | 17 | deserialize(obj, serialize) { 18 | return mapObjectFields(obj, serialize) 19 | }, 20 | } 21 | 22 | function mapObjectFields(originalObj: any, mapFn: (x: any) => any): any { 23 | const obj: any = {} 24 | const keys = Object.keys(originalObj) 25 | const len = keys.length 26 | for (let i = 0; i < len; i++) { 27 | const k = keys[i] 28 | const v = originalObj[k] 29 | obj[k] = mapFn(v) 30 | } 31 | return obj 32 | } 33 | -------------------------------------------------------------------------------- /packages/lib/src/actionMiddlewares/actionSerialization/primitiveSerializer.ts: -------------------------------------------------------------------------------- 1 | import { namespace } from "../../utils" 2 | import { ActionCallArgumentSerializer, cannotSerialize } from "./core" 3 | 4 | export const primitiveSerializer: ActionCallArgumentSerializer< 5 | number | bigint | undefined, 6 | string 7 | > = { 8 | id: `${namespace}/primitiveAsString`, 9 | 10 | serialize(value) { 11 | // number 12 | if (Number.isNaN(value)) { 13 | return "nan" 14 | } 15 | switch (value) { 16 | case Number.POSITIVE_INFINITY: 17 | return "+inf" 18 | case Number.NEGATIVE_INFINITY: 19 | return "-inf" 20 | default: 21 | break 22 | } 23 | 24 | // bigint 25 | if (typeof value === "bigint") { 26 | return value.toString() 27 | } 28 | 29 | // undefined 30 | if (value === undefined) { 31 | return "undefined" 32 | } 33 | 34 | return cannotSerialize 35 | }, 36 | 37 | deserialize(str) { 38 | switch (str) { 39 | case "nan": 40 | return Number.NaN 41 | case "+inf": 42 | return Number.POSITIVE_INFINITY 43 | case "-inf": 44 | return Number.NEGATIVE_INFINITY 45 | case "undefined": 46 | return undefined 47 | default: 48 | return BigInt(str) 49 | } 50 | }, 51 | } 52 | -------------------------------------------------------------------------------- /packages/lib/src/actionMiddlewares/actionSerialization/setSerializer.ts: -------------------------------------------------------------------------------- 1 | import type { ObservableSet } from "mobx" 2 | import { namespace } from "../../utils" 3 | import { ActionCallArgumentSerializer, cannotSerialize } from "./core" 4 | 5 | export const setSerializer: ActionCallArgumentSerializer | ObservableSet, any[]> = { 6 | id: `${namespace}/setAsArray`, 7 | 8 | serialize(set, serialize) { 9 | if (!(set instanceof Set)) { 10 | return cannotSerialize 11 | } 12 | 13 | const arr: any[] = [] 14 | 15 | const iter = set.keys() 16 | let cur = iter.next() 17 | while (!cur.done) { 18 | const k = cur.value 19 | arr.push(serialize(k)) 20 | cur = iter.next() 21 | } 22 | 23 | return arr 24 | }, 25 | 26 | deserialize(arr, deserialize) { 27 | const set = new Set() 28 | 29 | const len = arr.length 30 | for (let i = 0; i < len; i++) { 31 | const k = arr[i] 32 | set.add(deserialize(k)) 33 | } 34 | 35 | return set 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /packages/lib/src/actionMiddlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./actionSerialization" 2 | export * from "./actionTrackingMiddleware" 3 | export * from "./onActionMiddleware" 4 | export * from "./readonlyMiddleware" 5 | export * from "./transactionMiddleware" 6 | export * from "./undoMiddleware" 7 | -------------------------------------------------------------------------------- /packages/lib/src/actionMiddlewares/utils.ts: -------------------------------------------------------------------------------- 1 | import { modelIdKey } from "../model/metadata" 2 | import { isModel } from "../model/utils" 3 | import type { RootPath } from "../parent/path" 4 | import type { Path } from "../parent/pathTypes" 5 | 6 | /** 7 | * @internal 8 | */ 9 | export function rootPathToTargetPathIds(rootPath: RootPath): (string | null)[] { 10 | const targetPathIds: (string | null)[] = [] 11 | 12 | for (let i = 0; i < rootPath.path.length; i++) { 13 | const targetObj = rootPath.pathObjects[i + 1] // first is root, we don't care about its ID 14 | const targetObjId = isModel(targetObj) ? targetObj[modelIdKey] ?? null : null 15 | targetPathIds.push(targetObjId) 16 | } 17 | 18 | return targetPathIds 19 | } 20 | 21 | /** 22 | * @internal 23 | */ 24 | export function pathToTargetPathIds(root: any, path: Path): (string | null)[] { 25 | const targetPathIds: (string | null)[] = [] 26 | let current = root // we don't care about the root ID 27 | 28 | for (let i = 0; i < path.length; i++) { 29 | current = current[path[i]] 30 | const targetObjId = isModel(current) ? current[modelIdKey] ?? null : null 31 | targetPathIds.push(targetObjId) 32 | } 33 | 34 | return targetPathIds 35 | } 36 | -------------------------------------------------------------------------------- /packages/lib/src/computedTree/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./computedTree" 2 | -------------------------------------------------------------------------------- /packages/lib/src/context/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./context" 2 | -------------------------------------------------------------------------------- /packages/lib/src/dataModel/DataModelConstructorOptions.ts: -------------------------------------------------------------------------------- 1 | import type { ModelClass } from "../modelShared/BaseModelShared" 2 | import type { AnyDataModel } from "./BaseDataModel" 3 | 4 | /** 5 | * @internal 6 | */ 7 | export interface DataModelConstructorOptions { 8 | modelClass?: ModelClass 9 | } 10 | -------------------------------------------------------------------------------- /packages/lib/src/dataModel/actions.ts: -------------------------------------------------------------------------------- 1 | import { ModelClass } from "../modelShared/BaseModelShared" 2 | import type { AnyDataModel } from "./BaseDataModel" 3 | 4 | const dataModelActionRegistry = new Map< 5 | string, 6 | { 7 | modelClass: ModelClass 8 | fnName: string 9 | } 10 | >() 11 | 12 | /** 13 | * @internal 14 | */ 15 | export function getDataModelAction(fullActionName: string) { 16 | return dataModelActionRegistry.get(fullActionName) 17 | } 18 | 19 | /** 20 | * @internal 21 | */ 22 | export function setDataModelAction( 23 | fullActionName: string, 24 | modelClass: ModelClass, 25 | fnName: string 26 | ) { 27 | dataModelActionRegistry.set(fullActionName, { 28 | modelClass, 29 | fnName, 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /packages/lib/src/dataModel/getDataModelMetadata.ts: -------------------------------------------------------------------------------- 1 | import type { ModelClass } from "../modelShared/BaseModelShared" 2 | import { modelMetadataSymbol } from "../modelShared/modelSymbols" 3 | import type { AnyType } from "../types/schemas" 4 | import { failure } from "../utils" 5 | import type { AnyDataModel } from "./BaseDataModel" 6 | import { isDataModel, isDataModelClass } from "./utils" 7 | 8 | /** 9 | * Associated data model metadata. 10 | */ 11 | export interface DataModelMetadata { 12 | /** 13 | * Associated data type for runtime checking (if any). 14 | */ 15 | dataType?: AnyType 16 | } 17 | 18 | /** 19 | * Returns the associated metadata for a data model instance or class. 20 | * 21 | * @param modelClassOrInstance Data model class or instance. 22 | * @returns The associated metadata. 23 | */ 24 | export function getDataModelMetadata( 25 | modelClassOrInstance: AnyDataModel | ModelClass 26 | ): DataModelMetadata { 27 | if (isDataModel(modelClassOrInstance)) { 28 | return (modelClassOrInstance as any).constructor[modelMetadataSymbol] 29 | } else if (isDataModelClass(modelClassOrInstance)) { 30 | return (modelClassOrInstance as any)[modelMetadataSymbol] 31 | } else { 32 | throw failure(`modelClassOrInstance must be a model class or instance`) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/lib/src/dataModel/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./BaseDataModel" 2 | export * from "./DataModel" 3 | export * from "./getDataModelMetadata" 4 | export * from "./utils" 5 | -------------------------------------------------------------------------------- /packages/lib/src/dataModel/newDataModel.ts: -------------------------------------------------------------------------------- 1 | import { action } from "mobx" 2 | import type { O } from "ts-toolbelt" 3 | import { isModelAutoTypeCheckingEnabled } from "../globalConfig/globalConfig" 4 | import type { ModelUntransformedData } from "../modelShared/BaseModelShared" 5 | import { modelInfoByClass } from "../modelShared/modelInfo" 6 | import { applyModelInitializers } from "../modelShared/newModel" 7 | import { failure, inDevMode, makePropReadonly } from "../utils" 8 | import type { AnyDataModel } from "./BaseDataModel" 9 | import type { DataModelConstructorOptions } from "./DataModelConstructorOptions" 10 | import { getDataModelMetadata } from "./getDataModelMetadata" 11 | import { assertIsDataModelClass } from "./utils" 12 | 13 | /** 14 | * @internal 15 | */ 16 | export const internalNewDataModel = action( 17 | "newModel", 18 | ( 19 | origModelObj: M, 20 | tweakedData: ModelUntransformedData, 21 | options: Pick 22 | ): M => { 23 | const { modelClass: _modelClass } = options 24 | const modelClass = _modelClass! 25 | 26 | if (inDevMode) { 27 | assertIsDataModelClass(modelClass, "modelClass") 28 | } 29 | 30 | const modelObj = origModelObj as O.Writable 31 | 32 | const modelInfo = modelInfoByClass.get(modelClass) 33 | if (!modelInfo) { 34 | throw failure( 35 | `no model info for class ${modelClass.name} could be found - did you forget to add the @model decorator?` 36 | ) 37 | } 38 | 39 | // link it, and make it readonly 40 | modelObj.$ = tweakedData 41 | if (inDevMode) { 42 | makePropReadonly(modelObj, "$", true) 43 | } 44 | 45 | // run any extra initializers for the class as needed 46 | applyModelInitializers(modelClass, modelObj) 47 | 48 | // type check it if needed 49 | if (isModelAutoTypeCheckingEnabled() && getDataModelMetadata(modelClass).dataType) { 50 | const err = modelObj.typeCheck() 51 | if (err) { 52 | err.throw() 53 | } 54 | } 55 | 56 | return modelObj as M 57 | } 58 | ) 59 | -------------------------------------------------------------------------------- /packages/lib/src/dataModel/utils.ts: -------------------------------------------------------------------------------- 1 | import type { ModelClass } from "../modelShared/BaseModelShared" 2 | import { failure } from "../utils" 3 | import type { AnyDataModel } from "./BaseDataModel" 4 | import { BaseDataModel } from "./BaseDataModel" 5 | 6 | /** 7 | * Checks if an object is a data model instance. 8 | * 9 | * @param model 10 | * @returns 11 | */ 12 | export function isDataModel(model: unknown): model is AnyDataModel { 13 | return model instanceof BaseDataModel 14 | } 15 | 16 | /** 17 | * @internal 18 | * 19 | * Asserts something is actually a data model. 20 | * 21 | * @param model 22 | * @param argName 23 | */ 24 | export function assertIsDataModel( 25 | model: unknown, 26 | argName: string, 27 | customErrMsg = "must be a data model instance" 28 | ): asserts model is AnyDataModel { 29 | if (!isDataModel(model)) { 30 | throw failure(`${argName} ${customErrMsg}`) 31 | } 32 | } 33 | 34 | /** 35 | * @internal 36 | */ 37 | export function isDataModelClass(modelClass: unknown): modelClass is ModelClass { 38 | if (typeof modelClass !== "function") { 39 | return false 40 | } 41 | 42 | if (modelClass !== BaseDataModel && !(modelClass.prototype instanceof BaseDataModel)) { 43 | return false 44 | } 45 | 46 | return true 47 | } 48 | 49 | /** 50 | * @internal 51 | */ 52 | export function assertIsDataModelClass( 53 | modelClass: unknown, 54 | argName: string 55 | ): asserts modelClass is ModelClass { 56 | if (typeof modelClass !== "function") { 57 | throw failure(`${argName} must be a class`) 58 | } 59 | 60 | if (modelClass !== BaseDataModel && !(modelClass.prototype instanceof BaseDataModel)) { 61 | throw failure(`${argName} must extend DataModel`) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/lib/src/frozen/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Frozen" 2 | -------------------------------------------------------------------------------- /packages/lib/src/globalConfig/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./globalConfig" 2 | -------------------------------------------------------------------------------- /packages/lib/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./action" 2 | export * from "./actionMiddlewares" 3 | export * from "./computedTree" 4 | export * from "./context" 5 | export * from "./dataModel" 6 | export * from "./frozen" 7 | export * from "./globalConfig" 8 | export * from "./model" 9 | export * from "./modelShared" 10 | export * from "./parent" 11 | export * from "./patch" 12 | export * from "./redux" 13 | export * from "./ref" 14 | export * from "./rootStore" 15 | export * from "./snapshot" 16 | export * from "./standardActions" 17 | export * from "./transforms" 18 | export * from "./treeUtils" 19 | export * from "./tweaker" 20 | export * from "./types" 21 | export * from "./utils" 22 | export * from "./utils/tag" 23 | export * from "./wrappers" 24 | -------------------------------------------------------------------------------- /packages/lib/src/model/ModelConstructorOptions.ts: -------------------------------------------------------------------------------- 1 | import type { ModelClass } from "../modelShared/BaseModelShared" 2 | import type { AnyModel } from "./BaseModel" 3 | 4 | /** 5 | * @internal 6 | */ 7 | export interface ModelConstructorOptions { 8 | snapshotInitialData?: { 9 | unprocessedSnapshot: any 10 | unprocessedModelType: unknown 11 | snapshotToInitialData(processedSnapshot: any): any 12 | } 13 | modelClass?: ModelClass 14 | generateNewIds?: boolean 15 | } 16 | -------------------------------------------------------------------------------- /packages/lib/src/model/getModelMetadata.ts: -------------------------------------------------------------------------------- 1 | import type { ModelClass } from "../modelShared/BaseModelShared" 2 | import { modelMetadataSymbol } from "../modelShared/modelSymbols" 3 | import type { AnyType } from "../types/schemas" 4 | import { failure } from "../utils" 5 | import { getOrCreate } from "../utils/mapUtils" 6 | import type { AnyModel } from "./BaseModel" 7 | import { isModel, isModelClass } from "./utils" 8 | 9 | /** 10 | * Associated model metadata. 11 | */ 12 | export interface ModelMetadata { 13 | /** 14 | * Associated data type for runtime checking (if any). 15 | */ 16 | dataType?: AnyType 17 | 18 | /** 19 | * Property used as model id. 20 | */ 21 | modelIdProperty: string | undefined 22 | 23 | /** 24 | * A value type will be cloned automatically when being attached to a new tree. 25 | */ 26 | valueType: boolean 27 | } 28 | 29 | /** 30 | * Returns the associated metadata for a model instance or class. 31 | * 32 | * @param modelClassOrInstance Model class or instance. 33 | * @returns The associated metadata. 34 | */ 35 | export function getModelMetadata( 36 | modelClassOrInstance: AnyModel | ModelClass 37 | ): ModelMetadata { 38 | if (isModel(modelClassOrInstance)) { 39 | return (modelClassOrInstance as any).constructor[modelMetadataSymbol] 40 | } else if (isModelClass(modelClassOrInstance)) { 41 | return (modelClassOrInstance as any)[modelMetadataSymbol] 42 | } else { 43 | throw failure(`modelClassOrInstance must be a model class or instance`) 44 | } 45 | } 46 | 47 | const modelIdPropertyNameCache = new WeakMap() 48 | 49 | /** 50 | * @internal 51 | */ 52 | export function getModelIdPropertyName(modelClass: ModelClass): string | undefined { 53 | return getOrCreate( 54 | modelIdPropertyNameCache, 55 | modelClass, 56 | () => getModelMetadata(modelClass).modelIdProperty 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /packages/lib/src/model/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./BaseModel" 2 | export * from "./getModelMetadata" 3 | export * from "./metadata" 4 | export * from "./Model" 5 | export * from "./utils" 6 | -------------------------------------------------------------------------------- /packages/lib/src/model/metadata.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Key where model snapshots will store model type metadata. 3 | */ 4 | export const modelTypeKey = "$modelType" 5 | 6 | /** 7 | * Key that serves as proxy to the model property designed as 'idProp' (if any). 8 | */ 9 | export const modelIdKey = "$modelId" 10 | 11 | /** 12 | * @internal 13 | * Returns if a given key is a reserved key in model snapshots. 14 | * 15 | * @param key 16 | * @returns 17 | */ 18 | export function isReservedModelKey(key: string) { 19 | // note $modelId is NOT a reserved key, since it will eventually end up in the data 20 | // and can actually be changed 21 | return key === modelTypeKey 22 | } 23 | -------------------------------------------------------------------------------- /packages/lib/src/model/utils.ts: -------------------------------------------------------------------------------- 1 | import type { ModelClass } from "../modelShared/BaseModelShared" 2 | import { failure, isPlainObject } from "../utils" 3 | import { AnyModel, BaseModel } from "./BaseModel" 4 | import { modelTypeKey } from "./metadata" 5 | 6 | /** 7 | * Checks if an object is a model instance. 8 | * 9 | * @param model 10 | * @returns 11 | */ 12 | export function isModel(model: unknown): model is AnyModel { 13 | return model instanceof BaseModel 14 | } 15 | 16 | /** 17 | * @internal 18 | * 19 | * Asserts something is actually a model. 20 | * 21 | * @param model 22 | * @param argName 23 | */ 24 | export function assertIsModel( 25 | model: unknown, 26 | argName: string, 27 | customErrMsg = "must be a model instance" 28 | ): asserts model is AnyModel { 29 | if (!isModel(model)) { 30 | throw failure(`${argName} ${customErrMsg}`) 31 | } 32 | } 33 | 34 | /** 35 | * @internal 36 | */ 37 | export function isModelClass(modelClass: unknown): modelClass is ModelClass { 38 | if (typeof modelClass !== "function") { 39 | return false 40 | } 41 | 42 | if (modelClass !== BaseModel && !(modelClass.prototype instanceof BaseModel)) { 43 | return false 44 | } 45 | 46 | return true 47 | } 48 | 49 | /** 50 | * @internal 51 | */ 52 | export function assertIsModelClass( 53 | modelClass: unknown, 54 | argName: string 55 | ): asserts modelClass is ModelClass { 56 | if (typeof modelClass !== "function") { 57 | throw failure(`${argName} must be a class`) 58 | } 59 | 60 | if (modelClass !== BaseModel && !(modelClass.prototype instanceof BaseModel)) { 61 | throw failure(`${argName} must extend Model`) 62 | } 63 | } 64 | 65 | /** 66 | * @internal 67 | */ 68 | export function isModelSnapshot(sn: unknown): sn is { [modelTypeKey]: string } { 69 | return isPlainObject(sn) && modelTypeKey in sn 70 | } 71 | -------------------------------------------------------------------------------- /packages/lib/src/modelShared/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./BaseModelShared" 2 | export * from "./modelDecorator" 3 | export * from "./prop" 4 | -------------------------------------------------------------------------------- /packages/lib/src/modelShared/modelClassInitializer.ts: -------------------------------------------------------------------------------- 1 | import type { AnyDataModel } from "../dataModel/BaseDataModel" 2 | import type { AnyModel } from "../model/BaseModel" 3 | import type { ModelClass } from "./BaseModelShared" 4 | 5 | /** 6 | * @internal 7 | */ 8 | export const modelInitializersSymbol = Symbol("modelInitializers") 9 | 10 | /** 11 | * @internal 12 | */ 13 | export type ModelClassInitializer = (modelInstance: AnyModel | AnyDataModel) => void 14 | 15 | /** 16 | * @internal 17 | */ 18 | export function addModelClassInitializer( 19 | modelClass: ModelClass, 20 | init: ModelClassInitializer 21 | ) { 22 | let initializers: ModelClassInitializer[] = (modelClass as any)[modelInitializersSymbol] 23 | if (!initializers) { 24 | initializers = [] 25 | ;(modelClass as any)[modelInitializersSymbol] = initializers 26 | } 27 | initializers.push(init) 28 | } 29 | 30 | /** 31 | * @internal 32 | */ 33 | export function getModelClassInitializers( 34 | modelClass: ModelClass 35 | ): ModelClassInitializer[] | undefined { 36 | return (modelClass as any)[modelInitializersSymbol] 37 | } 38 | -------------------------------------------------------------------------------- /packages/lib/src/modelShared/modelInfo.ts: -------------------------------------------------------------------------------- 1 | import type { AnyDataModel } from "../dataModel/BaseDataModel" 2 | import type { AnyModel } from "../model/BaseModel" 3 | import type { ModelClass } from "./BaseModelShared" 4 | 5 | /** 6 | * @internal 7 | */ 8 | export interface ModelInfo { 9 | name: string 10 | class: ModelClass 11 | } 12 | 13 | /** 14 | * @internal 15 | */ 16 | export const modelInfoByName: { 17 | [name: string]: ModelInfo 18 | } = {} 19 | 20 | /** 21 | * @internal 22 | */ 23 | export const modelInfoByClass = new WeakMap, ModelInfo>() 24 | 25 | /** 26 | * @internal 27 | */ 28 | export function getModelInfoForName(name: string): ModelInfo | undefined { 29 | return modelInfoByName[name] 30 | } 31 | -------------------------------------------------------------------------------- /packages/lib/src/modelShared/modelPropsInfo.ts: -------------------------------------------------------------------------------- 1 | import type { AnyDataModel } from "../dataModel/BaseDataModel" 2 | import type { AnyModel } from "../model/BaseModel" 3 | import type { ModelClass } from "./BaseModelShared" 4 | import type { ModelProps } from "./prop" 5 | 6 | const modelPropertiesSymbol = Symbol("modelProperties") 7 | 8 | /** 9 | * @internal 10 | * 11 | * Gets the info related to a model class properties. 12 | * 13 | * @param modelClass 14 | */ 15 | export function getInternalModelClassPropsInfo( 16 | modelClass: ModelClass 17 | ): ModelProps { 18 | return (modelClass as any)[modelPropertiesSymbol] 19 | } 20 | 21 | /** 22 | * @internal 23 | * 24 | * Sets the info related to a model class properties. 25 | * 26 | * @param modelClass 27 | */ 28 | export function setInternalModelClassPropsInfo( 29 | modelClass: ModelClass, 30 | props: ModelProps 31 | ): void { 32 | ;(modelClass as any)[modelPropertiesSymbol] = props 33 | } 34 | -------------------------------------------------------------------------------- /packages/lib/src/modelShared/modelSymbols.ts: -------------------------------------------------------------------------------- 1 | export const modelMetadataSymbol = Symbol("modelMetadata") 2 | export const modelUnwrappedClassSymbol = Symbol("modelUnwrappedClass") 3 | export const runAfterModelDecoratorSymbol = Symbol("runAfterModelDecorator") 4 | -------------------------------------------------------------------------------- /packages/lib/src/modelShared/newModel.ts: -------------------------------------------------------------------------------- 1 | import type { AnyDataModel } from "../dataModel/BaseDataModel" 2 | import type { AnyModel } from "../model/BaseModel" 3 | import type { ModelClass } from "../modelShared/BaseModelShared" 4 | import { getModelClassInitializers } from "../modelShared/modelClassInitializer" 5 | 6 | export function applyModelInitializers( 7 | modelClass: ModelClass, 8 | modelObj: any 9 | ) { 10 | const initializers = getModelClassInitializers(modelClass) 11 | if (initializers) { 12 | const len = initializers.length 13 | for (let i = 0; i < len; i++) { 14 | initializers[i](modelObj) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/lib/src/modelShared/utils.ts: -------------------------------------------------------------------------------- 1 | import type { AnyDataModel } from "../dataModel/BaseDataModel" 2 | import { isDataModelClass } from "../dataModel/utils" 3 | import type { AnyModel } from "../model/BaseModel" 4 | import { isModelClass } from "../model/utils" 5 | import { failure } from "../utils" 6 | import type { ModelClass } from "./BaseModelShared" 7 | 8 | /** 9 | * @internal 10 | * 11 | * Asserts something is actually a class or data model. 12 | * 13 | * @param model 14 | * @param argName 15 | */ 16 | export function assertIsClassOrDataModelClass( 17 | model: unknown, 18 | argName: string, 19 | customErrMsg = "must be a class or data model class" 20 | ): asserts model is ModelClass | ModelClass { 21 | if (!(isModelClass(model) || isDataModelClass(model))) { 22 | throw failure(`${argName} ${customErrMsg}`) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/lib/src/parent/core.ts: -------------------------------------------------------------------------------- 1 | import { createAtom, IAtom } from "mobx" 2 | import { isModel } from "../model/utils" 3 | import { getOrCreate } from "../utils/mapUtils" 4 | import type { ParentPath } from "./path" 5 | 6 | /** 7 | * @internal 8 | */ 9 | export const objectParents = new WeakMap | undefined>() 10 | 11 | const objectParentsAtoms = new WeakMap() 12 | 13 | /** 14 | * @internal 15 | */ 16 | export function parentPathEquals( 17 | parentPath1: ParentPath | undefined, 18 | parentPath2: ParentPath | undefined 19 | ) { 20 | if (!(parentPath1 || parentPath2)) { 21 | return true 22 | } 23 | if (!(parentPath1 && parentPath2)) { 24 | return false 25 | } 26 | return parentPath1.parent === parentPath2.parent && parentPath1.path === parentPath2.path 27 | } 28 | 29 | function createParentPathAtom() { 30 | return createAtom("parentAtom") 31 | } 32 | 33 | /** 34 | * @internal 35 | */ 36 | export function reportParentPathObserved(node: object) { 37 | getOrCreate(objectParentsAtoms, node, createParentPathAtom).reportObserved() 38 | } 39 | 40 | /** 41 | * @internal 42 | */ 43 | export function reportParentPathChanged(node: object) { 44 | objectParentsAtoms.get(node)?.reportChanged() 45 | } 46 | 47 | /** 48 | * @internal 49 | */ 50 | export const dataObjectParent = new WeakMap() 51 | 52 | /** 53 | * @internal 54 | */ 55 | export function dataToModelNode(node: T): T { 56 | const modelNode = dataObjectParent.get(node) 57 | return (modelNode as T | undefined) ?? node 58 | } 59 | 60 | /** 61 | * @internal 62 | */ 63 | export function modelToDataNode(node: T): T { 64 | return isModel(node) ? node.$ : node 65 | } 66 | -------------------------------------------------------------------------------- /packages/lib/src/parent/detach.ts: -------------------------------------------------------------------------------- 1 | import { isObservableArray, isObservableObject, remove } from "mobx" 2 | import { BuiltInAction } from "../action/builtInActions" 3 | import { ActionContextActionType } from "../action/context" 4 | import { wrapInAction } from "../action/wrapInAction" 5 | import { assertTweakedObject } from "../tweaker/core" 6 | import { failure, lazy } from "../utils" 7 | import { fastGetParentPathIncludingDataObjects } from "./path" 8 | 9 | /** 10 | * Detaches a given object from a tree. 11 | * If the parent is an object / model, detaching will delete the property. 12 | * If the parent is an array detaching will remove the node by splicing it. 13 | * If there's no parent it will throw. 14 | * 15 | * @param node Object to be detached. 16 | */ 17 | export function detach(node: object): void { 18 | assertTweakedObject(node, "node") 19 | 20 | wrappedInternalDetach().call(node) 21 | } 22 | 23 | const wrappedInternalDetach = lazy(() => 24 | wrapInAction({ 25 | nameOrNameFn: BuiltInAction.Detach, 26 | fn: internalDetach, 27 | actionType: ActionContextActionType.Sync, 28 | }) 29 | ) 30 | 31 | function internalDetach(this: object): void { 32 | const node = this 33 | 34 | const parentPath = fastGetParentPathIncludingDataObjects(node, false) 35 | if (!parentPath) { 36 | return 37 | } 38 | 39 | const { parent, path } = parentPath 40 | if (isObservableArray(parent)) { 41 | parent.splice(+path, 1) 42 | } else if (isObservableObject(parent)) { 43 | remove(parent, String(path)) 44 | } else { 45 | throw failure("parent must be an observable object or an observable array") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/lib/src/parent/findChildren.ts: -------------------------------------------------------------------------------- 1 | import { getChildrenObjects } from "./getChildrenObjects" 2 | 3 | /** 4 | * Iterates through all children and collects them in a set if the 5 | * given predicate matches. 6 | * 7 | * @param root Root object to get the matching children from. 8 | * @param predicate Function that will be run for every child of the root object. 9 | * @param [options] An optional object with the `deep` option (defaults to `false`) set to `true` to 10 | * get the children deeply or `false` to get them shallowly. 11 | * @returns A readonly observable set with the matching children. 12 | */ 13 | export function findChildren( 14 | root: object, 15 | predicate: (node: object) => boolean, 16 | options?: { 17 | deep?: boolean 18 | } 19 | ): ReadonlySet { 20 | const children = getChildrenObjects(root, options) 21 | 22 | const set = new Set() 23 | 24 | const iter = children.values() 25 | let cur = iter.next() 26 | while (!cur.done) { 27 | if (predicate(cur.value)) { 28 | set.add(cur.value) 29 | } 30 | cur = iter.next() 31 | } 32 | 33 | return set 34 | } 35 | -------------------------------------------------------------------------------- /packages/lib/src/parent/getChildrenObjects.ts: -------------------------------------------------------------------------------- 1 | import { assertTweakedObject } from "../tweaker/core" 2 | import { getDeepObjectChildren, getObjectChildren } from "./coreObjectChildren" 3 | 4 | /** 5 | * Returns all the children objects (this is, excluding primitives) of an object. 6 | * 7 | * @param node Object to get the list of children from. 8 | * @param [options] An optional object with the `deep` option (defaults to false) to true to get 9 | * the children deeply or false to get them shallowly. 10 | * @returns A readonly observable set with the children. 11 | */ 12 | export function getChildrenObjects( 13 | node: object, 14 | options?: { 15 | deep?: boolean 16 | } 17 | ): ReadonlySet { 18 | assertTweakedObject(node, "node") 19 | 20 | if (options?.deep) { 21 | return getDeepObjectChildren(node).deep 22 | } else { 23 | return getObjectChildren(node) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/lib/src/parent/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./detach" 2 | export * from "./findChildren" 3 | export * from "./findParent" 4 | export * from "./getChildrenObjects" 5 | export * from "./onChildAttachedTo" 6 | export * from "./path" 7 | export * from "./path2" 8 | export * from "./pathTypes" 9 | export * from "./walkTree" 10 | -------------------------------------------------------------------------------- /packages/lib/src/parent/path2.ts: -------------------------------------------------------------------------------- 1 | import { assertTweakedObject } from "../tweaker/core" 2 | import { fastGetParent } from "./path" 3 | 4 | /** 5 | * Returns if the target is a "child" of the tree of the given "parent" object. 6 | * 7 | * @param child Target object. 8 | * @param parent Parent object. 9 | * @returns 10 | */ 11 | export function isChildOfParent(child: object, parent: object): boolean { 12 | assertTweakedObject(child, "child") 13 | assertTweakedObject(parent, "parent") 14 | 15 | let currentParent = fastGetParent(child, true) 16 | while (currentParent) { 17 | if (currentParent === parent) { 18 | return true 19 | } 20 | 21 | currentParent = fastGetParent(currentParent, true) 22 | } 23 | 24 | return false 25 | } 26 | 27 | /** 28 | * Returns if the target is a "parent" that has in its tree the given "child" object. 29 | * 30 | * @param parent Target object. 31 | * @param child Child object. 32 | * @returns 33 | */ 34 | export function isParentOfChild(parent: object, child: object): boolean { 35 | return isChildOfParent(child, parent) 36 | } 37 | -------------------------------------------------------------------------------- /packages/lib/src/parent/pathTypes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Property name (if the parent is an object) or index number (if the parent is an array). 3 | */ 4 | export type PathElement = string | number 5 | 6 | /** 7 | * Path from a parent to a child. 8 | */ 9 | export type Path = ReadonlyArray 10 | 11 | /** 12 | * Path from a parent to a child (writable). 13 | */ 14 | export type WritablePath = PathElement[] 15 | -------------------------------------------------------------------------------- /packages/lib/src/patch/Patch.ts: -------------------------------------------------------------------------------- 1 | import type { Path } from "../parent/pathTypes" 2 | 3 | export type Patch = PatchAddOperation | PatchRemoveOperation | PatchReplaceOperation 4 | 5 | export interface PatchBaseOperation { 6 | path: Path 7 | } 8 | 9 | export interface PatchAddOperation extends PatchBaseOperation { 10 | op: "add" 11 | value: T 12 | } 13 | 14 | export interface PatchRemoveOperation extends PatchBaseOperation { 15 | op: "remove" 16 | } 17 | 18 | export interface PatchReplaceOperation extends PatchBaseOperation { 19 | op: "replace" 20 | value: T 21 | } 22 | -------------------------------------------------------------------------------- /packages/lib/src/patch/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./applyPatches" 2 | export * from "./emitPatch" 3 | export * from "./jsonPatch" 4 | export * from "./Patch" 5 | export * from "./patchRecorder" 6 | -------------------------------------------------------------------------------- /packages/lib/src/redux/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./connectReduxDevTools" 2 | export * from "./redux" 3 | -------------------------------------------------------------------------------- /packages/lib/src/ref/customRef.ts: -------------------------------------------------------------------------------- 1 | import { action } from "mobx" 2 | import { 3 | getModelRefId, 4 | internalCustomRef, 5 | RefIdResolver, 6 | RefOnResolvedValueChange, 7 | RefResolver, 8 | } from "./core" 9 | import type { RefConstructor } from "./Ref" 10 | 11 | /** 12 | * Custom reference options. 13 | */ 14 | export interface CustomRefOptions { 15 | /** 16 | * Must return the resolution for the given reference object. 17 | * 18 | * @param ref Reference object. 19 | * @returns The resolved object or undefined if it could not be resolved. 20 | */ 21 | resolve: RefResolver 22 | 23 | /** 24 | * Must return the ID associated to the given target object, or `undefined` if it has no ID. 25 | * If not provided it will try to get the reference id from the model `getRefId()` method. 26 | * 27 | * @param target Target object. 28 | */ 29 | getId?: RefIdResolver 30 | 31 | /** 32 | * What should happen when the resolved value changes. 33 | * 34 | * @param ref Reference object. 35 | * @param newValue New resolved value. 36 | * @param oldValue Old resolved value. 37 | */ 38 | onResolvedValueChange?: RefOnResolvedValueChange 39 | } 40 | 41 | /** 42 | * Creates a custom ref to an object, which in its snapshot form has an id. 43 | * 44 | * @template T Target object type. 45 | * @param modelTypeId Unique model type id. 46 | * @param options Custom reference options. 47 | * @returns A function that allows you to construct that type of custom reference. 48 | */ 49 | export const customRef: ( 50 | modelTypeId: string, 51 | options: CustomRefOptions 52 | ) => RefConstructor = action("customRef", (modelTypeId, options) => { 53 | const getId = options.getId ?? getModelRefId 54 | 55 | return internalCustomRef(modelTypeId, () => options.resolve, getId, options.onResolvedValueChange) 56 | }) 57 | -------------------------------------------------------------------------------- /packages/lib/src/ref/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./core" 2 | export * from "./customRef" 3 | export * from "./Ref" 4 | export * from "./rootRef" 5 | -------------------------------------------------------------------------------- /packages/lib/src/rootStore/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./rootStore" 2 | -------------------------------------------------------------------------------- /packages/lib/src/snapshot/SnapshotterAndReconcilerPriority.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @internal 3 | */ 4 | // biome-ignore lint/style/useEnumInitializers: 5 | export enum SnapshotterAndReconcilerPriority { 6 | Array, 7 | Frozen, 8 | Model, 9 | PlainObject, 10 | } 11 | -------------------------------------------------------------------------------- /packages/lib/src/snapshot/clone.ts: -------------------------------------------------------------------------------- 1 | import { assertTweakedObject } from "../tweaker/core" 2 | import { fromSnapshot } from "./fromSnapshot" 3 | import { getSnapshot } from "./getSnapshot" 4 | 5 | /** 6 | * Clone options. 7 | */ 8 | export interface CloneOptions { 9 | /** 10 | * Pass `true` to generate new internal ids for models rather than reusing them. (Default is `true`) 11 | */ 12 | generateNewIds: boolean 13 | } 14 | 15 | /** 16 | * Clones an object by doing a `fromSnapshot(getSnapshot(value), { generateNewIds: true })`. 17 | * 18 | * @template T Object type. 19 | * @param node Object to clone. 20 | * @param [options] Options. 21 | * @returns The cloned object. 22 | */ 23 | export function clone(node: T, options?: Partial): T { 24 | assertTweakedObject(node, "node") 25 | 26 | const opts = { 27 | generateNewIds: true, 28 | ...options, 29 | } 30 | 31 | const sn = getSnapshot(node) 32 | return fromSnapshot(sn, opts) 33 | } 34 | -------------------------------------------------------------------------------- /packages/lib/src/snapshot/fromArraySnapshot.ts: -------------------------------------------------------------------------------- 1 | import { observable } from "mobx" 2 | import { tweakArray } from "../tweaker/tweakArray" 3 | import { isArray } from "../utils" 4 | import { 5 | FromSnapshotContext, 6 | internalFromSnapshot, 7 | observableOptions, 8 | registerSnapshotter, 9 | } from "./fromSnapshot" 10 | import { SnapshotInOfObject } from "./SnapshotOf" 11 | import { SnapshotterAndReconcilerPriority } from "./SnapshotterAndReconcilerPriority" 12 | 13 | function fromArraySnapshot(sn: SnapshotInOfObject, ctx: FromSnapshotContext): any[] { 14 | const arr = observable.array([] as any[], observableOptions) 15 | const ln = sn.length 16 | for (let i = 0; i < ln; i++) { 17 | arr.push(internalFromSnapshot(sn[i], ctx)) 18 | } 19 | return tweakArray(arr, undefined, true) 20 | } 21 | 22 | /** 23 | * @internal 24 | */ 25 | export function registerFromArraySnapshotter() { 26 | registerSnapshotter(SnapshotterAndReconcilerPriority.Array, (sn, ctx) => { 27 | if (isArray(sn)) { 28 | return fromArraySnapshot(sn, ctx) 29 | } 30 | return undefined 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /packages/lib/src/snapshot/fromFrozenSnapshot.ts: -------------------------------------------------------------------------------- 1 | import { frozen, isFrozenSnapshot } from "../frozen/Frozen" 2 | import { registerSnapshotter } from "./fromSnapshot" 3 | import { SnapshotterAndReconcilerPriority } from "./SnapshotterAndReconcilerPriority" 4 | 5 | /** 6 | * @internal 7 | */ 8 | export function registerFromFrozenSnapshotter() { 9 | registerSnapshotter(SnapshotterAndReconcilerPriority.Frozen, (sn) => { 10 | if (isFrozenSnapshot(sn)) { 11 | return frozen(sn.data) 12 | } 13 | return undefined 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /packages/lib/src/snapshot/fromModelSnapshot.ts: -------------------------------------------------------------------------------- 1 | import { AnyModel } from "../model/BaseModel" 2 | import { getModelIdPropertyName } from "../model/getModelMetadata" 3 | import { modelTypeKey } from "../model/metadata" 4 | import { ModelConstructorOptions } from "../model/ModelConstructorOptions" 5 | import { isModelSnapshot } from "../model/utils" 6 | import { ModelClass } from "../modelShared/BaseModelShared" 7 | import { getModelInfoForName } from "../modelShared/modelInfo" 8 | import { failure } from "../utils" 9 | import { FromSnapshotContext, registerSnapshotter } from "./fromSnapshot" 10 | import { SnapshotInOfModel } from "./SnapshotOf" 11 | import { SnapshotterAndReconcilerPriority } from "./SnapshotterAndReconcilerPriority" 12 | 13 | function fromModelSnapshot(sn: SnapshotInOfModel, ctx: FromSnapshotContext): AnyModel { 14 | const type = sn[modelTypeKey] 15 | 16 | if (!type) { 17 | throw failure(`a model snapshot must contain a type key (${modelTypeKey}), but none was found`) 18 | } 19 | 20 | const modelInfo = getModelInfoForName(type) 21 | if (!modelInfo) { 22 | throw failure(`model with name "${type}" not found in the registry`) 23 | } 24 | 25 | const modelIdPropertyName = getModelIdPropertyName(modelInfo.class as ModelClass) 26 | if (modelIdPropertyName && sn[modelIdPropertyName] === undefined) { 27 | throw failure( 28 | `a model snapshot of type '${type}' must contain an id key (${modelIdPropertyName}), but none was found` 29 | ) 30 | } 31 | 32 | return new (modelInfo.class as any)(undefined, { 33 | snapshotInitialData: { 34 | unprocessedSnapshot: sn, 35 | unprocessedModelType: 36 | typeof ctx.untypedSnapshot === "object" && 37 | ctx.untypedSnapshot && 38 | modelTypeKey in ctx.untypedSnapshot 39 | ? ctx.untypedSnapshot[modelTypeKey] 40 | : undefined, 41 | snapshotToInitialData: ctx.snapshotToInitialData, 42 | }, 43 | generateNewIds: ctx.options.generateNewIds, 44 | } satisfies ModelConstructorOptions) 45 | } 46 | 47 | /** 48 | * @internal 49 | */ 50 | export function registerFromModelSnapshotter() { 51 | registerSnapshotter(SnapshotterAndReconcilerPriority.Model, (sn, ctx) => { 52 | if (isModelSnapshot(sn)) { 53 | return fromModelSnapshot(sn, ctx) 54 | } 55 | return undefined 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /packages/lib/src/snapshot/fromPlainObjectSnapshot.ts: -------------------------------------------------------------------------------- 1 | import { observable, set } from "mobx" 2 | import { tweakPlainObject } from "../tweaker/tweakPlainObject" 3 | import { isPlainObject } from "../utils" 4 | import { SnapshotInOfObject } from "./SnapshotOf" 5 | import { SnapshotterAndReconcilerPriority } from "./SnapshotterAndReconcilerPriority" 6 | import { 7 | FromSnapshotContext, 8 | internalFromSnapshot, 9 | observableOptions, 10 | registerSnapshotter, 11 | } from "./fromSnapshot" 12 | 13 | function fromPlainObjectSnapshot(sn: SnapshotInOfObject, ctx: FromSnapshotContext): object { 14 | const plainObj = observable.object({}, undefined, observableOptions) 15 | 16 | const snKeys = Object.keys(sn) 17 | const snKeysLen = snKeys.length 18 | for (let i = 0; i < snKeysLen; i++) { 19 | const k = snKeys[i] 20 | const v = sn[k] 21 | // setIfDifferent not required 22 | set(plainObj, k, internalFromSnapshot(v, ctx)) 23 | } 24 | return tweakPlainObject(plainObj, undefined, undefined, true, false) 25 | } 26 | 27 | /** 28 | * @internal 29 | */ 30 | export function registerFromPlainObjectSnapshotter() { 31 | registerSnapshotter(SnapshotterAndReconcilerPriority.PlainObject, (sn, ctx) => { 32 | if (isPlainObject(sn)) { 33 | return fromPlainObjectSnapshot(sn, ctx) 34 | } 35 | return undefined 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /packages/lib/src/snapshot/getSnapshot.ts: -------------------------------------------------------------------------------- 1 | import { assertTweakedObject } from "../tweaker/core" 2 | import { resolveTypeChecker } from "../types/resolveTypeChecker" 3 | import { AnyType, TypeToData } from "../types/schemas" 4 | import { failure, identityFn, isPrimitive } from "../utils" 5 | import { 6 | freezeInternalSnapshot, 7 | getInternalSnapshot, 8 | reportInternalSnapshotObserved, 9 | } from "./internal" 10 | import type { SnapshotOutOf } from "./SnapshotOf" 11 | 12 | /** 13 | * Retrieves an immutable snapshot for a data structure. 14 | * Since returned snapshots are immutable they will respect shallow equality, this is, 15 | * if no changes are made then the snapshot will be kept the same. 16 | * 17 | * @template T Object type. 18 | * @param nodeOrPrimitive Data structure, including primtives. 19 | * @returns The snapshot. 20 | */ 21 | export function getSnapshot( 22 | type: T, 23 | nodeOrPrimitive: TypeToData 24 | ): SnapshotOutOf> 25 | 26 | /** 27 | * Retrieves an immutable snapshot for a data structure. 28 | * Since returned snapshots are immutable they will respect shallow equality, this is, 29 | * if no changes are made then the snapshot will be kept the same. 30 | * 31 | * @template T Object type. 32 | * @param nodeOrPrimitive Data structure, including primtives. 33 | * @returns The snapshot. 34 | */ 35 | export function getSnapshot(nodeOrPrimitive: T): SnapshotOutOf 36 | 37 | export function getSnapshot(arg1: any, arg2?: any): any { 38 | let toSnapshotProcessor = identityFn as (sn: any) => unknown 39 | let nodeOrPrimitive: any 40 | 41 | if (arguments.length >= 2) { 42 | toSnapshotProcessor = resolveTypeChecker(arg1).toSnapshotProcessor 43 | nodeOrPrimitive = arg2 44 | } else { 45 | nodeOrPrimitive = arg1 46 | } 47 | 48 | if (isPrimitive(nodeOrPrimitive)) { 49 | return toSnapshotProcessor(nodeOrPrimitive) 50 | } 51 | 52 | assertTweakedObject(nodeOrPrimitive, "nodeOrPrimitive") 53 | 54 | const snapshot = getInternalSnapshot(nodeOrPrimitive) 55 | if (!snapshot) { 56 | throw failure("getSnapshot is not supported for this kind of object") 57 | } 58 | 59 | freezeInternalSnapshot(snapshot.transformed) 60 | reportInternalSnapshotObserved(snapshot) 61 | return toSnapshotProcessor(snapshot.transformed) 62 | } 63 | -------------------------------------------------------------------------------- /packages/lib/src/snapshot/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./applySnapshot" 2 | export * from "./clone" 3 | export * from "./fromSnapshot" 4 | export * from "./getSnapshot" 5 | export * from "./onSnapshot" 6 | export * from "./SnapshotOf" 7 | -------------------------------------------------------------------------------- /packages/lib/src/snapshot/onSnapshot.ts: -------------------------------------------------------------------------------- 1 | import { reaction } from "mobx" 2 | import { assertTweakedObject } from "../tweaker/core" 3 | import { getSnapshot } from "./getSnapshot" 4 | import type { SnapshotOutOf } from "./SnapshotOf" 5 | 6 | /** 7 | * Listener function for onSnapshot. 8 | */ 9 | export type OnSnapshotListener = (sn: SnapshotOutOf, prevSn: SnapshotOutOf) => void 10 | 11 | /** 12 | * Disposer function for onSnapshot. 13 | */ 14 | export type OnSnapshotDisposer = () => void 15 | 16 | /** 17 | * Adds a reaction that will trigger every time an snapshot changes. 18 | * 19 | * @template T Object type. 20 | * @param nodeOrFn Object to get the snapshot from or a function to get it. 21 | * @param listener Function that will be triggered when the snapshot changes. 22 | * @returns A disposer. 23 | */ 24 | export function onSnapshot( 25 | nodeOrFn: T | (() => T), 26 | listener: OnSnapshotListener 27 | ): OnSnapshotDisposer { 28 | const nodeFn = typeof nodeOrFn === "function" ? (nodeOrFn as () => T) : () => nodeOrFn 29 | 30 | const node = nodeFn() 31 | assertTweakedObject(node, "node") 32 | 33 | let currentSnapshot = getSnapshot(node) 34 | 35 | return reaction( 36 | () => getSnapshot(nodeFn()), 37 | (newSnapshot) => { 38 | const prevSn = currentSnapshot 39 | currentSnapshot = newSnapshot 40 | listener(newSnapshot, prevSn) 41 | } 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /packages/lib/src/snapshot/reconcileArraySnapshot.ts: -------------------------------------------------------------------------------- 1 | import { runTypeCheckingAfterChange } from "../tweaker/typeChecking" 2 | import { withoutTypeChecking } from "../tweaker/withoutTypeChecking" 3 | import { isArray } from "../utils" 4 | import { ModelPool } from "../utils/ModelPool" 5 | import { setIfDifferent } from "../utils/setIfDifferent" 6 | import type { SnapshotInOfObject } from "./SnapshotOf" 7 | import { SnapshotterAndReconcilerPriority } from "./SnapshotterAndReconcilerPriority" 8 | import { fromSnapshot } from "./fromSnapshot" 9 | import { getSnapshot } from "./getSnapshot" 10 | import { detachIfNeeded, reconcileSnapshot, registerReconciler } from "./reconcileSnapshot" 11 | 12 | function reconcileArraySnapshot( 13 | value: any, 14 | sn: SnapshotInOfObject, 15 | modelPool: ModelPool 16 | ): any[] { 17 | if (!isArray(value)) { 18 | // no reconciliation possible 19 | return fromSnapshot(sn) 20 | } 21 | 22 | const snapshotBeforeChanges = getSnapshot(value) 23 | 24 | withoutTypeChecking(() => { 25 | // remove excess items 26 | if (value.length > sn.length) { 27 | value.splice(sn.length, value.length - sn.length) 28 | } 29 | 30 | // reconcile present items 31 | for (let i = 0; i < value.length; i++) { 32 | const oldValue = value[i] 33 | const newValue = reconcileSnapshot(oldValue, sn[i], modelPool, value) 34 | 35 | detachIfNeeded(newValue, oldValue, modelPool) 36 | 37 | setIfDifferent(value, i, newValue) 38 | } 39 | 40 | // add excess items 41 | for (let i = value.length; i < sn.length; i++) { 42 | const newValue = reconcileSnapshot(undefined, sn[i], modelPool, value) 43 | 44 | detachIfNeeded(newValue, undefined, modelPool) 45 | 46 | value.push(newValue) 47 | } 48 | }) 49 | 50 | runTypeCheckingAfterChange(value, undefined, snapshotBeforeChanges) 51 | 52 | return value 53 | } 54 | 55 | /** 56 | * @internal 57 | */ 58 | export function registerArraySnapshotReconciler() { 59 | registerReconciler(SnapshotterAndReconcilerPriority.Array, (value, sn, modelPool) => { 60 | if (isArray(sn)) { 61 | return reconcileArraySnapshot(value, sn, modelPool) 62 | } 63 | return undefined 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /packages/lib/src/snapshot/reconcileFrozenSnapshot.ts: -------------------------------------------------------------------------------- 1 | import { Frozen, frozen, isFrozenSnapshot } from "../frozen/Frozen" 2 | import { registerReconciler } from "./reconcileSnapshot" 3 | import type { SnapshotInOfFrozen } from "./SnapshotOf" 4 | import { SnapshotterAndReconcilerPriority } from "./SnapshotterAndReconcilerPriority" 5 | 6 | function reconcileFrozenSnapshot(value: any, sn: SnapshotInOfFrozen>): Frozen { 7 | // reconciliation is only possible if the target is a Frozen instance with the same data (by ref) 8 | // in theory we could compare the JSON representation of both datas or do a deep comparison, but that'd be too slow 9 | if (value instanceof Frozen && value.data === sn.data) { 10 | return value 11 | } 12 | return frozen(sn.data) 13 | } 14 | 15 | /** 16 | * @internal 17 | */ 18 | export function registerFrozenSnapshotReconciler() { 19 | registerReconciler(SnapshotterAndReconcilerPriority.Frozen, (value, sn) => { 20 | if (isFrozenSnapshot(sn)) { 21 | return reconcileFrozenSnapshot(value, sn) 22 | } 23 | return undefined 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /packages/lib/src/snapshot/reconcilePlainObjectSnapshot.ts: -------------------------------------------------------------------------------- 1 | import { isObservableObject, remove } from "mobx" 2 | import { runTypeCheckingAfterChange } from "../tweaker/typeChecking" 3 | import { withoutTypeChecking } from "../tweaker/withoutTypeChecking" 4 | import { isPlainObject } from "../utils" 5 | import type { ModelPool } from "../utils/ModelPool" 6 | import { setIfDifferent } from "../utils/setIfDifferent" 7 | import type { SnapshotInOfObject } from "./SnapshotOf" 8 | import { SnapshotterAndReconcilerPriority } from "./SnapshotterAndReconcilerPriority" 9 | import { fromSnapshot } from "./fromSnapshot" 10 | import { getSnapshot } from "./getSnapshot" 11 | import { detachIfNeeded, reconcileSnapshot, registerReconciler } from "./reconcileSnapshot" 12 | 13 | function reconcilePlainObjectSnapshot( 14 | value: any, 15 | sn: SnapshotInOfObject, 16 | modelPool: ModelPool 17 | ): object { 18 | // plain obj 19 | if (!(isPlainObject(value) || isObservableObject(value))) { 20 | // no reconciliation possible 21 | return fromSnapshot(sn) 22 | } 23 | 24 | const plainObj = value 25 | const snapshotBeforeChanges = getSnapshot(plainObj) 26 | 27 | withoutTypeChecking(() => { 28 | // remove excess props 29 | const plainObjKeys = Object.keys(plainObj) 30 | const plainObjKeysLen = plainObjKeys.length 31 | for (let i = 0; i < plainObjKeysLen; i++) { 32 | const k = plainObjKeys[i] 33 | if (!(k in sn)) { 34 | remove(plainObj, k) 35 | } 36 | } 37 | 38 | // reconcile the rest 39 | const snKeys = Object.keys(sn) 40 | const snKeysLen = snKeys.length 41 | for (let i = 0; i < snKeysLen; i++) { 42 | const k = snKeys[i] 43 | const v = sn[k] 44 | 45 | const oldValue = plainObj[k] 46 | const newValue = reconcileSnapshot(oldValue, v, modelPool, plainObj) 47 | 48 | detachIfNeeded(newValue, oldValue, modelPool) 49 | 50 | setIfDifferent(plainObj, k, newValue) 51 | } 52 | }) 53 | 54 | runTypeCheckingAfterChange(plainObj, undefined, snapshotBeforeChanges) 55 | 56 | return plainObj 57 | } 58 | 59 | /** 60 | * @internal 61 | */ 62 | export function registerPlainObjectSnapshotReconciler() { 63 | registerReconciler(SnapshotterAndReconcilerPriority.PlainObject, (value, sn, modelPool) => { 64 | if (isPlainObject(sn)) { 65 | return reconcilePlainObjectSnapshot(value, sn, modelPool) 66 | } 67 | return undefined 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /packages/lib/src/snapshot/reconcileSnapshot.ts: -------------------------------------------------------------------------------- 1 | import { set } from "mobx" 2 | import { modelIdKey, modelTypeKey } from "../model/metadata" 3 | import { isModel } from "../model/utils" 4 | import { fastGetParentPathIncludingDataObjects } from "../parent" 5 | import { failure, isMap, isPrimitive, isSet } from "../utils" 6 | import type { ModelPool } from "../utils/ModelPool" 7 | import { getSnapshot } from "./getSnapshot" 8 | import { registerDefaultReconcilers } from "./registerDefaultReconcilers" 9 | 10 | type Reconciler = (value: any, sn: any, modelPool: ModelPool, parent: any) => any 11 | 12 | const reconcilers: { priority: number; reconciler: Reconciler }[] = [] 13 | 14 | /** 15 | * @internal 16 | */ 17 | export function registerReconciler(priority: number, reconciler: Reconciler): void { 18 | reconcilers.push({ priority, reconciler }) 19 | reconcilers.sort((a, b) => a.priority - b.priority) 20 | } 21 | 22 | /** 23 | * @internal 24 | */ 25 | export function reconcileSnapshot(value: any, sn: any, modelPool: ModelPool, parent: any): any { 26 | if (isPrimitive(sn)) { 27 | return sn 28 | } 29 | 30 | // if the snapshot passed is exactly the same as the current one 31 | // then it is already reconciled 32 | if (getSnapshot(value) === sn) { 33 | return value 34 | } 35 | 36 | registerDefaultReconcilers() 37 | 38 | const reconcilersLen = reconcilers.length 39 | for (let i = 0; i < reconcilersLen; i++) { 40 | const { reconciler } = reconcilers[i] 41 | const ret = reconciler(value, sn, modelPool, parent) 42 | if (ret !== undefined) { 43 | return ret 44 | } 45 | } 46 | 47 | if (isMap(sn)) { 48 | throw failure("a snapshot must not contain maps") 49 | } 50 | 51 | if (isSet(sn)) { 52 | throw failure("a snapshot must not contain sets") 53 | } 54 | 55 | throw failure(`unsupported snapshot - ${sn}`) 56 | } 57 | 58 | /** 59 | * @internal 60 | */ 61 | export function detachIfNeeded(newValue: any, oldValue: any, modelPool: ModelPool) { 62 | // edge case for when we are swapping models around the tree 63 | 64 | if (newValue === oldValue) { 65 | // already where it should be 66 | return 67 | } 68 | 69 | if ( 70 | isModel(newValue) && 71 | modelPool.findModelByTypeAndId(newValue[modelTypeKey], newValue[modelIdKey]) 72 | ) { 73 | const parentPath = fastGetParentPathIncludingDataObjects(newValue, false) 74 | if (parentPath) { 75 | set(parentPath.parent, parentPath.path, null) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/lib/src/snapshot/registerDefaultReconcilers.ts: -------------------------------------------------------------------------------- 1 | import { registerArraySnapshotReconciler } from "./reconcileArraySnapshot" 2 | import { registerFrozenSnapshotReconciler } from "./reconcileFrozenSnapshot" 3 | import { registerModelSnapshotReconciler } from "./reconcileModelSnapshot" 4 | import { registerPlainObjectSnapshotReconciler } from "./reconcilePlainObjectSnapshot" 5 | 6 | let defaultReconcilersRegistered = false 7 | 8 | /** 9 | * @internal 10 | */ 11 | export function registerDefaultReconcilers() { 12 | if (defaultReconcilersRegistered) { 13 | return 14 | } 15 | defaultReconcilersRegistered = true 16 | 17 | registerArraySnapshotReconciler() 18 | registerFrozenSnapshotReconciler() 19 | registerModelSnapshotReconciler() 20 | registerPlainObjectSnapshotReconciler() 21 | } 22 | -------------------------------------------------------------------------------- /packages/lib/src/snapshot/registerDefaultSnapshotters.ts: -------------------------------------------------------------------------------- 1 | import { registerFromArraySnapshotter } from "./fromArraySnapshot" 2 | import { registerFromFrozenSnapshotter } from "./fromFrozenSnapshot" 3 | import { registerFromModelSnapshotter } from "./fromModelSnapshot" 4 | import { registerFromPlainObjectSnapshotter } from "./fromPlainObjectSnapshot" 5 | 6 | let defaultSnapshottersRegistered = false 7 | 8 | /** 9 | * @internal 10 | */ 11 | export function registerDefaultSnapshotters() { 12 | if (defaultSnapshottersRegistered) { 13 | return 14 | } 15 | defaultSnapshottersRegistered = true 16 | 17 | registerFromArraySnapshotter() 18 | registerFromFrozenSnapshotter() 19 | registerFromModelSnapshotter() 20 | registerFromPlainObjectSnapshotter() 21 | } 22 | -------------------------------------------------------------------------------- /packages/lib/src/standardActions/actions.ts: -------------------------------------------------------------------------------- 1 | import { AnyFunction } from "../utils/AnyFunction" 2 | import { ActionContextActionType } from "../action/context" 3 | import { isModelAction } from "../action/isModelAction" 4 | import { flow, isModelFlow } from "../action/modelFlow" 5 | import { wrapInAction } from "../action/wrapInAction" 6 | import { assertIsTreeNode } from "../tweaker/core" 7 | import { assertIsFunction, failure, logWarning } from "../utils" 8 | 9 | /** 10 | * A function with an object as target. 11 | */ 12 | type TargetedAction = AnyFunction 13 | 14 | const standaloneActionRegistry = new Map() 15 | 16 | /** 17 | * @internal 18 | */ 19 | export function getStandaloneAction(actionName: string) { 20 | return standaloneActionRegistry.get(actionName) 21 | } 22 | 23 | /** 24 | * @internal 25 | */ 26 | export function addStandaloneAction(fullActionName: string, fn: TargetedAction, isFlow: boolean) { 27 | assertIsFunction(fn, fullActionName) 28 | 29 | if (standaloneActionRegistry.has(fullActionName)) { 30 | logWarning( 31 | "warn", 32 | `an standalone action with name "${fullActionName}" already exists (if you are using hot-reloading you may safely ignore this warning)`, 33 | `duplicateActionName - ${fullActionName}` 34 | ) 35 | } 36 | 37 | if (isModelAction(fn)) { 38 | throw failure("the standalone action must not be previously marked as an action") 39 | } 40 | if (isModelFlow(fn)) { 41 | throw failure("the standalone action must not be previously marked as a flow action") 42 | } 43 | 44 | const wrappedAction = isFlow 45 | ? flow({ nameOrNameFn: fullActionName, generator: fn }) 46 | : wrapInAction({ 47 | nameOrNameFn: fullActionName, 48 | fn, 49 | actionType: ActionContextActionType.Sync, 50 | }) 51 | 52 | const finalAction = (target: any, ...args: any[]) => { 53 | assertIsTreeNode(target, "target") 54 | 55 | // we need to put the target into this 56 | return wrappedAction.call(target, target, ...args) 57 | } 58 | 59 | standaloneActionRegistry.set(fullActionName, finalAction) 60 | return finalAction 61 | } 62 | -------------------------------------------------------------------------------- /packages/lib/src/standardActions/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./arrayActions" 2 | export * from "./objectActions" 3 | export * from "./standaloneActions" 4 | -------------------------------------------------------------------------------- /packages/lib/src/standardActions/objectActions.ts: -------------------------------------------------------------------------------- 1 | import { isObservable, remove } from "mobx" 2 | import { toTreeNode } from "../tweaker/tweak" 3 | import { assertIsObject, namespace as ns } from "../utils" 4 | import { setIfDifferent } from "../utils/setIfDifferent" 5 | import { standaloneAction } from "./standaloneActions" 6 | import { AnyFunction } from "../utils/AnyFunction" 7 | 8 | const namespace = `${ns}/objectActions` 9 | 10 | export const objectActions = { 11 | set: standaloneAction( 12 | `${namespace}::set`, 13 | (target: T, key: K, value: T[K]): void => { 14 | if (isObservable(target)) { 15 | setIfDifferent(target, key, value) 16 | } else { 17 | target[key] = value 18 | } 19 | } 20 | ), 21 | 22 | assign: standaloneAction( 23 | `${namespace}::assign`, 24 | (target: T, partialObject: Partial): void => { 25 | assertIsObject(partialObject, "partialObject") 26 | const keys = Object.keys(partialObject) 27 | 28 | if (isObservable(target)) { 29 | for (const key of keys) { 30 | const newValue = (partialObject as any)[key] 31 | setIfDifferent(target, key, newValue) 32 | } 33 | } else { 34 | for (const key of keys) { 35 | ;(target as any)[key] = (partialObject as any)[key] 36 | } 37 | } 38 | } 39 | ), 40 | 41 | delete: standaloneAction( 42 | `${namespace}::delete`, 43 | (target: T, key: K): boolean => { 44 | return remove(target, key as any) 45 | } 46 | ), 47 | 48 | call: standaloneAction( 49 | `${namespace}::call`, 50 | ( 51 | target: T, 52 | methodName: K, 53 | ...args: T[K] extends AnyFunction ? Parameters : never 54 | ): T[K] extends AnyFunction ? ReturnType : never => { 55 | return (target as any)[methodName](...args) 56 | } 57 | ), 58 | 59 | create: (data: T): T => toTreeNode(data), 60 | } 61 | -------------------------------------------------------------------------------- /packages/lib/src/standardActions/standaloneActions.ts: -------------------------------------------------------------------------------- 1 | import { addStandaloneAction } from "./actions" 2 | 3 | /** 4 | * Creates a standalone action. A standalone action must always take an existing tree node as first argument. 5 | * 6 | * @param actionName Unique action name. 7 | * @param fn Function. 8 | * @returns The function as an standalone action. 9 | */ 10 | export function standaloneAction any>( 11 | actionName: string, 12 | fn: FN 13 | ): FN { 14 | return addStandaloneAction(actionName, fn, false) as unknown as FN 15 | } 16 | 17 | /** 18 | * Creates a standalone flow. A standalone flow must always take an existing tree node as first argument. 19 | * 20 | * @param actionName Unique action name. 21 | * @param fn Function. 22 | * @returns The function as an standalone flow. 23 | */ 24 | export function standaloneFlow( 25 | actionName: string, 26 | fn: (target: TTarget, ...args: TArgs) => Generator 27 | ): (target: TTarget, ...args: TArgs) => Promise { 28 | return addStandaloneAction(actionName, fn, true) as unknown as any 29 | } 30 | -------------------------------------------------------------------------------- /packages/lib/src/transforms/ImmutableDate.ts: -------------------------------------------------------------------------------- 1 | import { failure } from "../utils" 2 | 3 | /** 4 | * @internal 5 | */ 6 | export interface ImmutableDate 7 | extends Omit< 8 | Date, 9 | | "setTime" 10 | | "setMilliseconds" 11 | | "setUTCMilliseconds" 12 | | "setSeconds" 13 | | "setUTCSeconds" 14 | | "setMinutes" 15 | | "setUTCMinutes" 16 | | "setHours" 17 | | "setUTCHours" 18 | | "setDate" 19 | | "setUTCDate" 20 | | "setMonth" 21 | | "setUTCMonth" 22 | | "setFullYear" 23 | | "setUTCFullYear" 24 | > {} 25 | 26 | const errMessage = "this Date object is immutable" 27 | 28 | /** 29 | * @internal 30 | */ 31 | export class ImmutableDate extends Date { 32 | // disable mutable methods 33 | 34 | setTime(): any { 35 | throw failure(errMessage) 36 | } 37 | 38 | setMilliseconds(): any { 39 | throw failure(errMessage) 40 | } 41 | 42 | setUTCMilliseconds(): any { 43 | throw failure(errMessage) 44 | } 45 | 46 | setSeconds(): any { 47 | throw failure(errMessage) 48 | } 49 | 50 | setUTCSeconds(): any { 51 | throw failure(errMessage) 52 | } 53 | 54 | setMinutes(): any { 55 | throw failure(errMessage) 56 | } 57 | 58 | setUTCMinutes(): any { 59 | throw failure(errMessage) 60 | } 61 | 62 | setHours(): any { 63 | throw failure(errMessage) 64 | } 65 | 66 | setUTCHours(): any { 67 | throw failure(errMessage) 68 | } 69 | 70 | setDate(): any { 71 | throw failure(errMessage) 72 | } 73 | 74 | setUTCDate(): any { 75 | throw failure(errMessage) 76 | } 77 | 78 | setMonth(): any { 79 | throw failure(errMessage) 80 | } 81 | 82 | setUTCMonth(): any { 83 | throw failure(errMessage) 84 | } 85 | 86 | setFullYear(): any { 87 | throw failure(errMessage) 88 | } 89 | 90 | setUTCFullYear(): any { 91 | throw failure(errMessage) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /packages/lib/src/transforms/asMap.ts: -------------------------------------------------------------------------------- 1 | import type { ModelPropTransform } from "../modelShared/prop" 2 | import { asMap } from "../wrappers/asMap" 3 | 4 | const _objectToMapTransform: ModelPropTransform, Map> = { 5 | transform({ originalValue: obj, cachedTransformedValue: cachedMap }) { 6 | return cachedMap ?? (asMap(obj) as unknown as Map) 7 | }, 8 | 9 | untransform({ transformedValue: map }) { 10 | // do not cache map <-> obj relationship 11 | 12 | const obj: Record = {} 13 | map.forEach((v, k) => { 14 | obj[k] = v 15 | }) 16 | 17 | return obj 18 | }, 19 | } 20 | 21 | export const objectToMapTransform = () => 22 | _objectToMapTransform as ModelPropTransform, Map> 23 | 24 | const _arrayToMapTransform: ModelPropTransform, Map> = { 25 | transform: ({ originalValue: arr, cachedTransformedValue: cachedMap }) => { 26 | return cachedMap ?? (asMap(arr) as unknown as Map) 27 | }, 28 | 29 | untransform({ transformedValue: map }) { 30 | // do not cache map <-> arr relationship 31 | 32 | const arr: Array<[unknown, unknown]> = [] 33 | map.forEach((v, k) => { 34 | arr.push([k, v]) 35 | }) 36 | 37 | return arr 38 | }, 39 | } 40 | 41 | export const arrayToMapTransform = () => 42 | _arrayToMapTransform as ModelPropTransform, Map> 43 | -------------------------------------------------------------------------------- /packages/lib/src/transforms/asSet.ts: -------------------------------------------------------------------------------- 1 | import { ObservableSet } from "mobx" 2 | import type { ModelPropTransform } from "../modelShared/prop" 3 | import { asSet } from "../wrappers/asSet" 4 | 5 | const _arrayToSetTransform: ModelPropTransform, ObservableSet> = { 6 | transform({ originalValue: arr, cachedTransformedValue: cachedSet }) { 7 | return cachedSet ?? asSet(arr) 8 | }, 9 | 10 | untransform({ transformedValue: set }) { 11 | // do not cache set <-> arr relationship 12 | 13 | return Array.from(set.values()) 14 | }, 15 | } 16 | 17 | export const arrayToSetTransform = () => 18 | _arrayToSetTransform as ModelPropTransform, Set | ObservableSet> 19 | -------------------------------------------------------------------------------- /packages/lib/src/transforms/bigint.ts: -------------------------------------------------------------------------------- 1 | import type { ModelPropTransform } from "../modelShared/prop" 2 | 3 | const _stringToBigIntTransform: ModelPropTransform = { 4 | transform({ originalValue, cachedTransformedValue }) { 5 | return cachedTransformedValue ?? BigInt(originalValue) 6 | }, 7 | 8 | untransform({ transformedValue, cacheTransformedValue }) { 9 | if (typeof transformedValue === "bigint") { 10 | cacheTransformedValue() 11 | } 12 | return transformedValue.toString() 13 | }, 14 | } 15 | 16 | export const stringToBigIntTransform = () => _stringToBigIntTransform 17 | -------------------------------------------------------------------------------- /packages/lib/src/transforms/date.ts: -------------------------------------------------------------------------------- 1 | import type { ModelPropTransform } from "../modelShared/prop" 2 | import { ImmutableDate } from "./ImmutableDate" 3 | 4 | const _timestampToDateTransform: ModelPropTransform = { 5 | transform({ originalValue, cachedTransformedValue }) { 6 | return cachedTransformedValue ?? new ImmutableDate(originalValue) 7 | }, 8 | 9 | untransform({ transformedValue, cacheTransformedValue }) { 10 | if (transformedValue instanceof ImmutableDate) { 11 | cacheTransformedValue() 12 | } 13 | return +transformedValue 14 | }, 15 | } 16 | 17 | export const timestampToDateTransform = () => _timestampToDateTransform 18 | 19 | const _isoStringToDateTransform: ModelPropTransform = { 20 | transform({ originalValue, cachedTransformedValue }) { 21 | return cachedTransformedValue ?? new ImmutableDate(originalValue) 22 | }, 23 | 24 | untransform({ transformedValue, cacheTransformedValue }) { 25 | if (transformedValue instanceof ImmutableDate) { 26 | cacheTransformedValue() 27 | } 28 | return transformedValue.toISOString() 29 | }, 30 | } 31 | 32 | export const isoStringToDateTransform = () => _isoStringToDateTransform 33 | -------------------------------------------------------------------------------- /packages/lib/src/transforms/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./asMap" 2 | export * from "./asSet" 3 | export * from "./bigint" 4 | export * from "./date" 5 | -------------------------------------------------------------------------------- /packages/lib/src/treeUtils/deepEquals.ts: -------------------------------------------------------------------------------- 1 | import fastDeepEqual from "fast-deep-equal/es6" 2 | import { isObservable, toJS } from "mobx" 3 | import { getSnapshot } from "../snapshot" 4 | import { isTreeNode } from "../tweaker" 5 | import { getMobxVersion } from "../utils" 6 | 7 | /** 8 | * Deeply compares two values. 9 | * 10 | * Supported values are: 11 | * - Primitives 12 | * - Boxed observables 13 | * - Objects, observable objects 14 | * - Arrays, observable arrays 15 | * - Typed arrays 16 | * - Maps, observable maps 17 | * - Sets, observable sets 18 | * - Tree nodes (optimized by using snapshot comparison internally) 19 | * 20 | * Note that in the case of models the result will be false if their model IDs are different. 21 | * 22 | * @param a First value to compare. 23 | * @param b Second value to compare. 24 | * @returns `true` if they are the equivalent, `false` otherwise. 25 | */ 26 | export function deepEquals(a: any, b: any): boolean { 27 | // quick check for reference 28 | if (a === b) { 29 | return true 30 | } 31 | 32 | // use snapshots to compare if possible 33 | // since snapshots use structural sharing it is more likely 34 | // to speed up comparisons 35 | if (isTreeNode(a)) { 36 | a = getSnapshot(a) 37 | } else if (isObservable(a)) { 38 | a = toJS(a, toJSOptions) 39 | } 40 | if (isTreeNode(b)) { 41 | b = getSnapshot(b) 42 | } else if (isObservable(b)) { 43 | b = toJS(b, toJSOptions) 44 | } 45 | 46 | return fastDeepEqual(a, b) 47 | } 48 | 49 | const toJSOptions = 50 | getMobxVersion() >= 6 51 | ? undefined 52 | : { 53 | exportMapsAsObjects: false, 54 | recurseEverything: false, 55 | } 56 | -------------------------------------------------------------------------------- /packages/lib/src/treeUtils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./deepEquals" 2 | export * from "./draft" 3 | export * from "./sandbox" 4 | -------------------------------------------------------------------------------- /packages/lib/src/tweaker/TweakerPriority.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @internal 3 | */ 4 | export enum TweakerPriority { 5 | Model = 0, 6 | Array = 1, 7 | PlainObject = 2, 8 | Frozen = 3, 9 | } 10 | -------------------------------------------------------------------------------- /packages/lib/src/tweaker/core.ts: -------------------------------------------------------------------------------- 1 | import { runInAction } from "mobx" 2 | import { dataObjectParent } from "../parent/core" 3 | import { failure, isPrimitive } from "../utils" 4 | 5 | /** 6 | * @internal 7 | */ 8 | export const tweakedObjects = new WeakMap void)>() 9 | 10 | /** 11 | * @internal 12 | */ 13 | export function isTweakedObject(value: unknown, canBeDataObject: boolean): value is object { 14 | if (!canBeDataObject && dataObjectParent.has(value as object)) { 15 | return false 16 | } 17 | return tweakedObjects.has(value as object) 18 | } 19 | 20 | /** 21 | * Checks if a given object is now a tree node. 22 | * 23 | * @param value Value to check. 24 | * @returns true if it is a tree node, false otherwise. 25 | */ 26 | export function isTreeNode(value: unknown): value is object { 27 | return !isPrimitive(value) && isTweakedObject(value, false) 28 | } 29 | 30 | /** 31 | * @internal 32 | */ 33 | export function assertTweakedObject( 34 | treeNode: unknown, 35 | argName: string, 36 | canBeDataObject = false 37 | ): asserts treeNode is object { 38 | if (!canBeDataObject && dataObjectParent.has(treeNode as object)) { 39 | throw failure(`${argName} must be the model object instance instead of the '$' sub-object`) 40 | } 41 | if (isPrimitive(treeNode) || !isTweakedObject(treeNode, true)) { 42 | throw failure( 43 | `${argName} must be a tree node (usually a model or a shallow / deep child part of a model 'data' object)` 44 | ) 45 | } 46 | } 47 | 48 | /** 49 | * Asserts a given object is now a tree node, or throws otherwise. 50 | * 51 | * @param value Value to check. 52 | * @param argName Argument name, part of the thrown error description. 53 | */ 54 | export function assertIsTreeNode(value: unknown, argName = "argument"): asserts value is object { 55 | assertTweakedObject(value, argName, false) 56 | } 57 | 58 | /** 59 | * @internal 60 | */ 61 | export let runningWithoutSnapshotOrPatches = false 62 | 63 | /** 64 | * @internal 65 | */ 66 | export function runWithoutSnapshotOrPatches(fn: () => void) { 67 | const old = runningWithoutSnapshotOrPatches 68 | runningWithoutSnapshotOrPatches = true 69 | try { 70 | runInAction(() => { 71 | fn() 72 | }) 73 | } finally { 74 | runningWithoutSnapshotOrPatches = old 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /packages/lib/src/tweaker/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./core" 2 | export * from "./tweak" 3 | -------------------------------------------------------------------------------- /packages/lib/src/tweaker/registerDefaultTweakers.ts: -------------------------------------------------------------------------------- 1 | import { registerArrayTweaker } from "./tweakArray" 2 | import { registerFrozenTweaker } from "./tweakFrozen" 3 | import { registerModelTweaker } from "./tweakModel" 4 | import { registerPlainObjectTweaker } from "./tweakPlainObject" 5 | 6 | let defaultTweakersRegistered = false 7 | 8 | /** 9 | * @internal 10 | */ 11 | export function registerDefaultTweakers() { 12 | if (defaultTweakersRegistered) { 13 | return 14 | } 15 | defaultTweakersRegistered = true 16 | 17 | registerArrayTweaker() 18 | registerFrozenTweaker() 19 | registerModelTweaker() 20 | registerPlainObjectTweaker() 21 | } 22 | -------------------------------------------------------------------------------- /packages/lib/src/tweaker/tweakFrozen.ts: -------------------------------------------------------------------------------- 1 | import { Frozen, frozenKey } from "../frozen/Frozen" 2 | import type { ParentPath } from "../parent/path" 3 | import { setParent } from "../parent/setParent" 4 | import { setNewInternalSnapshot } from "../snapshot/internal" 5 | import { tweakedObjects } from "./core" 6 | import { registerTweaker } from "./tweak" 7 | import { TweakerPriority } from "./TweakerPriority" 8 | 9 | /** 10 | * @internal 11 | */ 12 | export function tweakFrozen>( 13 | frozenObj: T, 14 | parentPath: ParentPath | undefined 15 | ): T { 16 | tweakedObjects.set(frozenObj, undefined) 17 | setParent( 18 | frozenObj, // value 19 | parentPath, 20 | false, // indexChangeAllowed 21 | false, // isDataObject 22 | // a frozen is not a value-type 23 | false // cloneIfApplicable 24 | ) 25 | 26 | // we DON'T want data proxified, but the snapshot is the data itself 27 | setNewInternalSnapshot(frozenObj, { [frozenKey]: true, data: frozenObj.data }, undefined, true) 28 | 29 | return frozenObj 30 | } 31 | 32 | /** 33 | * @internal 34 | */ 35 | export function registerFrozenTweaker() { 36 | registerTweaker(TweakerPriority.Frozen, (value, parentPath) => { 37 | if (value instanceof Frozen) { 38 | return tweakFrozen(value, parentPath) 39 | } 40 | return undefined 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /packages/lib/src/tweaker/tweakModel.ts: -------------------------------------------------------------------------------- 1 | import { isModel } from "../model/utils" 2 | import type { ParentPath } from "../parent/path" 3 | import { setParent } from "../parent/setParent" 4 | import { tweakedObjects } from "./core" 5 | import { registerTweaker } from "./tweak" 6 | import { TweakerPriority } from "./TweakerPriority" 7 | 8 | /** 9 | * @internal 10 | */ 11 | export function tweakModel(value: T, parentPath: ParentPath | undefined): T { 12 | tweakedObjects.set(value, undefined) 13 | setParent( 14 | value, 15 | parentPath, 16 | false, // indexChangeAllowed 17 | false, // isDataObject 18 | true // cloneIfApplicable 19 | ) 20 | 21 | // nothing to do for models, data is already proxified and its parent is set 22 | // for snapshots we will use its "$" object snapshot directly 23 | 24 | return value 25 | } 26 | 27 | /** 28 | * @internal 29 | */ 30 | export function registerModelTweaker() { 31 | registerTweaker(TweakerPriority.Model, (value, parentPath) => { 32 | if (isModel(value)) { 33 | return tweakModel(value, parentPath) 34 | } 35 | return undefined 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /packages/lib/src/tweaker/withoutTypeChecking.ts: -------------------------------------------------------------------------------- 1 | let typeCheckingAllowed = true 2 | 3 | export function withoutTypeChecking(fn: () => void): void { 4 | const oldTypeCheckingAllowed = typeCheckingAllowed 5 | typeCheckingAllowed = false 6 | 7 | try { 8 | fn() 9 | } finally { 10 | typeCheckingAllowed = oldTypeCheckingAllowed 11 | } 12 | } 13 | 14 | export function isTypeCheckingAllowed() { 15 | return typeCheckingAllowed 16 | } 17 | -------------------------------------------------------------------------------- /packages/lib/src/types/TypeCheckError.ts: -------------------------------------------------------------------------------- 1 | import { fastGetRootPath } from "../parent/path" 2 | import { Path } from "../parent/pathTypes" 3 | import { getSnapshot } from "../snapshot/getSnapshot" 4 | import { isTweakedObject } from "../tweaker/core" 5 | import { failure } from "../utils" 6 | 7 | /** 8 | * A type checking error. 9 | */ 10 | export class TypeCheckError { 11 | /** 12 | * The type check error message. 13 | */ 14 | readonly message: string 15 | 16 | /** 17 | * Creates an instance of TypeError. 18 | * @param path Sub-path (where the root is the value being type checked) where the error occured. 19 | * @param expectedTypeName Name of the expected type. 20 | * @param actualValue Actual value. 21 | * @param typeCheckedValue The value where the type check was invoked. 22 | */ 23 | constructor( 24 | readonly path: Path, 25 | readonly expectedTypeName: string, 26 | readonly actualValue: any, 27 | readonly typeCheckedValue?: any 28 | ) { 29 | let rootPath: Path = [] 30 | if (this.typeCheckedValue && isTweakedObject(this.typeCheckedValue, true)) { 31 | rootPath = fastGetRootPath(this.typeCheckedValue, false).path 32 | } 33 | 34 | const actualValueSnapshot = isTweakedObject(this.actualValue, true) 35 | ? getSnapshot(this.actualValue) 36 | : this.actualValue 37 | 38 | this.message = `TypeCheckError: [/${[...rootPath, ...this.path].join( 39 | "/" 40 | )}] Expected a value of type <${this.expectedTypeName}> but got the value <${JSON.stringify( 41 | actualValueSnapshot 42 | )}> instead` 43 | } 44 | 45 | /** 46 | * Throws the type check error as an actual error. 47 | */ 48 | throw(): never { 49 | throw failure(this.message) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/lib/src/types/getTypeInfo.ts: -------------------------------------------------------------------------------- 1 | import { failure } from "../utils" 2 | import { resolveStandardType } from "./resolveTypeChecker" 3 | import type { AnyType } from "./schemas" 4 | import type { LateTypeChecker, TypeChecker, TypeInfo } from "./TypeChecker" 5 | 6 | /** 7 | * Gets the type info of a given type. 8 | * 9 | * @param type Type to get the info from. 10 | * @returns The type info. 11 | */ 12 | export function getTypeInfo(type: AnyType): TypeInfo { 13 | const stdType = resolveStandardType(type) 14 | const typeInfo = (stdType as any as TypeChecker | LateTypeChecker).typeInfo 15 | if (!typeInfo) { 16 | throw failure(`type info not found for ${type}`) 17 | } 18 | return typeInfo 19 | } 20 | -------------------------------------------------------------------------------- /packages/lib/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./schemas" 2 | export * from "./tProp" 3 | export * from "./typeCheck" 4 | export * from "./TypeCheckError" 5 | export * from "./types" 6 | -------------------------------------------------------------------------------- /packages/lib/src/types/objectBased/typesRef.ts: -------------------------------------------------------------------------------- 1 | import { modelTypeKey } from "../../model/metadata" 2 | import { modelInfoByClass } from "../../modelShared/modelInfo" 3 | import { Ref, RefConstructor } from "../../ref/Ref" 4 | import { isObject } from "../../utils" 5 | import { typesString } from "../primitiveBased/typesPrimitive" 6 | import { resolveTypeChecker } from "../resolveTypeChecker" 7 | import type { ModelType } from "../schemas" 8 | import { TypeChecker, TypeCheckerBaseType, TypeInfo } from "../TypeChecker" 9 | import { TypeCheckError } from "../TypeCheckError" 10 | import { typesObject } from "./typesObject" 11 | 12 | /** 13 | * A type that represents a reference to an object or model. 14 | * 15 | * Example: 16 | * ```ts 17 | * const refToSomeObject = types.ref(SomeObject) 18 | * ``` 19 | * 20 | * @template O Object or model type. 21 | * @param refConstructor Ref object type. 22 | * @returns 23 | */ 24 | export function typesRef(refConstructor: RefConstructor): ModelType> { 25 | const typeName = "Ref" 26 | 27 | const modelInfo = modelInfoByClass.get(refConstructor.refClass)! 28 | 29 | const refDataTypeChecker = resolveTypeChecker( 30 | typesObject(() => ({ 31 | id: typesString, 32 | })) 33 | ) 34 | 35 | const thisTc: TypeChecker = new TypeChecker( 36 | TypeCheckerBaseType.Object, 37 | 38 | (value, path, typeCheckedValue) => { 39 | if (!(value instanceof Ref)) { 40 | return new TypeCheckError(path, typeName, value, typeCheckedValue) 41 | } 42 | 43 | return refDataTypeChecker.check(value.$, path, typeCheckedValue) 44 | }, 45 | 46 | () => typeName, 47 | (t) => new RefTypeInfo(t), 48 | 49 | (obj) => { 50 | if (!isObject(obj)) { 51 | return null 52 | } 53 | 54 | if (obj[modelTypeKey] !== undefined) { 55 | // fast check 56 | return obj[modelTypeKey] === modelInfo.name ? thisTc : null 57 | } 58 | 59 | return refDataTypeChecker.snapshotType(obj) ? thisTc : null 60 | }, 61 | 62 | (sn: Record) => { 63 | if (sn[modelTypeKey]) { 64 | return sn 65 | } else { 66 | return { 67 | ...sn, 68 | [modelTypeKey]: modelInfo.name, 69 | } 70 | } 71 | }, 72 | 73 | (sn) => sn 74 | ) 75 | 76 | return thisTc as any 77 | } 78 | 79 | /** 80 | * `types.ref` type info. 81 | */ 82 | export class RefTypeInfo extends TypeInfo {} 83 | -------------------------------------------------------------------------------- /packages/lib/src/types/primitiveBased/typesEnum.ts: -------------------------------------------------------------------------------- 1 | import { assertIsObject } from "../../utils" 2 | import type { IdentityType } from "../schemas" 3 | import { typesOr } from "../utility/typesOr" 4 | import { typesLiteral } from "./typesPrimitive" 5 | 6 | /** 7 | * @ignore 8 | * Enum like object. 9 | */ 10 | export interface EnumLike { 11 | [k: string]: number | string 12 | [v: number]: string 13 | } 14 | 15 | /** 16 | * @internal 17 | */ 18 | export function enumValues(e: EnumLike): (string | number)[] { 19 | const vals: (string | number)[] = [] 20 | for (const k of Object.keys(e)) { 21 | const v = e[k] 22 | // we have to do this since TS does something weird 23 | // to number values 24 | // Hi = 0 -> { Hi: 0, 0: "Hi" } 25 | // and SWC currently generates enum code inconsistent with TS/Babel 26 | // https://github.com/swc-project/swc/issues/3711 27 | if (!vals.includes(v) && ((typeof v !== "string" && v !== +k) || e[v] !== +k)) { 28 | vals.push(v) 29 | } 30 | } 31 | return vals 32 | } 33 | 34 | /** 35 | * @ignore 36 | * Extract enum values out of a enum object. 37 | */ 38 | export type EnumValues = E extends Record ? V : never 39 | 40 | /** 41 | * An enum type, based on a TypeScript alike enum object. 42 | * Syntactic sugar for `types.or(...enum_values.map(types.literal))` 43 | * 44 | * Example: 45 | * ```ts 46 | * enum Color { 47 | * Red = "red", 48 | * Green = "green" 49 | * } 50 | * 51 | * const colorType = types.enum(Color) 52 | * ``` 53 | * 54 | * @template E Enum type. 55 | * @param enumObject 56 | * @returns 57 | */ 58 | export function typesEnum(enumObject: E): IdentityType> { 59 | assertIsObject(enumObject, "enumObject") 60 | 61 | const literals = enumValues(enumObject).map((e) => typesLiteral(e)) 62 | return typesOr(...literals) as any 63 | } 64 | -------------------------------------------------------------------------------- /packages/lib/src/types/primitiveBased/typesRefinedPrimitive.ts: -------------------------------------------------------------------------------- 1 | import { typesRefinement } from "../utility/typesRefinement" 2 | import { typesNumber, typesString } from "./typesPrimitive" 3 | 4 | /** 5 | * A type that represents any integer number value. 6 | * Syntactic sugar for `types.refinement(types.number, n => Number.isInteger(n), "integer")` 7 | * 8 | * ```ts 9 | * types.integer 10 | * ``` 11 | */ 12 | export const typesInteger = typesRefinement(typesNumber, (n) => Number.isInteger(n), "integer") 13 | 14 | /** 15 | * A type that represents any string value other than "". 16 | * Syntactic sugar for `types.refinement(types.string, s => s !== "", "nonEmpty")` 17 | * 18 | * ```ts 19 | * types.nonEmptyString 20 | * ``` 21 | */ 22 | export const typesNonEmptyString = typesRefinement(typesString, (s) => s !== "", "nonEmpty") 23 | -------------------------------------------------------------------------------- /packages/lib/src/types/registerDefaultStandardTypeResolvers.ts: -------------------------------------------------------------------------------- 1 | import { registerDataModelDataStandardTypeResolver } from "./objectBased/typesDataModelData" 2 | import { registerModelStandardTypeResolver } from "./objectBased/typesModel" 3 | import { registerPrimitiveStandardTypeResolvers } from "./primitiveBased/typesPrimitive" 4 | 5 | let defaultStandardTypeResolversRegistered = false 6 | 7 | /** 8 | * @internal 9 | */ 10 | export function registerDefaultStandardTypeResolvers() { 11 | if (defaultStandardTypeResolversRegistered) { 12 | return 13 | } 14 | defaultStandardTypeResolversRegistered = true 15 | 16 | registerModelStandardTypeResolver() 17 | registerDataModelDataStandardTypeResolver() 18 | registerPrimitiveStandardTypeResolvers() 19 | } 20 | -------------------------------------------------------------------------------- /packages/lib/src/types/resolveTypeChecker.ts: -------------------------------------------------------------------------------- 1 | import { failure } from "../utils" 2 | import { registerDefaultStandardTypeResolvers } from "./registerDefaultStandardTypeResolvers" 3 | import type { AnyStandardType, AnyType } from "./schemas" 4 | import { isLateTypeChecker, LateTypeChecker, TypeChecker } from "./TypeChecker" 5 | 6 | /** 7 | * @internal 8 | */ 9 | export type StandardTypeResolverFn = (value: any) => AnyStandardType | undefined 10 | 11 | const standardTypeResolvers: StandardTypeResolverFn[] = [] 12 | 13 | /** 14 | * @internal 15 | */ 16 | export function registerStandardTypeResolver(resolverFn: StandardTypeResolverFn) { 17 | standardTypeResolvers.push(resolverFn) 18 | } 19 | 20 | function findStandardType(value: any): AnyStandardType | undefined { 21 | registerDefaultStandardTypeResolvers() 22 | 23 | for (const resolverFn of standardTypeResolvers) { 24 | const tc = resolverFn(value) 25 | if (tc) { 26 | return tc 27 | } 28 | } 29 | return undefined 30 | } 31 | 32 | /** 33 | * @internal 34 | */ 35 | export function resolveTypeChecker(v: AnyType | TypeChecker | LateTypeChecker): TypeChecker { 36 | let next: TypeChecker | LateTypeChecker = v as any 37 | while (true) { 38 | if (next instanceof TypeChecker) { 39 | return next 40 | } else if (isLateTypeChecker(next)) { 41 | next = next() 42 | } else { 43 | const tc = findStandardType(v) 44 | if (tc) { 45 | return resolveTypeChecker(tc) 46 | } 47 | throw failure("type checker could not be resolved") 48 | } 49 | } 50 | } 51 | 52 | /** 53 | * @internal 54 | */ 55 | export function resolveStandardTypeNoThrow( 56 | v: AnyType | TypeChecker | LateTypeChecker 57 | ): AnyStandardType | undefined { 58 | if (v instanceof TypeChecker || isLateTypeChecker(v)) { 59 | return v as any 60 | } else { 61 | const tc = findStandardType(v) 62 | if (tc) { 63 | return tc 64 | } 65 | return undefined 66 | } 67 | } 68 | 69 | /** 70 | * @internal 71 | */ 72 | export function resolveStandardType(v: AnyType | TypeChecker | LateTypeChecker): AnyStandardType { 73 | const tc = resolveStandardTypeNoThrow(v) 74 | if (tc) { 75 | return tc 76 | } 77 | throw failure("standard type could not be resolved") 78 | } 79 | -------------------------------------------------------------------------------- /packages/lib/src/types/typeCheck.ts: -------------------------------------------------------------------------------- 1 | import { resolveTypeChecker } from "./resolveTypeChecker" 2 | import type { AnyType, TypeToData } from "./schemas" 3 | import type { TypeCheckError } from "./TypeCheckError" 4 | 5 | /** 6 | * Checks if a value conforms to a given type. 7 | * 8 | * @template T Type. 9 | * @param type Type to check for. 10 | * @param value Value to check. 11 | * @returns A TypeError if the check fails or null if no error. 12 | */ 13 | export function typeCheck(type: T, value: TypeToData): TypeCheckError | null { 14 | const typeChecker = resolveTypeChecker(type) 15 | 16 | if (typeChecker.unchecked) { 17 | return null 18 | } else { 19 | return typeChecker.check(value, [], value) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/lib/src/types/utility/typesMaybe.ts: -------------------------------------------------------------------------------- 1 | import { typesNull, typesUndefined } from "../primitiveBased/typesPrimitive" 2 | import type { AnyType, IdentityType } from "../schemas" 3 | import { typesOr } from "./typesOr" 4 | 5 | /** 6 | * A type that represents either a type or undefined. 7 | * Syntactic sugar for `types.or(baseType, types.undefined)` 8 | * 9 | * Example: 10 | * ```ts 11 | * const numberOrUndefinedType = types.maybe(types.number) 12 | * ``` 13 | * 14 | * @template T Type. 15 | * @param baseType Type. 16 | * @returns 17 | */ 18 | export function typesMaybe(baseType: T): T | IdentityType { 19 | return typesOr(baseType, typesUndefined) 20 | } 21 | 22 | /** 23 | * A type that represents either a type or null. 24 | * Syntactic sugar for `types.or(baseType, types.null)` 25 | * 26 | * * Example: 27 | * ```ts 28 | * const numberOrNullType = types.maybeNull(types.number) 29 | * ``` 30 | * 31 | * @template T Type. 32 | * @param type Type. 33 | * @returns 34 | */ 35 | export function typesMaybeNull(type: T): T | IdentityType { 36 | return typesOr(type, typesNull) 37 | } 38 | -------------------------------------------------------------------------------- /packages/lib/src/types/utility/typesUnchecked.ts: -------------------------------------------------------------------------------- 1 | import { identityFn } from "../../utils" 2 | import type { IdentityType } from "../schemas" 3 | import { TypeChecker, TypeCheckerBaseType, TypeInfo } from "../TypeChecker" 4 | 5 | const unchecked: IdentityType = new TypeChecker( 6 | TypeCheckerBaseType.Any, 7 | null, 8 | () => "any", 9 | (t) => new UncheckedTypeInfo(t), 10 | 11 | () => unchecked as any, 12 | 13 | identityFn, 14 | identityFn 15 | ) as any 16 | 17 | /** 18 | * A type that represents a given value that won't be type checked. 19 | * This is basically a way to bail out of the runtime type checking system. 20 | * 21 | * Example: 22 | * ```ts 23 | * const uncheckedSomeModel = types.unchecked() 24 | * const anyType = types.unchecked() 25 | * const customUncheckedType = types.unchecked<(A & B) | C>() 26 | * ``` 27 | * 28 | * @template T Type of the value, or unkown if not given. 29 | * @returns 30 | */ 31 | export function typesUnchecked(): IdentityType { 32 | return unchecked 33 | } 34 | 35 | /** 36 | * `types.unchecked` type info. 37 | */ 38 | export class UncheckedTypeInfo extends TypeInfo {} 39 | -------------------------------------------------------------------------------- /packages/lib/src/utils/AnyFunction.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @internal 3 | */ 4 | export type AnyFunction = (...args: any[]) => any 5 | -------------------------------------------------------------------------------- /packages/lib/src/utils/ModelPool.ts: -------------------------------------------------------------------------------- 1 | import type { AnyModel } from "../model/BaseModel" 2 | import { getModelIdPropertyName } from "../model/getModelMetadata" 3 | import { modelIdKey, modelTypeKey } from "../model/metadata" 4 | import { isModel, isModelSnapshot } from "../model/utils" 5 | import { ModelClass } from "../modelShared/BaseModelShared" 6 | import { getModelInfoForName } from "../modelShared/modelInfo" 7 | import { dataObjectParent } from "../parent/core" 8 | import { 9 | getDeepObjectChildren, 10 | registerDeepObjectChildrenExtension, 11 | } from "../parent/coreObjectChildren" 12 | 13 | function byModelTypeAndIdKey(modelType: string, modelId: string) { 14 | return modelType + " " + modelId 15 | } 16 | 17 | export class ModelPool { 18 | private pool: ReadonlyMap 19 | 20 | constructor(root: object) { 21 | // make sure we don't use the sub-data $ object 22 | root = dataObjectParent.get(root) ?? root 23 | 24 | this.pool = getDeepChildrenModels(getDeepObjectChildren(root)) 25 | } 26 | 27 | findModelByTypeAndId(modelType: string, modelId: string | undefined): AnyModel | undefined { 28 | return modelId ? this.pool.get(byModelTypeAndIdKey(modelType, modelId)) : undefined 29 | } 30 | 31 | findModelForSnapshot(sn: any): AnyModel | undefined { 32 | if (!isModelSnapshot(sn)) { 33 | return undefined 34 | } 35 | 36 | const modelType = sn[modelTypeKey] 37 | const modelInfo = getModelInfoForName(modelType)! 38 | const modelIdPropertyName = getModelIdPropertyName(modelInfo.class as ModelClass) 39 | 40 | return modelIdPropertyName 41 | ? this.findModelByTypeAndId(modelType, (sn as any)[modelIdPropertyName]) 42 | : undefined 43 | } 44 | } 45 | 46 | const getDeepChildrenModels = registerDeepObjectChildrenExtension>({ 47 | initData() { 48 | return new Map() 49 | }, 50 | 51 | addNode(node, data) { 52 | if (isModel(node)) { 53 | const id = node[modelIdKey] 54 | if (id) { 55 | data.set(byModelTypeAndIdKey(node[modelTypeKey], id), node) 56 | } 57 | } 58 | }, 59 | }) 60 | -------------------------------------------------------------------------------- /packages/lib/src/utils/chainFns.ts: -------------------------------------------------------------------------------- 1 | export function chainFns(...fns: (F | undefined)[]): F | undefined { 2 | const definedFns = fns.filter((fn) => !!fn) 3 | if (definedFns.length <= 0) { 4 | return undefined 5 | } 6 | 7 | const chainedFn = (v: any, ...args: any[]) => { 8 | let ret = v 9 | 10 | for (let i = 0; i < definedFns.length; i++) { 11 | ret = definedFns[i](ret, ...args) 12 | } 13 | 14 | return ret 15 | } 16 | 17 | return chainedFn as unknown as F 18 | } 19 | -------------------------------------------------------------------------------- /packages/lib/src/utils/mapUtils.ts: -------------------------------------------------------------------------------- 1 | type AnyMap = Map | WeakMap 2 | 3 | export function getOrCreate(map: Map, key: K, create: () => C): V 4 | export function getOrCreate( 5 | map: WeakMap, 6 | key: K, 7 | create: () => C 8 | ): V 9 | 10 | export function getOrCreate(map: AnyMap, key: any, create: () => V) { 11 | let value = map.get(key) 12 | if (value === undefined) { 13 | value = create() 14 | map.set(key, value) 15 | } 16 | return value 17 | } 18 | -------------------------------------------------------------------------------- /packages/lib/src/utils/setIfDifferent.ts: -------------------------------------------------------------------------------- 1 | import { set } from "mobx" 2 | 3 | export function setIfDifferent(target: any, key: PropertyKey, value: unknown): void { 4 | if (target[key] !== value || !(key in target)) { 5 | set(target, key, value) 6 | } 7 | } 8 | 9 | export function setIfDifferentWithReturn(target: any, key: PropertyKey, value: unknown): boolean { 10 | if (target[key] !== value || !(key in target)) { 11 | set(target, key, value) 12 | return true 13 | } 14 | 15 | return false 16 | } 17 | -------------------------------------------------------------------------------- /packages/lib/src/utils/tag.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a tag data accessor for a target object of a certain type. 3 | * Tag data will be lazy created on access and reused for the same target object. 4 | * 5 | * @template Target Target type. 6 | * @template TagData Tag data type. 7 | * @param tagDataConstructor Function that will be called the first time the tag 8 | * for a given object is requested. 9 | * @returns The tag data associated with the target object. 10 | */ 11 | export function tag( 12 | tagDataConstructor: (target: Target) => TagData 13 | ): { 14 | for(target: Target): TagData 15 | } { 16 | const map = new WeakMap() 17 | 18 | return { 19 | for(target): TagData { 20 | if (map.has(target)) { 21 | return map.get(target)! 22 | } else { 23 | const data = tagDataConstructor(target) 24 | map.set(target, data) 25 | return data 26 | } 27 | }, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/lib/src/utils/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @ignore 3 | * 4 | * A primitive value. 5 | */ 6 | export type PrimitiveValue = undefined | null | boolean | number | string | bigint 7 | 8 | /** 9 | * @ignore 10 | * 11 | * A JSON-compatible primitive value. 12 | */ 13 | export type JSONPrimitiveValue = null | boolean | number | string 14 | 15 | /** 16 | * @ignore 17 | * 18 | * Checks if a value is optional (undefined or any). 19 | * 20 | * Examples: 21 | * - string = false 22 | * - undefined = true 23 | * - string | undefined = true 24 | * - string & undefined = false, but we don't care 25 | * - any = true 26 | * - unknown = false, but we don't care 27 | * - null = false 28 | * - string | null = false 29 | * - string & null = false 30 | */ 31 | export type IsOptionalValue = IsNeverType, FV, TV> 32 | 33 | // type _A = IsOptionalValue // false 34 | // type _B = IsOptionalValue // true 35 | // type _C = IsOptionalValue // true 36 | // type _D = IsOptionalValue // false, but we don't care 37 | // type _E = IsOptionalValue // true 38 | // type _F = IsOptionalValue // false, but we don't care 39 | 40 | /** 41 | * @ignore 42 | */ 43 | export type IsNeverType = [T] extends [never] ? IfNever : IfNotNever 44 | 45 | /** 46 | * @ignore 47 | */ 48 | export type Flatten = T extends Record ? { [P in keyof T]: T[P] } : T 49 | -------------------------------------------------------------------------------- /packages/lib/src/wrappers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ArraySet" 2 | export * from "./asMap" 3 | export * from "./asSet" 4 | export * from "./ObjectMap" 5 | -------------------------------------------------------------------------------- /packages/lib/swc.config.js: -------------------------------------------------------------------------------- 1 | // SWC configuration via a JS file is currently not supported, but we're 2 | // loading it in `jest.config.js` manually, so not a problem. 3 | // https://github.com/swc-project/swc/issues/1547 4 | 5 | const { mobxVersion } = require("./env") 6 | 7 | module.exports = { 8 | jsc: { 9 | parser: { 10 | syntax: "typescript", 11 | decorators: true, 12 | }, 13 | loose: mobxVersion <= 5, 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /packages/lib/test/action/applyDelete.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addActionMiddleware, 3 | applyAction, 4 | applyDelete, 5 | BuiltInAction, 6 | Model, 7 | prop, 8 | } from "../../src" 9 | import { autoDispose, testModel } from "../utils" 10 | 11 | @testModel("P") 12 | class P extends Model({ 13 | obj: prop | undefined>(undefined), 14 | }) {} 15 | 16 | test("applyDelete", () => { 17 | const events: any[] = [] 18 | 19 | const p = new P({ obj: { a: 1, b: 2 } }) 20 | 21 | autoDispose( 22 | addActionMiddleware({ 23 | subtreeRoot: p, 24 | middleware(ctx, next) { 25 | events.push({ 26 | event: "action started", 27 | ctx, 28 | }) 29 | const result = next() 30 | events.push({ 31 | event: "action finished", 32 | ctx, 33 | result, 34 | }) 35 | return result 36 | }, 37 | }) 38 | ) 39 | expect(events.length).toBe(0) 40 | 41 | expect(p.obj!.a).toBe(1) 42 | expect(p.obj!.b).toBe(2) 43 | expect(Object.keys(p.obj!)).toEqual(["a", "b"]) 44 | applyDelete(p.obj!, "a") 45 | expect(p.obj!.a).toBe(undefined) 46 | expect(p.obj!.b).toBe(2) 47 | expect(Object.keys(p.obj!)).toEqual(["b"]) 48 | 49 | expect(events).toMatchInlineSnapshot(` 50 | [ 51 | { 52 | "ctx": { 53 | "actionName": "$$applyDelete", 54 | "args": [ 55 | "a", 56 | ], 57 | "data": {}, 58 | "parentContext": undefined, 59 | "rootContext": [Circular], 60 | "target": { 61 | "b": 2, 62 | }, 63 | "type": "sync", 64 | }, 65 | "event": "action started", 66 | }, 67 | { 68 | "ctx": { 69 | "actionName": "$$applyDelete", 70 | "args": [ 71 | "a", 72 | ], 73 | "data": {}, 74 | "parentContext": undefined, 75 | "rootContext": [Circular], 76 | "target": { 77 | "b": 2, 78 | }, 79 | "type": "sync", 80 | }, 81 | "event": "action finished", 82 | "result": undefined, 83 | }, 84 | ] 85 | `) 86 | 87 | // apply action should work 88 | applyAction(p.obj!, { 89 | actionName: BuiltInAction.ApplyDelete, 90 | args: ["b"], 91 | targetPath: [], 92 | targetPathIds: [], 93 | }) 94 | 95 | expect(p.obj!.b).toBe(undefined) 96 | expect(Object.keys(p.obj!)).toEqual([]) 97 | }) 98 | -------------------------------------------------------------------------------- /packages/lib/test/commonSetup.ts: -------------------------------------------------------------------------------- 1 | import { configure } from "mobx" 2 | import { setGlobalConfig } from "../src" 3 | 4 | configure({ enforceActions: "always" }) 5 | 6 | let id = 1 7 | 8 | setGlobalConfig({ 9 | showDuplicateModelNameWarnings: false, 10 | modelIdGenerator() { 11 | return `id-${id++}` 12 | }, 13 | }) 14 | 15 | beforeEach(() => { 16 | id = 1 17 | }) 18 | -------------------------------------------------------------------------------- /packages/lib/test/model/create.test.ts: -------------------------------------------------------------------------------- 1 | import { idProp, Model, modelIdKey, prop, tProp, types } from "../../src" 2 | import { testModel } from "../utils" 3 | 4 | describe("create with extra properties", () => { 5 | const data = { value: 0, a: 2 } 6 | 7 | test("with unchecked props", () => { 8 | @testModel("M-unchecked") 9 | class M extends Model({ 10 | [modelIdKey]: idProp, 11 | value: prop(), 12 | }) {} 13 | 14 | const m = new M(data) 15 | expect(m instanceof M).toBeTruthy() 16 | expect(m.value).toBe(0) 17 | expect((m as any).a).toBeUndefined() 18 | expect((m.$ as any).a).toBe(2) 19 | expect((m.$ as any)[modelIdKey]).toBe(m[modelIdKey]) 20 | }) 21 | 22 | test("with checked props", () => { 23 | @testModel("M-checked") 24 | class M extends Model({ 25 | value: tProp(types.number), 26 | }) {} 27 | 28 | const m = new M(data) 29 | expect(m instanceof M).toBeTruthy() 30 | expect(m.value).toBe(0) 31 | expect((m as any).a).toBeUndefined() 32 | expect((m.$ as any).a).toBe(2) 33 | expect((m.$ as any)[modelIdKey]).toBe(m[modelIdKey]) 34 | }) 35 | 36 | test("with a custom model id", () => { 37 | @testModel("M-customId") 38 | class M extends Model({ 39 | id: idProp, 40 | }) {} 41 | 42 | const m = new M({ id: "123" }) 43 | expect(m instanceof M).toBeTruthy() 44 | expect(m.id).toBe("123") 45 | expect(m.$.id).toBe("123") 46 | expect(m[modelIdKey]).toBe("123") 47 | expect((m.$ as any)[modelIdKey]).toBe(undefined) // should not be actually stored 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /packages/lib/test/model/propsWithSameName.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, _ } from "spec.ts" 2 | import { idProp, Model, modelIdKey, prop, runUnprotected } from "../../src" 3 | import { testModel } from "../utils" 4 | 5 | @testModel("M") 6 | class M extends Model({ 7 | [modelIdKey]: idProp, 8 | $modelType: prop(), 9 | onInit: prop(() => 10), 10 | x: prop(() => 20), 11 | y: prop(() => 20), 12 | }) { 13 | // would throw, since it tries to change this.$.x and this.$ is intied on new M 14 | // x = 100 15 | 16 | // fails because already defined as prop of different type 17 | // x = "100" 18 | 19 | // fails because already defined as prop 20 | // y() { 21 | // } 22 | 23 | // ok since it is a base method 24 | onInit() { 25 | this.x += 100 26 | } 27 | } 28 | 29 | test("props with same name", () => { 30 | const m = new M({ $modelType: 5, $modelId: "10" }) 31 | 32 | expect(m.x).toBe(120) 33 | expect(m.$.x).toBe(120) 34 | runUnprotected(() => { 35 | m.x += 20 36 | }) 37 | expect(m.x).toBe(140) 38 | expect(m.$.x).toBe(140) 39 | 40 | assert(m.$modelType, _ as string) 41 | expect(typeof m.$modelType).toBe("string") 42 | assert(m.$.$modelType, _ as number) 43 | expect(m.$.$modelType).toBe(5) 44 | 45 | assert(m.onInit, _ as () => void) 46 | expect(typeof m.onInit).toBe("function") 47 | assert(m.$.onInit, _ as number) 48 | expect(m.$.onInit).toBe(10) 49 | }) 50 | -------------------------------------------------------------------------------- /packages/lib/test/parent/__snapshots__/getChildrenObjects.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getChildrenObjects {deep: true}: after delete 1`] = ` 4 | [ 5 | "array", 6 | "1-1", 7 | "array", 8 | "1-1-1", 9 | "1-1-2", 10 | "1-2", 11 | "array", 12 | "1-2-1", 13 | "1-2-2", 14 | ] 15 | `; 16 | 17 | exports[`getChildrenObjects {deep: true}: after re-add 1`] = ` 18 | [ 19 | "array", 20 | "1-1", 21 | "array", 22 | "1-1-1", 23 | "1-1-2", 24 | "1-2", 25 | "array", 26 | "1-2-1", 27 | "1-2-2", 28 | "1-3", 29 | "array", 30 | "1-3-1", 31 | "1-3-2", 32 | ] 33 | `; 34 | 35 | exports[`getChildrenObjects {deep: true}: initial 1`] = ` 36 | [ 37 | "array", 38 | "1-1", 39 | "array", 40 | "1-1-1", 41 | "1-1-2", 42 | "1-2", 43 | "array", 44 | "1-2-1", 45 | "1-2-2", 46 | "1-3", 47 | "array", 48 | "1-3-1", 49 | "1-3-2", 50 | ] 51 | `; 52 | -------------------------------------------------------------------------------- /packages/lib/test/parent/walkTree.test.ts: -------------------------------------------------------------------------------- 1 | import { autorun, observable, ObservableMap, runInAction } from "mobx" 2 | import { AnyModel, detach, Model, prop, walkTree, WalkTreeMode } from "../../src" 3 | import { testModel } from "../utils" 4 | 5 | test("walktree should be reactive", () => { 6 | type Registry = ObservableMap 7 | function registry( 8 | root: AnyModel, 9 | getId: (child: unknown) => K | undefined 10 | ): Registry { 11 | const reg = observable.map([], { deep: false }) 12 | 13 | autorun(() => { 14 | const childrenThere = new Set() 15 | walkTree( 16 | root, 17 | (n) => { 18 | const id = getId(n) 19 | if (id !== undefined) { 20 | childrenThere.add(n as any) 21 | } 22 | }, 23 | WalkTreeMode.ParentFirst 24 | ) 25 | 26 | // remove/update 27 | runInAction(() => { 28 | for (const [id, c] of reg.entries()) { 29 | if (!childrenThere.has(c)) { 30 | reg.delete(id) 31 | } 32 | } 33 | for (const c of childrenThere) { 34 | reg.set(getId(c)!, c) 35 | } 36 | }) 37 | }) 38 | 39 | return reg 40 | } 41 | 42 | @testModel("root") 43 | class Root extends Model({ 44 | children: prop(() => []), 45 | }) { 46 | readonly registry = registry(this, (n) => { 47 | if (n instanceof Child) { 48 | return n.id 49 | } 50 | return undefined 51 | }) 52 | } 53 | 54 | @testModel("child") 55 | class Child extends Model({ 56 | id: prop(), 57 | }) {} 58 | 59 | const c1 = new Child({ id: "1" }) 60 | const c2 = new Child({ id: "2" }) 61 | const c3 = new Child({ id: "3" }) 62 | const r = new Root({ 63 | children: [c1, c2, c3], 64 | }) 65 | expect([...r.registry.entries()]).toEqual([ 66 | ["1", c1], 67 | ["2", c2], 68 | ["3", c3], 69 | ]) 70 | 71 | detach(c2) 72 | 73 | expect([...r.registry.entries()]).toEqual([ 74 | ["1", c1], 75 | ["3", c3], 76 | ]) 77 | }) 78 | -------------------------------------------------------------------------------- /packages/lib/test/patch/jsonPatch.test.ts: -------------------------------------------------------------------------------- 1 | import { jsonPatchToPatch, jsonPointerToPath, patchToJsonPatch, pathToJsonPointer } from "../../src" 2 | 3 | test("JSON path conversion", () => { 4 | expect(pathToJsonPointer([])).toEqual("") 5 | expect(jsonPointerToPath("")).toEqual([]) 6 | 7 | expect(pathToJsonPointer([""])).toEqual("/") 8 | expect(jsonPointerToPath("/")).toEqual([""]) 9 | 10 | expect(pathToJsonPointer(["abc"])).toEqual("/abc") 11 | expect(jsonPointerToPath("/abc")).toEqual(["abc"]) 12 | 13 | expect(pathToJsonPointer(["abc", "def"])).toEqual("/abc/def") 14 | expect(jsonPointerToPath("/abc/def")).toEqual(["abc", "def"]) 15 | 16 | expect(pathToJsonPointer([123, 456])).toEqual("/123/456") 17 | expect(jsonPointerToPath("/123/456")).toEqual(["123", "456"]) 18 | 19 | expect(pathToJsonPointer(["/a"])).toEqual("/~1a") 20 | expect(jsonPointerToPath("/~1a")).toEqual(["/a"]) 21 | 22 | expect(pathToJsonPointer(["~a"])).toEqual("/~0a") 23 | expect(jsonPointerToPath("/~0a")).toEqual(["~a"]) 24 | }) 25 | 26 | test("JSON patch conversion", () => { 27 | expect(jsonPatchToPatch({ path: "/abc", op: "remove" })).toEqual({ 28 | path: ["abc"], 29 | op: "remove", 30 | }) 31 | 32 | expect( 33 | patchToJsonPatch({ 34 | path: ["abc"], 35 | op: "remove", 36 | }) 37 | ).toEqual({ path: "/abc", op: "remove" }) 38 | }) 39 | -------------------------------------------------------------------------------- /packages/lib/test/snapshot/clone.test.ts: -------------------------------------------------------------------------------- 1 | import { clone, getRootPath, getSnapshot } from "../../src" 2 | import { createP } from "../testbed" 3 | 4 | test("clone", () => { 5 | const p = createP() 6 | 7 | const cloneP = clone(p) 8 | 9 | let origSn = getSnapshot(p) 10 | let cloneSn = getSnapshot(cloneP) 11 | 12 | expect(getRootPath(p.p2!).root).toBe(p) 13 | expect(getRootPath(cloneP.p2!).root).toBe(cloneP) 14 | expect(p).not.toBe(cloneP) 15 | expect(p.p2).not.toBe(cloneP.p2) 16 | 17 | // ids must have changed 18 | expect(origSn.$modelId).not.toBe(cloneSn.$modelId) 19 | expect(origSn.p2!.$modelId).not.toBe(cloneSn.p2!.$modelId) 20 | 21 | // except for ids, everything else should be the same 22 | origSn = { 23 | ...origSn, 24 | $modelId: "p", 25 | p2: { 26 | ...origSn.p2!, 27 | $modelId: "p2", 28 | }, 29 | } 30 | cloneSn = { 31 | ...cloneSn, 32 | $modelId: "p", 33 | p2: { 34 | ...cloneSn.p2!, 35 | $modelId: "p2", 36 | }, 37 | } 38 | 39 | expect(origSn).toStrictEqual(cloneSn) 40 | }) 41 | -------------------------------------------------------------------------------- /packages/lib/test/snapshot/fromSnapshot.test.ts: -------------------------------------------------------------------------------- 1 | import { isObservable } from "mobx" 2 | import { fromSnapshot, modelSnapshotInWithMetadata } from "../../src" 3 | import { P, P2 } from "../testbed" 4 | 5 | const snapshot = modelSnapshotInWithMetadata(P, { 6 | $modelId: "id-2", 7 | arr: [1, 2, 3], 8 | p2: modelSnapshotInWithMetadata(P2, { 9 | $modelId: "id-1", 10 | y: 12, 11 | }), 12 | }) 13 | 14 | test("basic", () => { 15 | const p = fromSnapshot(P, snapshot) 16 | 17 | expect(p).toMatchInlineSnapshot(` 18 | P { 19 | "$": { 20 | "$modelId": "id-2", 21 | "arr": [ 22 | 1, 23 | 2, 24 | 3, 25 | ], 26 | "p2": P2 { 27 | "$": { 28 | "$modelId": "id-1", 29 | "y": 12, 30 | }, 31 | "$modelType": "P2", 32 | }, 33 | "x": 5, 34 | }, 35 | "$modelType": "P", 36 | "boundAction": [Function], 37 | "boundNonAction": [Function], 38 | } 39 | `) 40 | 41 | expect(isObservable(p)).toBeTruthy() 42 | expect(isObservable(p.p2!.$)).toBeTruthy() 43 | expect(p.p2 instanceof P2).toBeTruthy() 44 | expect(isObservable(p.arr)).toBeTruthy() 45 | }) 46 | -------------------------------------------------------------------------------- /packages/lib/test/standardActions/arrayActions.test.ts: -------------------------------------------------------------------------------- 1 | import { toJS } from "mobx" 2 | import { arrayActions, isTreeNode } from "../../src" 3 | 4 | test("typed array", () => { 5 | const arr = arrayActions.create([1, 2]) 6 | expect(isTreeNode(arr)).toBe(true) 7 | expect(arr[0]).toBe(1) 8 | expect(arr.length).toBe(2) 9 | 10 | arrayActions.set(arr, 0, 3) 11 | expect(arr[0]).toBe(3) 12 | 13 | arrayActions.delete(arr, 0) 14 | expect(arr[0]).toBe(2) 15 | expect(arr.length).toBe(1) 16 | }) 17 | 18 | test("untyped array", () => { 19 | const arr = arrayActions.create([1, 2]) 20 | expect(isTreeNode(arr)).toBe(true) 21 | expect(arr[0]).toBe(1) 22 | expect(arr.length).toBe(2) 23 | 24 | arrayActions.set(arr, 0, "3") 25 | expect(arr[0]).toBe("3") 26 | 27 | arrayActions.delete(arr, 0) 28 | expect(arr[0]).toBe(2) 29 | expect(arr.length).toBe(1) 30 | }) 31 | 32 | test("swap", () => { 33 | const arr = arrayActions.create([1, 2, 3, 4, 5]) 34 | arrayActions.swap(arr, 1, 3) 35 | expect(toJS(arr)).toEqual([1, 4, 3, 2, 5]) 36 | 37 | arrayActions.swap(arr, -1, 3) 38 | expect(toJS(arr)).toEqual([1, 4, 3, 2, 5]) 39 | 40 | arrayActions.swap(arr, 1, arr.length) 41 | expect(toJS(arr)).toEqual([1, 4, 3, 2, 5]) 42 | }) 43 | -------------------------------------------------------------------------------- /packages/lib/test/standardActions/objectActions.test.ts: -------------------------------------------------------------------------------- 1 | import { Model, modelAction, objectActions, prop } from "../../src" 2 | import { testModel } from "../utils" 3 | 4 | test("typed object", () => { 5 | const obj = objectActions.create({ a: 1 }) 6 | expect(obj.a).toBe(1) 7 | 8 | objectActions.set(obj, "a", 2) 9 | expect(obj.a).toBe(2) 10 | 11 | objectActions.delete(obj, "a") 12 | expect(obj.a).toBe(undefined) 13 | 14 | objectActions.assign(obj, { a: 3 }) 15 | expect(obj.a).toBe(3) 16 | }) 17 | 18 | test("untyped object", () => { 19 | const obj = objectActions.create({ a: 1 }) 20 | expect(obj.a).toBe(1) 21 | 22 | objectActions.set(obj, "b", 2) 23 | expect(obj.b).toBe(2) 24 | 25 | objectActions.delete(obj, "b") 26 | expect(obj.b).toBe(undefined) 27 | 28 | objectActions.assign(obj, { b: 3 }) 29 | expect(obj.b).toBe(3) 30 | }) 31 | 32 | test("over a model", () => { 33 | @testModel("M") 34 | class M extends Model({ x: prop(10) }) { 35 | // without action 36 | setXNoAction(n: number) { 37 | this.x = n 38 | return n 39 | } 40 | 41 | @modelAction 42 | setXAction(n: number) { 43 | this.x = n 44 | return n 45 | } 46 | } 47 | 48 | const m = new M({}) 49 | 50 | expect(objectActions.call(m, "setXNoAction", 20)).toBe(20) 51 | expect(m.x).toBe(20) 52 | 53 | expect(objectActions.call(m, "setXAction", 30)).toBe(30) 54 | expect(m.x).toBe(30) 55 | 56 | objectActions.set(m, "x", 40) 57 | expect(m.x).toBe(40) 58 | }) 59 | -------------------------------------------------------------------------------- /packages/lib/test/testbed.ts: -------------------------------------------------------------------------------- 1 | import { computed } from "mobx" 2 | import { idProp, model, Model, modelAction, modelIdKey, prop } from "../src" 3 | 4 | @model("P2") 5 | export class P2 extends Model({ 6 | [modelIdKey]: idProp, 7 | y: prop(() => 10), 8 | }) {} 9 | 10 | @model("P") 11 | export class P extends Model({ 12 | [modelIdKey]: idProp, 13 | x: prop(() => 5), 14 | arr: prop(() => []), 15 | p2: prop().withSetter(), 16 | }) { 17 | @computed 18 | get xx() { 19 | return this.x 20 | } 21 | 22 | get xx2() { 23 | return this.x 24 | } 25 | 26 | unboundNonAction(): void {} 27 | boundNonAction: () => void = () => {} 28 | 29 | @modelAction 30 | unboundAction(): void {} 31 | 32 | @modelAction 33 | boundAction: () => void = () => {} 34 | } 35 | 36 | export function createP(withArray = false) { 37 | return new P({ 38 | p2: new P2({ 39 | y: 12, 40 | }), 41 | arr: withArray ? [1, 2, 3] : [], 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /packages/lib/test/tsconfig.experimental-decorators.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "experimentalDecorators": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/lib/test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["../src", "../types", "."], 4 | "compilerOptions": { 5 | "target": "ES2020", 6 | "experimentalDecorators": false, 7 | "rootDir": ".." 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/lib/test/tsconfig.mobx4.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.experimental-decorators.json", 3 | "compilerOptions": { 4 | "useDefineForClassFields": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/lib/test/tsconfig.mobx5.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.experimental-decorators.json", 3 | "compilerOptions": {} 4 | } 5 | -------------------------------------------------------------------------------- /packages/lib/test/tweaker/tweak.test.ts: -------------------------------------------------------------------------------- 1 | import { getGlobalConfig, runUnprotected, setGlobalConfig, toTreeNode } from "../../src" 2 | 3 | const undefinedIsNotSupported = /^undefined is not supported inside arrays/ 4 | 5 | test("array disallows undefined element when allowUndefinedArrayElements is false", () => { 6 | expect(getGlobalConfig().allowUndefinedArrayElements).toBeFalsy() 7 | 8 | const array = toTreeNode([]) 9 | 10 | expect(() => { 11 | runUnprotected(() => { 12 | array.push(undefined) 13 | }) 14 | }).toThrow(undefinedIsNotSupported) 15 | }) 16 | 17 | test("array allows undefined element when allowUndefinedArrayElements is true", () => { 18 | setGlobalConfig({ allowUndefinedArrayElements: true }) 19 | 20 | const array = toTreeNode([]) 21 | 22 | expect(() => { 23 | runUnprotected(() => { 24 | array.push(undefined) 25 | }) 26 | }).not.toThrow(undefinedIsNotSupported) 27 | }) 28 | -------------------------------------------------------------------------------- /packages/lib/test/utils.ts: -------------------------------------------------------------------------------- 1 | import { model } from "../src" 2 | 3 | type Disposer = () => void 4 | 5 | const disposers: Disposer[] = [] 6 | 7 | afterEach(() => { 8 | disposers.forEach((d) => { 9 | d() 10 | }) 11 | disposers.length = 0 12 | }) 13 | 14 | export function autoDispose(disposer: Disposer) { 15 | disposers.push(disposer) 16 | } 17 | 18 | export async function delay(x: number) { 19 | return new Promise((r) => 20 | setTimeout(() => { 21 | r(x) 22 | }, x) 23 | ) 24 | } 25 | 26 | export function timeMock() { 27 | const now = Date.now() 28 | 29 | return { 30 | async advanceTimeTo(x: number) { 31 | await delay(now + x - Date.now()) 32 | }, 33 | } 34 | } 35 | 36 | export const testModel = (name: string) => { 37 | const testName = expect.getState().currentTestName 38 | const modelName = testName ? `${testName}/${name}` : name 39 | return model(modelName) 40 | } 41 | -------------------------------------------------------------------------------- /packages/lib/test/wrappers/asSet.test.ts: -------------------------------------------------------------------------------- 1 | import { computed, reaction, toJS } from "mobx" 2 | import { asSet, Model, modelAction, prop, runUnprotected, setToArray } from "../../src" 3 | import { testModel } from "../utils" 4 | 5 | test("asSet", () => { 6 | @testModel("M") 7 | class M extends Model({ 8 | arr: prop(() => [1, 2, 3]), 9 | }) { 10 | @computed 11 | get set() { 12 | return asSet(this.arr) 13 | } 14 | 15 | @modelAction 16 | add(n: number) { 17 | this.set.add(n) 18 | } 19 | 20 | @modelAction 21 | setSet(set: Set) { 22 | this.arr = setToArray(set) 23 | } 24 | } 25 | 26 | const m = new M({}) 27 | 28 | reaction( 29 | () => m.set, 30 | () => {} 31 | ) 32 | 33 | // should not change 34 | const s = m.set 35 | expect(m.set).toBe(s) 36 | 37 | // adding 38 | expect(m.set.has(1)).toBe(true) 39 | expect(m.set.has(4)).toBe(false) 40 | m.add(4) 41 | expect(m.set.has(4)).toBe(true) 42 | 43 | expect(toJS(setToArray(m.set))).toEqual([1, 2, 3, 4]) 44 | expect(setToArray(m.set)).toBe(m.arr) // same as backed prop 45 | 46 | m.setSet(new Set([5, 6, 7])) 47 | expect(m.set).not.toBe(s) // should be a new one 48 | expect(toJS(setToArray(m.set))).toEqual([5, 6, 7]) 49 | 50 | runUnprotected(() => { 51 | m.arr.push(8) 52 | expect(m.set.has(8)).toBe(true) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /packages/lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "resolveJsonModule": true, 9 | "esModuleInterop": true, 10 | "noEmit": true, 11 | "importHelpers": true, 12 | "declaration": true, 13 | "declarationDir": "./dist/types", 14 | "rootDir": "./src", 15 | "strict": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "strictFunctionTypes": true, 19 | "strictPropertyInitialization": true, 20 | "noImplicitThis": true, 21 | "alwaysStrict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noImplicitReturns": true, 25 | "noFallthroughCasesInSwitch": true, 26 | "baseUrl": "./", 27 | "paths": { 28 | "*": ["src/*", "node_modules/*"] 29 | }, 30 | "jsx": "react", 31 | "experimentalDecorators": true, 32 | "emitDecoratorMetadata": false, 33 | "skipLibCheck": true, 34 | "stripInternal": true, 35 | "useDefineForClassFields": true, 36 | "isolatedModules": true 37 | }, 38 | "include": ["./src"] 39 | } 40 | -------------------------------------------------------------------------------- /packages/lib/typedocconfig.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | excludePrivate: true, 3 | excludeProtected: true, 4 | readme: "none", 5 | out: "api-docs", 6 | tsconfig: "tsconfig.json", 7 | disableSources: true, 8 | } 9 | -------------------------------------------------------------------------------- /packages/lib/vite.config.mts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import typescript2 from "rollup-plugin-typescript2" 3 | import { defineConfig } from "vite" 4 | 5 | const resolvePath = (str: string) => path.resolve(__dirname, str) 6 | 7 | export default defineConfig({ 8 | build: { 9 | target: "node10", 10 | lib: { 11 | entry: resolvePath("./src/index.ts"), 12 | name: "mobx-keystone", 13 | }, 14 | sourcemap: "inline", 15 | minify: false, 16 | 17 | rollupOptions: { 18 | external: ["mobx"], 19 | 20 | output: [ 21 | { 22 | format: "esm", 23 | entryFileNames: "mobx-keystone.esm.mjs", 24 | }, 25 | { 26 | name: "mobx-keystone", 27 | format: "umd", 28 | globals: { 29 | mobx: "mobx", 30 | }, 31 | }, 32 | ], 33 | }, 34 | }, 35 | plugins: [ 36 | { 37 | ...typescript2({ 38 | useTsconfigDeclarationDir: true, 39 | }), 40 | enforce: "pre", 41 | }, 42 | ], 43 | }) 44 | -------------------------------------------------------------------------------- /packages/mobx-keystone-yjs/.gitignore: -------------------------------------------------------------------------------- 1 | /*.log 2 | /node_modules/ 3 | /dist/ 4 | /LICENSE 5 | /*.local 6 | /coverage/ 7 | -------------------------------------------------------------------------------- /packages/mobx-keystone-yjs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | - `applyJsonArrayToYArray` / `applyJsonObjectToYMap` are no longer wrapped in mobx actions in case they want to track the original values. 4 | 5 | ## 1.5.4 6 | 7 | - Fixed some more types. 8 | 9 | ## 1.5.3 10 | 11 | - Fixed some types. 12 | 13 | ## 1.5.2 14 | 15 | - Just renamed some internal types. 16 | 17 | ## 1.5.1 18 | 19 | - Fixed a wrong import. 20 | 21 | ## 1.5.0 22 | 23 | - Added undefined to accepted primitive "JSON" types. 24 | 25 | ## 1.4.0 26 | 27 | - Added `YjsTextModel` as a way to use `Y.Text` as if it were a node. 28 | 29 | ## 1.3.1 30 | 31 | - Added `boundObject` to `yjsBindingContext` so it's easier to access the root bound object from the context. 32 | 33 | ## 1.3.0 34 | 35 | - Frozen values will be stored as plain values in Y.js instead of being deeply converted to Y.js Maps/Arrays, etc. This means storing/fetching frozen values should be faster, require less memory and probably require less space in the Y.js state. 36 | 37 | ## 1.2.0 38 | 39 | - Added `yjsBindingContext` so bound objects offer a context with the Y.js doc, bound object, etc. 40 | 41 | ## 1.1.0 42 | 43 | - Added the `convertJsonToYjsData`, `applyJsonArrayToYArray` and `applyJsonObjectToYMap` functions to help with first migrations from snapshots to Y.js states. 44 | 45 | ## 1.0.0 46 | 47 | - First public release. 48 | -------------------------------------------------------------------------------- /packages/mobx-keystone-yjs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | ["@babel/preset-typescript", { allowDeclareFields: true }], 5 | ], 6 | plugins: [ 7 | ["@babel/plugin-proposal-decorators", { version: "legacy" }], 8 | ["@babel/plugin-proposal-class-properties", { loose: false }], 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /packages/mobx-keystone-yjs/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "jest" 2 | 3 | const config: Config = { 4 | setupFilesAfterEnv: ["./test/commonSetup.ts"], 5 | preset: "ts-jest", 6 | testEnvironment: "node", 7 | transform: { 8 | "^.+\\.ts$": ["ts-jest", { tsconfig: `./test/tsconfig.json` }], 9 | }, 10 | prettierPath: null, 11 | } 12 | 13 | export default config 14 | -------------------------------------------------------------------------------- /packages/mobx-keystone-yjs/src/binding/convertJsonToYjsData.ts: -------------------------------------------------------------------------------- 1 | import * as Y from "yjs" 2 | import { YjsTextModel, yjsTextModelId } from "./YjsTextModel" 3 | import { SnapshotOutOf } from "mobx-keystone" 4 | import { YjsData } from "./convertYjsDataToJson" 5 | import { PlainArray, PlainObject, PlainPrimitive, PlainValue } from "../plainTypes" 6 | 7 | function isPlainPrimitive(v: PlainValue): v is PlainPrimitive { 8 | const t = typeof v 9 | return t === "string" || t === "number" || t === "boolean" || v === null || v === undefined 10 | } 11 | 12 | function isPlainArray(v: PlainValue): v is PlainArray { 13 | return Array.isArray(v) 14 | } 15 | 16 | function isPlainObject(v: PlainValue): v is PlainObject { 17 | return !isPlainArray(v) && typeof v === "object" && v !== null 18 | } 19 | 20 | /** 21 | * Converts a plain value to a Y.js data structure. 22 | * Objects are converted to Y.Maps, arrays to Y.Arrays, primitives are untouched. 23 | * Frozen values are a special case and they are kept as immutable plain values. 24 | */ 25 | export function convertJsonToYjsData(v: PlainValue): YjsData { 26 | if (isPlainPrimitive(v)) { 27 | return v 28 | } 29 | 30 | if (isPlainArray(v)) { 31 | const arr = new Y.Array() 32 | applyJsonArrayToYArray(arr, v) 33 | return arr 34 | } 35 | 36 | if (isPlainObject(v)) { 37 | if (v.$frozen === true) { 38 | // frozen value, save as immutable object 39 | return v 40 | } 41 | 42 | if (v.$modelType === yjsTextModelId) { 43 | const text = new Y.Text() 44 | const yjsTextModel = v as unknown as SnapshotOutOf 45 | yjsTextModel.deltaList.forEach((frozenDeltas) => { 46 | text.applyDelta(frozenDeltas.data) 47 | }) 48 | return text 49 | } 50 | 51 | const map = new Y.Map() 52 | applyJsonObjectToYMap(map, v) 53 | return map 54 | } 55 | 56 | throw new Error(`unsupported value type: ${v}`) 57 | } 58 | 59 | /** 60 | * Applies a JSON array to a Y.Array, using the convertJsonToYjsData to convert the values. 61 | */ 62 | export const applyJsonArrayToYArray = (dest: Y.Array, source: PlainArray) => { 63 | dest.push(source.map(convertJsonToYjsData)) 64 | } 65 | 66 | /** 67 | * Applies a JSON object to a Y.Map, using the convertJsonToYjsData to convert the values. 68 | */ 69 | export const applyJsonObjectToYMap = (dest: Y.Map, source: PlainObject) => { 70 | Object.entries(source).forEach(([k, v]) => { 71 | dest.set(k, convertJsonToYjsData(v)) 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /packages/mobx-keystone-yjs/src/binding/convertYjsDataToJson.ts: -------------------------------------------------------------------------------- 1 | import { modelSnapshotOutWithMetadata } from "mobx-keystone" 2 | import * as Y from "yjs" 3 | import { PlainObject, PlainValue } from "../plainTypes" 4 | import { YjsTextModel } from "./YjsTextModel" 5 | import { action } from "mobx" 6 | 7 | export type YjsData = Y.Array | Y.Map | Y.Text | PlainValue 8 | 9 | export const convertYjsDataToJson = action((yjsData: YjsData): PlainValue => { 10 | if (yjsData instanceof Y.Array) { 11 | return yjsData.map((v) => convertYjsDataToJson(v)) 12 | } 13 | 14 | if (yjsData instanceof Y.Map) { 15 | const obj: PlainObject = {} 16 | yjsData.forEach((v, k) => { 17 | obj[k] = convertYjsDataToJson(v) 18 | }) 19 | return obj 20 | } 21 | 22 | if (yjsData instanceof Y.Text) { 23 | const deltas = yjsData.toDelta() as unknown[] 24 | 25 | return modelSnapshotOutWithMetadata(YjsTextModel, { 26 | deltaList: deltas.length > 0 ? [{ $frozen: true, data: deltas }] : [], 27 | }) as unknown as PlainValue 28 | } 29 | 30 | // assume it's a primitive 31 | return yjsData 32 | }) 33 | -------------------------------------------------------------------------------- /packages/mobx-keystone-yjs/src/binding/resolveYjsPath.ts: -------------------------------------------------------------------------------- 1 | import * as Y from "yjs" 2 | import { failure } from "../utils/error" 3 | import { getOrCreateYjsCollectionAtom } from "../utils/getOrCreateYjsCollectionAtom" 4 | 5 | export function resolveYjsPath(yjsObject: unknown, path: readonly (string | number)[]): unknown { 6 | let currentYjsObject: unknown = yjsObject 7 | 8 | path.forEach((pathPart, i) => { 9 | if (currentYjsObject instanceof Y.Map) { 10 | getOrCreateYjsCollectionAtom(currentYjsObject).reportObserved() 11 | const key = String(pathPart) 12 | currentYjsObject = currentYjsObject.get(key) 13 | } else if (currentYjsObject instanceof Y.Array) { 14 | getOrCreateYjsCollectionAtom(currentYjsObject).reportObserved() 15 | const key = Number(pathPart) 16 | currentYjsObject = currentYjsObject.get(key) 17 | } else { 18 | throw failure( 19 | `Y.Map or Y.Array was expected at path ${JSON.stringify( 20 | path.slice(0, i) 21 | )} in order to resolve path ${JSON.stringify(path)}, but got ${currentYjsObject} instead` 22 | ) 23 | } 24 | }) 25 | 26 | return currentYjsObject 27 | } 28 | -------------------------------------------------------------------------------- /packages/mobx-keystone-yjs/src/binding/yjsBindingContext.ts: -------------------------------------------------------------------------------- 1 | import { AnyType, createContext } from "mobx-keystone" 2 | import * as Y from "yjs" 3 | 4 | /** 5 | * Context with info on how a mobx-keystone model is bound to a Y.js data structure. 6 | */ 7 | export interface YjsBindingContext { 8 | /** 9 | * The Y.js document. 10 | */ 11 | yjsDoc: Y.Doc 12 | 13 | /** 14 | * The bound Y.js data structure. 15 | */ 16 | yjsObject: Y.Map | Y.Array | Y.Text 17 | 18 | /** 19 | * The mobx-keystone model type. 20 | */ 21 | mobxKeystoneType: AnyType 22 | 23 | /** 24 | * The origin symbol used for transactions. 25 | */ 26 | yjsOrigin: symbol 27 | 28 | /** 29 | * The bound mobx-keystone instance. 30 | */ 31 | boundObject: unknown 32 | 33 | /** 34 | * Whether we are currently applying Y.js changes to the mobx-keystone model. 35 | */ 36 | isApplyingYjsChangesToMobxKeystone: boolean 37 | } 38 | 39 | /** 40 | * Context with info on how a mobx-keystone model is bound to a Y.js data structure. 41 | */ 42 | export const yjsBindingContext = createContext(undefined) 43 | -------------------------------------------------------------------------------- /packages/mobx-keystone-yjs/src/index.ts: -------------------------------------------------------------------------------- 1 | export { YjsTextModel, yjsTextModelId } from "./binding/YjsTextModel" 2 | export { bindYjsToMobxKeystone } from "./binding/bindYjsToMobxKeystone" 3 | export { 4 | applyJsonArrayToYArray, 5 | applyJsonObjectToYMap, 6 | convertJsonToYjsData, 7 | } from "./binding/convertJsonToYjsData" 8 | export { yjsBindingContext } from "./binding/yjsBindingContext" 9 | export type { YjsBindingContext } from "./binding/yjsBindingContext" 10 | export { MobxKeystoneYjsError } from "./utils/error" 11 | -------------------------------------------------------------------------------- /packages/mobx-keystone-yjs/src/plainTypes.ts: -------------------------------------------------------------------------------- 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 interface PlainArray extends Array {} 8 | -------------------------------------------------------------------------------- /packages/mobx-keystone-yjs/src/utils/error.ts: -------------------------------------------------------------------------------- 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 | } 19 | -------------------------------------------------------------------------------- /packages/mobx-keystone-yjs/src/utils/getOrCreateYjsCollectionAtom.ts: -------------------------------------------------------------------------------- 1 | import { IAtom, createAtom } from "mobx" 2 | import * as Y from "yjs" 3 | 4 | const yjsCollectionAtoms = new WeakMap | Y.Array, IAtom>() 5 | 6 | /** 7 | * @internal 8 | */ 9 | export const getYjsCollectionAtom = ( 10 | yjsCollection: Y.Map | Y.Array 11 | ): IAtom | undefined => { 12 | return yjsCollectionAtoms.get(yjsCollection) 13 | } 14 | 15 | /** 16 | * @internal 17 | */ 18 | export const getOrCreateYjsCollectionAtom = ( 19 | yjsCollection: Y.Map | Y.Array 20 | ): IAtom => { 21 | let atom = yjsCollectionAtoms.get(yjsCollection) 22 | if (!atom) { 23 | atom = createAtom(`yjsCollectionAtom`) 24 | yjsCollectionAtoms.set(yjsCollection, atom) 25 | } 26 | return atom 27 | } 28 | -------------------------------------------------------------------------------- /packages/mobx-keystone-yjs/test/commonSetup.ts: -------------------------------------------------------------------------------- 1 | import { configure } from "mobx" 2 | import { setGlobalConfig } from "mobx-keystone" 3 | 4 | configure({ enforceActions: "always" }) 5 | 6 | let id = 1 7 | 8 | setGlobalConfig({ 9 | showDuplicateModelNameWarnings: false, 10 | modelIdGenerator() { 11 | return `id-${id++}` 12 | }, 13 | }) 14 | 15 | beforeEach(() => { 16 | id = 1 17 | }) 18 | -------------------------------------------------------------------------------- /packages/mobx-keystone-yjs/test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["../src", "../types", "."], 4 | "compilerOptions": { 5 | "target": "ES2020", 6 | "experimentalDecorators": false, 7 | "rootDir": ".." 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/mobx-keystone-yjs/test/utils.ts: -------------------------------------------------------------------------------- 1 | import { model } from "mobx-keystone" 2 | 3 | type Disposer = () => void 4 | 5 | const disposers: Disposer[] = [] 6 | 7 | afterEach(() => { 8 | disposers.forEach((d) => { 9 | d() 10 | }) 11 | disposers.length = 0 12 | }) 13 | 14 | export function autoDispose(disposer: Disposer) { 15 | disposers.push(disposer) 16 | } 17 | 18 | export async function delay(x: number) { 19 | return new Promise((r) => 20 | setTimeout(() => { 21 | r(x) 22 | }, x) 23 | ) 24 | } 25 | 26 | export const testModel = (name: string) => { 27 | const testName = expect.getState().currentTestName 28 | const modelName = testName ? `${testName}/${name}` : name 29 | return model(modelName) 30 | } 31 | -------------------------------------------------------------------------------- /packages/mobx-keystone-yjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "resolveJsonModule": true, 9 | "esModuleInterop": true, 10 | "noEmit": true, 11 | "importHelpers": true, 12 | "declaration": true, 13 | "declarationDir": "./dist/types", 14 | "rootDir": "./src", 15 | "strict": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "strictFunctionTypes": true, 19 | "strictPropertyInitialization": true, 20 | "noImplicitThis": true, 21 | "alwaysStrict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noImplicitReturns": true, 25 | "noFallthroughCasesInSwitch": true, 26 | "baseUrl": "./", 27 | "paths": { 28 | "*": ["src/*", "node_modules/*"] 29 | }, 30 | "jsx": "react", 31 | "experimentalDecorators": true, 32 | "emitDecoratorMetadata": false, 33 | "skipLibCheck": true, 34 | "stripInternal": true, 35 | "useDefineForClassFields": true, 36 | "isolatedModules": true 37 | }, 38 | "include": ["./src"] 39 | } 40 | -------------------------------------------------------------------------------- /packages/mobx-keystone-yjs/vite.config.mts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import typescript2 from "rollup-plugin-typescript2" 3 | import { defineConfig } from "vite" 4 | 5 | const resolvePath = (str: string) => path.resolve(__dirname, str) 6 | 7 | export default defineConfig({ 8 | build: { 9 | target: "node10", 10 | lib: { 11 | entry: resolvePath("./src/index.ts"), 12 | name: "mobx-keystone-yjs", 13 | }, 14 | sourcemap: "inline", 15 | minify: false, 16 | 17 | rollupOptions: { 18 | external: ["mobx", "mobx-keystone", "yjs"], 19 | 20 | output: [ 21 | { 22 | format: "esm", 23 | entryFileNames: "mobx-keystone-yjs.esm.mjs", 24 | }, 25 | { 26 | name: "mobx-keystone-yjs", 27 | format: "umd", 28 | globals: { 29 | mobx: "mobx", 30 | "mobx-keystone": "mobx-keystone", 31 | yjs: "yjs", 32 | }, 33 | }, 34 | ], 35 | }, 36 | }, 37 | plugins: [ 38 | { 39 | ...typescript2({ 40 | useTsconfigDeclarationDir: true, 41 | }), 42 | enforce: "pre", 43 | }, 44 | ], 45 | }) 46 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "tasks": { 4 | "mobx-keystone#build-docs": { 5 | "dependsOn": ["build"], 6 | "outputs": ["api-docs/**", "../../apps/site/copy-to-build/api/**"] 7 | }, 8 | "mobx-keystone#build": { 9 | "dependsOn": [], 10 | "outputs": ["dist/**"] 11 | }, 12 | "mobx-keystone-yjs#build": { 13 | "dependsOn": ["mobx-keystone#build"], 14 | "outputs": ["dist/**"] 15 | }, 16 | "site#build": { 17 | "dependsOn": ["mobx-keystone#build", "mobx-keystone-yjs#build", "mobx-keystone#build-docs"], 18 | "outputs": ["build/**"] 19 | }, 20 | "site#serve": { 21 | "cache": false, 22 | "dependsOn": ["build"], 23 | "outputs": [] 24 | }, 25 | "site#start": { 26 | "cache": false, 27 | "dependsOn": ["mobx-keystone#build", "mobx-keystone-yjs#build", "mobx-keystone#build-docs"], 28 | "outputs": [] 29 | }, 30 | "mobx-keystone#test": { 31 | "dependsOn": [], 32 | "outputs": [], 33 | "env": ["COMPILER", "MOBX-VERSION"] 34 | }, 35 | "mobx-keystone-yjs#test": { 36 | "dependsOn": ["mobx-keystone#build"], 37 | "outputs": [] 38 | }, 39 | "mobx-keystone#test:ci": { 40 | "dependsOn": [], 41 | "outputs": [], 42 | "env": ["COMPILER", "MOBX-VERSION"] 43 | }, 44 | "mobx-keystone-yjs#test:ci": { 45 | "dependsOn": ["mobx-keystone#build"], 46 | "outputs": [] 47 | } 48 | } 49 | } 50 | --------------------------------------------------------------------------------