├── .eslintrc.js ├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── dev.tsconfig.json ├── package.json ├── src ├── index.ts ├── playground.ts ├── store.ts └── util.ts ├── test ├── ephermal-nodes.test.ts ├── index.test.ts └── side-effects.test.ts ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | "react-app", 4 | "prettier/@typescript-eslint", 5 | "plugin:prettier/recommended" 6 | ], 7 | "settings": { 8 | "react": { 9 | "version": "999.999.999" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Begin CI... 9 | uses: actions/checkout@v2 10 | 11 | - name: Use Node 12 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12.x 15 | 16 | - name: Use cached node_modules 17 | uses: actions/cache@v1 18 | with: 19 | path: node_modules 20 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | nodeModules- 23 | 24 | - name: Install peer dependencies 25 | run: yarn add mobx 26 | env: 27 | CI: true 28 | 29 | - name: Install dependencies 30 | run: yarn install --frozen-lockfile 31 | env: 32 | CI: true 33 | 34 | - name: Lint 35 | run: yarn lint 36 | env: 37 | CI: true 38 | 39 | - name: Test 40 | run: yarn test --ci --coverage --maxWorkers=2 41 | env: 42 | CI: true 43 | 44 | - name: Build 45 | run: yarn build 46 | env: 47 | CI: true 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | lib 6 | .vscode 7 | **/*.tsbuildinfo -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Colin McDonnell 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
if you're happy and you know it, leave a star
3 |created by @vriad
4 | 5 | ## Motivation 6 | 7 | One of the best things about MobX is that it lets you create and use observable objects _without_ worrying about normalization. If you get a big blob of nested JSON back from an API, you can wrap it up as an observable and pass it into your React components. Badda bing, badda boom. 8 | 9 | So why build a normalized cache on top of MobX? Because it's literally magic. 10 | 11 | If you normalize all data coming into your application, there is a _single copy_ of each "object" in your application. What do I mean by "object"? To understand that, let's look at an example. 12 | 13 | 17 | 18 | ## Simple example 19 | 20 | ```ts 21 | // First let's create a Normi instance 22 | const normi = new Normi(); 23 | 24 | // Now let's pass some data into it! 25 | normi.merge({ id: '123', name: 'Zendaya' }); 26 | // => { id: "123", name: "Zendaya" } 27 | ``` 28 | 29 | Makes sense! Normi is built with TypeScript so all type information is preserved. Now let's pass in a more detailed version of Zendaya. 30 | 31 | ```ts 32 | normi.merge({ 33 | id: '123', 34 | bestSong: 'Replay', 35 | bestMovie: 'Spiderman: Homecoming', 36 | }); 37 | /* { 38 | id: '123', 39 | name: "Zendaya", 40 | bestSong: "Replay", 41 | bestMovie: "Spiderman: Homecoming" 42 | } 43 | */ 44 | ``` 45 | 46 | As can see, Normi has merged the two objects, since their `id` properties were equivalent. 47 | 48 | ## Example 49 | 50 | let's look at an example. you're building a blog. your homepage fetches a list of posts and renders them as a list of links. 51 | 52 | ```ts 53 | const posts = await getPosts(); 54 | // { id: string; title: string; }[] 55 | ``` 56 | 57 | Once a user clicks on one of the links, you fetch a "detail view" of the post: 58 | 59 | ```ts 60 | const post = await getPost(id); 61 | // { id: string; title: string; content: string } 62 | ``` 63 | 64 | As you can see the "detail view" contains the `content` field in addition to the fields from before. 65 | 66 | Without normalization, you'd now have two JavaScript objects in memory that correspond to the same blog post. It's now possible for your representation of those blog posts to get out of sync. If you updated the title of a post, you would have to re-fetch both `getPost()` and `getPosts()` to ensure that all the data in your application is up-to-date. 67 | 68 | For complex applications, this quickly gets unsustainable. You need to know exactly which objects get fetched by which APIs and trigger a set of "refetches" every time you update something in your database. This problem is compounded by the single-page application paradigm and client-side routing - data fetched at the beginning of a long session will stick around for a long time, because there is no need for a full-page refresh. 69 | 70 | Normi is designed to be the easiest possible way to get the benefits of denormalization with none of the usual hassles. There's no need to define 71 | 72 | ### unique identifiers 73 | 74 | The hard part is knowing when two objects correspond to the same node. **By default**, Normi only has two criteria for a node: 75 | 76 | 1. Must be a plain JavaScript object (not an array or instance) 77 | 2. Must contain an `id` property 78 | 79 | This works great if you're using UUIDs to uniquely identify every object in your database. If you're using auto-incrementing integers (`SERIAL` in Postgres) then this default configuration may not work. You'll run into problems where two objects from different tables have the same ID. 80 | 81 | 82 | 83 | #### Custom ID key 84 | 85 | If you use a different key to store object identifers, you can use that instead: 86 | 87 | ```ts 88 | const normi = new Normi({ id: '__ID__' }); 89 | 90 | normi.merge({ 91 | __ID__: '1234', 92 | size: 'Venti', 93 | }); 94 | ``` 95 | 96 | #### GraphQL 97 | 98 | If you've used GraphQL or Apollo, you may be aware that Apollo's normalized cache generates a unique identifier by concatenating the `id` field and the `__typename` field. This works well for GraphQL APIs, where the `__typename` property is typically added into your data payloads automatically by your GraphQL server framework. 99 | 100 | To configure similar behavior in Normi: 101 | 102 | ```ts 103 | const normi = new Normi({ id: ['id', '__typename'] }); 104 | ``` 105 | 106 | #### Fully custom identifiers 107 | 108 | For any other use case, you're able to totally customize ID generation by passing a function into your params: 109 | 110 | ```ts 111 | const normi = new Normi({ 112 | id: data => { 113 | // generate a string from your object 114 | if (data.uid) return data.uid; 115 | if (data.id) return `__${data.id}`; 116 | return `${Math.random()}`; 117 | }, 118 | }); 119 | ``` 120 | -------------------------------------------------------------------------------- /dev.tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "commonjs" 6 | } 7 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.6.0", 3 | "license": "MIT", 4 | "name": "normi", 5 | "author": "Colin McDonnell", 6 | "description": "A zero-config normalized cache for MobX", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/vriad/normi" 10 | }, 11 | "sideEffects": false, 12 | "bugs": { 13 | "url": "https://github.com/vriad/normi/issues" 14 | }, 15 | "homepage": "https://github.com/vriad/normi", 16 | "keywords": [ 17 | "mobx", 18 | "normalization", 19 | "normalized cache", 20 | "state management", 21 | "observables" 22 | ], 23 | "main": "dist/index.js", 24 | "typings": "dist/index.d.ts", 25 | "files": [ 26 | "dist" 27 | ], 28 | "scripts": { 29 | "dev": "yarn start", 30 | "start": "tsdx watch", 31 | "build": "tsdx build", 32 | "test": "tsdx test", 33 | "lint": "tsdx lint src test", 34 | "prepare": "tsdx build", 35 | "size": "size-limit", 36 | "analyze": "size-limit --why", 37 | "play": "nodemon -e ts -w src -x ts-node --project dev.tsconfig.json src/playground.ts", 38 | "buildplay": "nodemon -e ts -w src -x 'tsc --project dev.tsconfig.json && node lib/index.js'" 39 | }, 40 | "peerDependencies": {}, 41 | "husky": { 42 | "hooks": { 43 | "pre-commit": "tsdx lint" 44 | } 45 | }, 46 | "prettier": { 47 | "printWidth": 80, 48 | "semi": true, 49 | "singleQuote": true, 50 | "trailingComma": "es5" 51 | }, 52 | "module": "lib/normi.esm.js", 53 | "devDependencies": { 54 | "husky": "^4.2.5", 55 | "tsdx": "^0.14.1", 56 | "tslib": "^2.0.0", 57 | "typescript": "^3.9.5" 58 | }, 59 | "dependencies": { 60 | "mobx": "^6.0.4" 61 | } 62 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Normi } from './store'; 2 | 3 | export { Normi }; 4 | -------------------------------------------------------------------------------- /src/playground.ts: -------------------------------------------------------------------------------- 1 | import { Normi } from '.'; 2 | 3 | export const play = async () => { 4 | console.log('play'); 5 | const normi = new Normi({ 6 | id: ['id'], 7 | }); 8 | 9 | const obj1 = { 10 | // createdAt: '2020-09-22T06:56:51.541Z', 11 | // uid: 'w2c5dsy8CaUHh6Nvt39xsQLao9R2', 12 | // firstName: 'Colin', 13 | // lastName: 'McDonnell', 14 | // userType: 'Physician', 15 | // suffix: 'MD', 16 | // npi: null, 17 | // ptan: null, 18 | // dea: null, 19 | // termsConsent: true, 20 | id: '3d16368b-79cc-48f4-8b72-e514ec99315c', 21 | nested: { 22 | asdf: 'asdf', 23 | }, 24 | qwer: { 25 | id: 'asdfasdf', 26 | nested: { aqwer: 1234 }, 27 | }, 28 | }; 29 | 30 | // const objs = [obj1, obj2, obj3]; 31 | 32 | const val = normi.merge(obj1); 33 | console.log(val.value.nested.asdf); 34 | console.log(normi.nodes); 35 | }; 36 | 37 | play(); 38 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, observable } from 'mobx'; 2 | import { util } from './util'; 3 | 4 | type NormiParams = { 5 | id: ((arg: any) => string) | string | string[]; 6 | }; 7 | 8 | type Node