├── .nvmrc ├── README.md ├── CHANGELOG.md ├── logo.webp ├── examples ├── vue-query │ ├── src │ │ ├── assets │ │ │ ├── main.css │ │ │ └── logo.svg │ │ ├── components │ │ │ ├── icons │ │ │ │ ├── IconSupport.vue │ │ │ │ ├── IconTooling.vue │ │ │ │ ├── IconCommunity.vue │ │ │ │ ├── IconDocumentation.vue │ │ │ │ └── IconEcosystem.vue │ │ │ ├── HelloWorld.vue │ │ │ ├── WelcomeItem.vue │ │ │ └── TheWelcome.vue │ │ ├── main.ts │ │ └── App.vue │ ├── .vscode │ │ └── extensions.json │ ├── public │ │ └── favicon.ico │ ├── tsconfig.json │ ├── README.md │ ├── vite.config.ts │ ├── index.html │ ├── tsconfig.app.json │ ├── .gitignore │ ├── tsconfig.node.json │ └── package.json ├── trpc │ ├── src │ │ ├── trpc.ts │ │ ├── index.html │ │ ├── server.ts │ │ ├── index.tsx │ │ └── components │ │ │ └── app.tsx │ ├── .babelrc │ ├── README.md │ ├── dev-server.ts │ ├── webpack.config.js │ └── package.json ├── swr │ ├── .babelrc │ ├── README.md │ ├── src │ │ ├── index.html │ │ ├── index.jsx │ │ └── components │ │ │ └── app.jsx │ ├── package.json │ └── webpack.config.js ├── rtk-query │ ├── .babelrc │ ├── README.md │ ├── src │ │ ├── index.html │ │ ├── index.jsx │ │ ├── components │ │ │ └── app.jsx │ │ └── api.js │ ├── package.json │ └── webpack.config.js ├── react-query │ ├── .babelrc │ ├── README.md │ ├── src │ │ ├── index.html │ │ ├── index.jsx │ │ └── components │ │ │ └── app.jsx │ ├── package.json │ └── webpack.config.js └── README.md ├── pnpm-workspace.yaml ├── babel.config.js ├── packages ├── normy │ ├── src │ │ ├── get-id.ts │ │ ├── index.ts │ │ ├── warning.ts │ │ ├── merge-data.ts │ │ ├── default-config.ts │ │ ├── get-dependencies-diff.ts │ │ ├── merge-data.spec.ts │ │ ├── get-queries-dependent-on-mutation.ts │ │ ├── denormalize.ts │ │ ├── get-dependencies-diff.spec.ts │ │ ├── get-queries-dependent-on-mutation.spec.ts │ │ ├── add-or-remove-dependencies.spec.ts │ │ ├── types.ts │ │ ├── add-or-remove-dependencies.ts │ │ ├── denormalize.spec.ts │ │ ├── normalize.ts │ │ ├── array-helpers.ts │ │ ├── create-normalizer.ts │ │ ├── array-transformations.ts │ │ └── array-helpers.test.ts │ ├── .npmignore │ ├── jest.config.js │ ├── webpack.config.js │ ├── .babelrc │ ├── LICENSE │ └── package.json ├── normy-swr │ ├── .npmignore │ ├── jest.config.js │ ├── src │ │ ├── index.ts │ │ ├── SWRNormalizerProvider.tsx │ │ └── useNormalizedSWRMutation.ts │ ├── .babelrc │ ├── webpack.config.js │ ├── LICENSE │ └── package.json ├── normy-query-core │ ├── .npmignore │ ├── src │ │ ├── index.ts │ │ ├── types.ts │ │ └── create-query-normalizer.ts │ ├── jest.config.js │ ├── .babelrc │ ├── webpack.config.js │ ├── README.md │ ├── LICENSE │ └── package.json ├── normy-react-query │ ├── .npmignore │ ├── jest.config.js │ ├── .babelrc │ ├── src │ │ ├── index.ts │ │ ├── QueryNormalizerProvider.tsx │ │ └── create-query-normalizer.spec.ts │ ├── webpack.config.js │ ├── LICENSE │ └── package.json ├── normy-rtk-query │ ├── .npmignore │ ├── jest.config.js │ ├── .babelrc │ ├── webpack.config.js │ ├── LICENSE │ ├── package.json │ ├── src │ │ └── index.ts │ └── README.md └── normy-vue-query │ ├── .npmignore │ ├── jest.config.js │ ├── .babelrc │ ├── src │ ├── index.ts │ ├── vueQueryNormalizerPlugin.ts │ └── create-query-normalizer.spec.ts │ ├── webpack.config.js │ ├── LICENSE │ └── package.json ├── .vscode └── settings.json ├── .eslintignore ├── .prettierrc ├── tsconfig.eslint.json ├── .gitignore ├── .editorconfig ├── tsconfig.json ├── jest.config.js ├── lerna.json ├── nx.json ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── package.json └── .eslintrc.js /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.9.0 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ./packages/normy/README.md -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | See https://github.com/klis87/normy/releases 2 | -------------------------------------------------------------------------------- /logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klis87/normy/HEAD/logo.webp -------------------------------------------------------------------------------- /examples/vue-query/src/assets/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 32px; 3 | } 4 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'examples/*' 4 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | babelrcRoots: ['packages/*'], 3 | }; 4 | -------------------------------------------------------------------------------- /packages/normy/src/get-id.ts: -------------------------------------------------------------------------------- 1 | export const getId = (id: string) => `@@${id}`; 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "jest.enable": true, 3 | "jest.autoRun": "off" 4 | } 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.snap 2 | **/dist/** 3 | **/es/** 4 | **/lib/** 5 | **/*.html 6 | **/*.hbs -------------------------------------------------------------------------------- /examples/vue-query/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/normy/.npmignore: -------------------------------------------------------------------------------- 1 | **/*.spec.ts 2 | *.tgz 3 | .babelrc 4 | webpack.config.js 5 | coverage 6 | -------------------------------------------------------------------------------- /packages/normy-swr/.npmignore: -------------------------------------------------------------------------------- 1 | **/*.spec.ts 2 | *.tgz 3 | .babelrc 4 | webpack.config.js 5 | coverage 6 | -------------------------------------------------------------------------------- /packages/normy-query-core/.npmignore: -------------------------------------------------------------------------------- 1 | **/*.spec.ts 2 | *.tgz 3 | .babelrc 4 | webpack.config.js 5 | coverage 6 | -------------------------------------------------------------------------------- /packages/normy-react-query/.npmignore: -------------------------------------------------------------------------------- 1 | **/*.spec.ts 2 | *.tgz 3 | .babelrc 4 | webpack.config.js 5 | coverage 6 | -------------------------------------------------------------------------------- /packages/normy-rtk-query/.npmignore: -------------------------------------------------------------------------------- 1 | **/*.spec.ts 2 | *.tgz 3 | .babelrc 4 | webpack.config.js 5 | coverage 6 | -------------------------------------------------------------------------------- /packages/normy-vue-query/.npmignore: -------------------------------------------------------------------------------- 1 | **/*.spec.ts 2 | *.tgz 3 | .babelrc 4 | webpack.config.js 5 | coverage 6 | -------------------------------------------------------------------------------- /examples/vue-query/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klis87/normy/HEAD/examples/vue-query/public/favicon.ico -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "arrowParens": "avoid" 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["packages/*/src/**/*.ts", "packages/*/src/**/*.tsx"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn-error.log 3 | lerna-debug.log 4 | dist 5 | es 6 | lib 7 | .nyc_output 8 | coverage 9 | *.tgz 10 | *.d.ts 11 | .next -------------------------------------------------------------------------------- /packages/normy-query-core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { createQueryNormalizer } from './create-query-normalizer'; 2 | export type { NormyQueryMeta } from './types'; 3 | -------------------------------------------------------------------------------- /packages/normy/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; 6 | -------------------------------------------------------------------------------- /packages/normy-swr/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; 6 | -------------------------------------------------------------------------------- /packages/normy-rtk-query/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; 6 | -------------------------------------------------------------------------------- /packages/normy-vue-query/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; 6 | -------------------------------------------------------------------------------- /packages/normy-query-core/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; 6 | -------------------------------------------------------------------------------- /packages/normy-react-query/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; 6 | -------------------------------------------------------------------------------- /examples/trpc/src/trpc.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCReact } from '@trpc/react-query'; 2 | 3 | import type { AppRouter } from './server'; 4 | 5 | export const trpc = createTRPCReact(); 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /examples/swr/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "modules": false 7 | } 8 | ], 9 | "@babel/preset-react" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /examples/rtk-query/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "modules": false 7 | } 8 | ], 9 | "@babel/preset-react" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /examples/react-query/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "modules": false 7 | } 8 | ], 9 | "@babel/preset-react" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /examples/vue-query/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.node.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /examples/trpc/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "modules": false 8 | } 9 | ], 10 | "@babel/preset-react" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /packages/normy/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { NormalizerConfig, Data } from './types'; 2 | export { createNormalizer } from './create-normalizer'; 3 | export { getId } from './get-id'; 4 | export { arrayHelpers, createArrayHelpers } from './array-helpers'; 5 | -------------------------------------------------------------------------------- /packages/normy/src/warning.ts: -------------------------------------------------------------------------------- 1 | const isProduction = process.env.NODE_ENV === 'production'; 2 | 3 | export const warning = (show: boolean, ...messages: unknown[]) => { 4 | if (!isProduction) { 5 | if (show) { 6 | console.log(...messages); 7 | } 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /packages/normy-swr/src/index.ts: -------------------------------------------------------------------------------- 1 | export { getId, arrayHelpers, createArrayHelpers } from '@normy/core'; 2 | 3 | export { 4 | SWRNormalizerProvider, 5 | useSWRNormalizer, 6 | } from './SWRNormalizerProvider'; 7 | export { useNormalizedSWRMutation } from './useNormalizedSWRMutation'; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "strict": true, 5 | "esModuleInterop": true, 6 | "allowSyntheticDefaultImports": true, 7 | "lib": ["DOM", "es2017"] 8 | }, 9 | "typeAcquisition": { 10 | "include": ["jest"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/swr/README.md: -------------------------------------------------------------------------------- 1 | # normy react query example 2 | 3 | In order to launch this example, execute: 4 | 5 | - `pnpm install` or `npm install` (or `pnpm install` from the root directory to install all examples at once) 6 | - `pnpm start` or `npm start` 7 | 8 | Demo will be available on `localhost:3000`. 9 | -------------------------------------------------------------------------------- /examples/trpc/README.md: -------------------------------------------------------------------------------- 1 | # normy react query example 2 | 3 | In order to launch this example, execute: 4 | 5 | - `pnpm install` or `npm install` (or `pnpm install` from the root directory to install all examples at once) 6 | - `pnpm start` or `npm start` 7 | 8 | Demo will be available on `localhost:3000`. 9 | -------------------------------------------------------------------------------- /examples/rtk-query/README.md: -------------------------------------------------------------------------------- 1 | # normy react query example 2 | 3 | In order to launch this example, execute: 4 | 5 | - `pnpm install` or `npm install` (or `pnpm install` from the root directory to install all examples at once) 6 | - `pnpm start` or `npm start` 7 | 8 | Demo will be available on `localhost:3000`. 9 | -------------------------------------------------------------------------------- /examples/vue-query/README.md: -------------------------------------------------------------------------------- 1 | # normy react query example 2 | 3 | In order to launch this example, execute: 4 | 5 | - `pnpm install` or `npm install` (or `pnpm install` from the root directory to install all examples at once) 6 | - `pnpm start` or `npm start` 7 | 8 | Demo will be available on `localhost:3000`. 9 | -------------------------------------------------------------------------------- /examples/vue-query/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /packages/normy-query-core/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface NormyQueryMeta extends Record { 2 | normalize?: boolean; 3 | } 4 | 5 | declare module '@tanstack/query-core' { 6 | interface Register { 7 | queryMeta: NormyQueryMeta; 8 | mutationMeta: NormyQueryMeta; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | Examples in this package take advantage of `pnpm Workspaces`, so you can install 4 | `node_modules` for all examples with just one `pnpm install` command from root directory. If you don't use `pnpm`, that's 5 | fine too, just run `npm install` in an example directory, as usual. 6 | -------------------------------------------------------------------------------- /examples/react-query/README.md: -------------------------------------------------------------------------------- 1 | # normy react query example 2 | 3 | In order to launch this example, execute: 4 | 5 | - `pnpm install` or `npm install` (or `pnpm install` from the root directory to install all examples at once) 6 | - `pnpm start` or `npm start` 7 | 8 | Demo will be available on `localhost:3000`. 9 | -------------------------------------------------------------------------------- /packages/normy-swr/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | ["@babel/preset-env", { "loose": true, "modules": false }], 5 | "@babel/preset-react" 6 | ], 7 | "env": { 8 | "cjs": { 9 | "presets": [["@babel/preset-env", { "loose": true }]] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/normy-query-core/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | ["@babel/preset-env", { "loose": true, "modules": false }], 5 | "@babel/preset-react" 6 | ], 7 | "env": { 8 | "cjs": { 9 | "presets": [["@babel/preset-env", { "loose": true }]] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/normy-react-query/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | ["@babel/preset-env", { "loose": true, "modules": false }], 5 | "@babel/preset-react" 6 | ], 7 | "env": { 8 | "cjs": { 9 | "presets": [["@babel/preset-env", { "loose": true }]] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/normy-rtk-query/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | ["@babel/preset-env", { "loose": true, "modules": false }], 5 | "@babel/preset-react" 6 | ], 7 | "env": { 8 | "cjs": { 9 | "presets": [["@babel/preset-env", { "loose": true }]] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/normy-vue-query/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | ["@babel/preset-env", { "loose": true, "modules": false }], 5 | "@babel/preset-react" 6 | ], 7 | "env": { 8 | "cjs": { 9 | "presets": [["@babel/preset-env", { "loose": true }]] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/normy/src/merge-data.ts: -------------------------------------------------------------------------------- 1 | import deepmerge from 'deepmerge'; 2 | 3 | import { Data } from './types'; 4 | 5 | export const mergeData = (oldData: T, newData: T) => 6 | deepmerge(oldData, newData, { 7 | arrayMerge: (destinationArray: Data[], sourceArray: Data[]) => sourceArray, 8 | clone: false, 9 | }); 10 | -------------------------------------------------------------------------------- /examples/swr/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/trpc/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/react-query/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/rtk-query/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/normy/src/default-config.ts: -------------------------------------------------------------------------------- 1 | import { NormalizerConfig } from './types'; 2 | 3 | export const defaultConfig: Required = { 4 | getNormalizationObjectKey: obj => obj.id as string | undefined, 5 | devLogging: false, 6 | structuralSharing: true, 7 | getArrayType: () => undefined, 8 | customArrayOperations: {}, 9 | }; 10 | -------------------------------------------------------------------------------- /examples/vue-query/src/components/icons/IconSupport.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | coveragePathIgnorePatterns: [ 6 | '/node_modules/', 7 | '/packages/(?:.+?)/lib/', 8 | ], 9 | collectCoverageFrom: [ 10 | 'packages/*/src/**/*.{js,jsx,ts}', 11 | '!**/node_modules/**', 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /examples/vue-query/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | vue(), 10 | ], 11 | resolve: { 12 | alias: { 13 | '@': fileURLToPath(new URL('./src', import.meta.url)) 14 | } 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /examples/vue-query/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/normy/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | output: { 3 | library: 'Normy', 4 | libraryTarget: 'umd', 5 | }, 6 | resolve: { 7 | extensions: ['.ts'], 8 | }, 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.ts$/, 13 | exclude: /node_modules/, 14 | loader: 'babel-loader', 15 | }, 16 | ], 17 | }, 18 | devtool: 'source-map', 19 | }; 20 | -------------------------------------------------------------------------------- /examples/vue-query/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "composite": true, 7 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 8 | 9 | "baseUrl": ".", 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "independent", 3 | "npmClient": "pnpm", 4 | "command": { 5 | "publish": { 6 | "ignoreChanges": [ 7 | "node_modules/**", 8 | "packages/*/node_modules/**", 9 | "packages/*/dist/**", 10 | "packages/*/es/**", 11 | "packages/*/lib/**", 12 | "examples/**" 13 | ] 14 | } 15 | }, 16 | "$schema": "node_modules/lerna/schemas/lerna-schema.json" 17 | } 18 | -------------------------------------------------------------------------------- /packages/normy/src/get-dependencies-diff.ts: -------------------------------------------------------------------------------- 1 | export const getDependenciesDiff = ( 2 | oldDependencies: ReadonlyArray, 3 | newDependencies: ReadonlyArray, 4 | ) => ({ 5 | addedDependencies: newDependencies.filter( 6 | newDependency => !oldDependencies.includes(newDependency), 7 | ), 8 | removedDependencies: oldDependencies.filter( 9 | oldDependency => !newDependencies.includes(oldDependency), 10 | ), 11 | }); 12 | -------------------------------------------------------------------------------- /examples/swr/src/index.jsx: -------------------------------------------------------------------------------- 1 | import '@babel/polyfill'; 2 | import React from 'react'; 3 | import { createRoot } from 'react-dom/client'; 4 | 5 | import App from './components/app'; 6 | 7 | const renderApp = () => { 8 | const container = document.getElementById('root'); 9 | const root = createRoot(container); 10 | root.render(); 11 | }; 12 | 13 | renderApp(); 14 | 15 | if (module.hot) { 16 | module.hot.accept('./components/app', renderApp); 17 | } 18 | -------------------------------------------------------------------------------- /examples/vue-query/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | 30 | *.tsbuildinfo 31 | -------------------------------------------------------------------------------- /packages/normy/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | ["@babel/preset-env", { "loose": true, "modules": false }] 5 | ], 6 | "env": { 7 | "cjs": { 8 | "presets": [["@babel/preset-env", { "loose": true }]] 9 | } 10 | }, 11 | "plugins": [ 12 | [ 13 | "@babel/plugin-transform-runtime", 14 | { 15 | "corejs": false, 16 | "helpers": true, 17 | "regenerator": true 18 | } 19 | ] 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /packages/normy-vue-query/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { NormyQueryMeta } from '@normy/query-core'; 2 | 3 | export { createQueryNormalizer } from '@normy/query-core'; 4 | export { getId, arrayHelpers, createArrayHelpers } from '@normy/core'; 5 | 6 | export { 7 | VueQueryNormalizerPlugin, 8 | useQueryNormalizer, 9 | } from './vueQueryNormalizerPlugin'; 10 | 11 | declare module '@tanstack/vue-query' { 12 | interface Register { 13 | queryMeta: NormyQueryMeta; 14 | mutationMeta: NormyQueryMeta; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/normy-react-query/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { NormyQueryMeta } from '@normy/query-core'; 2 | 3 | export { createQueryNormalizer } from '@normy/query-core'; 4 | export { getId, arrayHelpers, createArrayHelpers } from '@normy/core'; 5 | 6 | export { 7 | QueryNormalizerProvider, 8 | useQueryNormalizer, 9 | } from './QueryNormalizerProvider'; 10 | 11 | declare module '@tanstack/react-query' { 12 | interface Register { 13 | queryMeta: NormyQueryMeta; 14 | mutationMeta: NormyQueryMeta; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/vue-query/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "include": [ 4 | "vite.config.*", 5 | "vitest.config.*", 6 | "cypress.config.*", 7 | "nightwatch.conf.*", 8 | "playwright.config.*" 9 | ], 10 | "compilerOptions": { 11 | "composite": true, 12 | "noEmit": true, 13 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 14 | 15 | "module": "ESNext", 16 | "moduleResolution": "Bundler", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/vue-query/src/main.ts: -------------------------------------------------------------------------------- 1 | import './assets/main.css'; 2 | 3 | import { VueQueryNormalizerPlugin } from '@normy/vue-query'; 4 | import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'; 5 | import { createApp } from 'vue'; 6 | import App from './App.vue'; 7 | 8 | const queryClient = new QueryClient({ 9 | defaultOptions: {}, 10 | }); 11 | 12 | createApp(App) 13 | .use(VueQueryNormalizerPlugin, { 14 | queryClient, 15 | normalizerConfig: { devLogging: true }, 16 | }) 17 | .use(VueQueryPlugin, { queryClient }) 18 | .mount('#app'); 19 | -------------------------------------------------------------------------------- /examples/vue-query/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-query", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "start": "vite", 7 | "type-check": "vue-tsc --build --force" 8 | }, 9 | "dependencies": { 10 | "@tanstack/vue-query": "^5.59.0", 11 | "vue": "^3.4.29", 12 | "@normy/vue-query": "workspace:*" 13 | }, 14 | "devDependencies": { 15 | "@tsconfig/node20": "^20.1.4", 16 | "@types/node": "^20.14.5", 17 | "@vitejs/plugin-vue": "^5.0.5", 18 | "@vue/tsconfig": "^0.5.1", 19 | "npm-run-all2": "^6.2.0", 20 | "typescript": "~5.4.0", 21 | "vite": "^5.3.1", 22 | "vue-tsc": "^2.0.21" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasksRunnerOptions": { 3 | "default": { 4 | "runner": "nx/tasks-runners/default", 5 | "options": { 6 | "cacheableOperations1": ["lint", "test", "build"] 7 | } 8 | } 9 | }, 10 | "targetDefaults": { 11 | "build": { 12 | "dependsOn": ["^build"], 13 | "outputs": [ 14 | "{projectRoot}/dist", 15 | "{projectRoot}/es", 16 | "{projectRoot}/lib", 17 | "{projectRoot}/types" 18 | ] 19 | } 20 | }, 21 | "$schema": "./node_modules/nx/schemas/nx-schema.json", 22 | "namedInputs": { 23 | "default": ["{projectRoot}/**/*", "sharedGlobals"], 24 | "sharedGlobals": [], 25 | "production": ["default"] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/trpc/dev-server.ts: -------------------------------------------------------------------------------- 1 | import '@babel/polyfill'; 2 | import express from 'express'; 3 | import webpack from 'webpack'; 4 | // @ts-ignore 5 | import webpackDevMiddleware from 'webpack-dev-middleware'; 6 | import { createExpressMiddleware } from '@trpc/server/adapters/express'; 7 | 8 | // @ts-ignore 9 | import webpackConfig from './webpack.config'; 10 | import { appRouter } from './src/server'; 11 | 12 | const app = express(); 13 | 14 | const createContext = () => ({}); // no context 15 | 16 | app.use( 17 | webpackDevMiddleware(webpack(webpackConfig), { 18 | publicPath: '/', 19 | }), 20 | ); 21 | 22 | app.use( 23 | '/trpc', 24 | createExpressMiddleware({ 25 | router: appRouter, 26 | createContext, 27 | }), 28 | ); 29 | 30 | app.listen(3000, () => { 31 | console.log('Listening on port 3000!'); 32 | }); 33 | -------------------------------------------------------------------------------- /examples/trpc/src/server.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from '@trpc/server'; 2 | 3 | export const t = initTRPC.context().create(); 4 | 5 | export const appRouter = t.router({ 6 | books: t.procedure.query(() => [ 7 | { id: '0', name: 'Name 0', author: null }, 8 | { id: '1', name: 'Name 1', author: { id: '1000', name: 'User1' } }, 9 | { id: '2', name: 'Name 2', author: { id: '1001', name: 'User2' } }, 10 | ]), 11 | book: t.procedure.query(() => ({ 12 | id: '1', 13 | name: 'Name 1', 14 | author: { id: '1000', name: 'User1' }, 15 | })), 16 | updateBookName: t.procedure.mutation(() => ({ 17 | id: '1', 18 | name: 'Name 1 Updated', 19 | })), 20 | updateBookAuthor: t.procedure.mutation(() => ({ 21 | id: '2', 22 | author: { id: '1002', name: 'User3' }, 23 | })), 24 | }); 25 | 26 | export type AppRouter = typeof appRouter; 27 | -------------------------------------------------------------------------------- /packages/normy/src/merge-data.spec.ts: -------------------------------------------------------------------------------- 1 | import { mergeData } from './merge-data'; 2 | 3 | describe('mergeData', () => { 4 | it('merges data', () => { 5 | expect(mergeData({ x: 1, y: { a: 2, b: 3 } }, { x: 4 })).toEqual({ 6 | x: 4, 7 | y: { a: 2, b: 3 }, 8 | }); 9 | }); 10 | 11 | it('deeply merges data', () => { 12 | expect(mergeData({ x: 1, y: { a: 2, b: 3 } }, { y: { b: 4 } })).toEqual({ 13 | x: 1, 14 | y: { a: 2, b: 4 }, 15 | }); 16 | }); 17 | 18 | it('does not mutate params', () => { 19 | const firstParam = { x: 1, y: { a: 2, b: 3 } }; 20 | const secondParam = { x: 1, y: { a: 2, b: 4 }, z: 5 }; 21 | 22 | mergeData(firstParam, secondParam); 23 | 24 | expect(firstParam).toEqual({ x: 1, y: { a: 2, b: 3 } }); 25 | expect(secondParam).toEqual({ x: 1, y: { a: 2, b: 4 }, z: 5 }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /examples/swr/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "normy-react-swr-example", 3 | "private": true, 4 | "description": "normy swr example", 5 | "main": "src/index.js", 6 | "repository": "git@github.com:klis87/normy.git", 7 | "author": "Konrad Lisiczynski ", 8 | "license": "MIT", 9 | "scripts": { 10 | "start": "webpack serve" 11 | }, 12 | "dependencies": { 13 | "@babel/polyfill": "7.12.1", 14 | "@normy/swr": "workspace:*", 15 | "react": "18.2.0", 16 | "react-dom": "18.2.0", 17 | "swr": "2.2.4" 18 | }, 19 | "devDependencies": { 20 | "@babel/core": "7.26.10", 21 | "@babel/preset-env": "7.26.9", 22 | "@babel/preset-react": "7.26.3", 23 | "babel-loader": "10.0.0", 24 | "html-webpack-plugin": "5.6.3", 25 | "webpack": "5.98.0", 26 | "webpack-cli": "6.0.1", 27 | "webpack-dev-server": "5.2.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/normy-swr/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | output: { 3 | library: 'NormySwr', 4 | libraryTarget: 'umd', 5 | }, 6 | resolve: { 7 | extensions: ['.ts', '.tsx'], 8 | }, 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.tsx?$/, 13 | exclude: /node_modules/, 14 | loader: 'babel-loader', 15 | }, 16 | ], 17 | }, 18 | externals: { 19 | react: { 20 | commonjs: 'react', 21 | commonjs2: 'react', 22 | amd: 'react', 23 | root: 'React', 24 | }, 25 | '@normy/core': { 26 | commonjs: '@normy/core', 27 | commonjs2: '@normy/core', 28 | amd: '@normy/core', 29 | root: 'Normy', 30 | }, 31 | swr: { 32 | commonjs: 'swr', 33 | commonjs2: 'swr', 34 | amd: 'swr', 35 | root: 'Swr', 36 | }, 37 | }, 38 | devtool: 'source-map', 39 | }; 40 | -------------------------------------------------------------------------------- /examples/vue-query/src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | 18 | 42 | -------------------------------------------------------------------------------- /packages/normy-rtk-query/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | output: { 3 | library: 'NormyRtkQuery', 4 | libraryTarget: 'umd', 5 | }, 6 | resolve: { 7 | extensions: ['.ts', '.tsx'], 8 | }, 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.tsx?$/, 13 | exclude: /node_modules/, 14 | loader: 'babel-loader', 15 | }, 16 | ], 17 | }, 18 | externals: { 19 | react: { 20 | commonjs: 'react', 21 | commonjs2: 'react', 22 | amd: 'react', 23 | root: 'React', 24 | }, 25 | '@normy/core': { 26 | commonjs: '@normy/core', 27 | commonjs2: '@normy/core', 28 | amd: '@normy/core', 29 | root: 'Normy', 30 | }, 31 | '@reduxjs/toolkit': { 32 | commonjs: '@reduxjs/toolkit', 33 | commonjs2: '@reduxjs/toolkit', 34 | amd: '@reduxjs/toolkit', 35 | }, 36 | }, 37 | devtool: 'source-map', 38 | }; 39 | -------------------------------------------------------------------------------- /examples/react-query/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "normy-react-query-example", 3 | "private": true, 4 | "description": "normy react query example", 5 | "main": "src/index.js", 6 | "repository": "git@github.com:klis87/normy.git", 7 | "author": "Konrad Lisiczynski ", 8 | "license": "MIT", 9 | "scripts": { 10 | "start": "webpack serve" 11 | }, 12 | "dependencies": { 13 | "@babel/polyfill": "7.12.1", 14 | "@normy/react-query": "workspace:*", 15 | "@tanstack/react-query": "5.4.3", 16 | "react": "18.2.0", 17 | "react-dom": "18.2.0" 18 | }, 19 | "devDependencies": { 20 | "@babel/core": "7.26.10", 21 | "@babel/preset-env": "7.26.9", 22 | "@babel/preset-react": "7.26.3", 23 | "babel-loader": "10.0.0", 24 | "html-webpack-plugin": "5.6.3", 25 | "webpack": "5.98.0", 26 | "webpack-cli": "6.0.1", 27 | "webpack-dev-server": "5.2.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/rtk-query/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "normy-rtk-query-example", 3 | "private": true, 4 | "description": "normy rtk query example", 5 | "main": "src/index.js", 6 | "repository": "git@github.com:klis87/normy.git", 7 | "author": "Konrad Lisiczynski ", 8 | "license": "MIT", 9 | "scripts": { 10 | "start": "webpack serve" 11 | }, 12 | "dependencies": { 13 | "@babel/polyfill": "7.12.1", 14 | "@normy/rtk-query": "workspace:*", 15 | "@reduxjs/toolkit": "^2.0.1", 16 | "react": "18.2.0", 17 | "react-dom": "18.2.0", 18 | "react-redux": "^9.0.4" 19 | }, 20 | "devDependencies": { 21 | "@babel/core": "7.26.10", 22 | "@babel/preset-env": "7.26.9", 23 | "@babel/preset-react": "7.26.3", 24 | "babel-loader": "10.0.0", 25 | "html-webpack-plugin": "5.6.3", 26 | "webpack": "5.98.0", 27 | "webpack-cli": "6.0.1", 28 | "webpack-dev-server": "5.2.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/normy-query-core/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | output: { 3 | library: 'NormyQueryCore', 4 | libraryTarget: 'umd', 5 | }, 6 | resolve: { 7 | extensions: ['.ts', '.tsx'], 8 | }, 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.tsx?$/, 13 | exclude: /node_modules/, 14 | loader: 'babel-loader', 15 | }, 16 | ], 17 | }, 18 | externals: { 19 | react: { 20 | commonjs: 'react', 21 | commonjs2: 'react', 22 | amd: 'react', 23 | root: 'React', 24 | }, 25 | '@normy/core': { 26 | commonjs: '@normy/core', 27 | commonjs2: '@normy/core', 28 | amd: '@normy/core', 29 | root: 'Normy', 30 | }, 31 | '@tanstack/react-query': { 32 | commonjs: '@tanstack/react-query', 33 | commonjs2: '@tanstack/react-query', 34 | amd: '@tanstack/react-query', 35 | root: 'ReactQuery', 36 | }, 37 | }, 38 | devtool: 'source-map', 39 | }; 40 | -------------------------------------------------------------------------------- /examples/trpc/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const path = require('path'); 4 | 5 | module.exports = { 6 | context: __dirname, 7 | entry: './src', 8 | output: { 9 | filename: '[name].js', 10 | path: path.join(__dirname, 'dist'), 11 | publicPath: '/', 12 | }, 13 | resolve: { 14 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.tsx?$/, 20 | exclude: /node_modules/, 21 | loader: 'babel-loader', 22 | }, 23 | ], 24 | }, 25 | devtool: 'eval-source-map', 26 | mode: 'development', 27 | plugins: [ 28 | new webpack.HotModuleReplacementPlugin(), 29 | new webpack.NoEmitOnErrorsPlugin(), 30 | new HtmlWebpackPlugin({ 31 | filename: path.join(__dirname, 'dist', 'index.html'), 32 | template: path.join(__dirname, 'src', 'index.html'), 33 | }), 34 | ], 35 | }; 36 | -------------------------------------------------------------------------------- /examples/react-query/src/index.jsx: -------------------------------------------------------------------------------- 1 | import '@babel/polyfill'; 2 | import React from 'react'; 3 | import { createRoot } from 'react-dom/client'; 4 | import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; 5 | import { QueryNormalizerProvider } from '@normy/react-query'; 6 | 7 | import App from './components/app'; 8 | 9 | const queryClient = new QueryClient(); 10 | 11 | const renderApp = () => { 12 | const container = document.getElementById('root'); 13 | const root = createRoot(container); 14 | root.render( 15 | arrayKey, 20 | }} 21 | > 22 | 23 | 24 | 25 | , 26 | ); 27 | }; 28 | 29 | renderApp(); 30 | 31 | if (module.hot) { 32 | module.hot.accept('./components/app', renderApp); 33 | } 34 | -------------------------------------------------------------------------------- /examples/vue-query/src/components/icons/IconTooling.vue: -------------------------------------------------------------------------------- 1 | 2 | 20 | -------------------------------------------------------------------------------- /examples/swr/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const path = require('path'); 4 | 5 | module.exports = { 6 | context: __dirname, 7 | entry: './src', 8 | output: { 9 | filename: '[name].js', 10 | path: path.join(__dirname, 'dist'), 11 | publicPath: '/', 12 | }, 13 | resolve: { 14 | extensions: ['.js', '.jsx'], 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.jsx?$/, 20 | exclude: /node_modules/, 21 | loader: 'babel-loader', 22 | }, 23 | ], 24 | }, 25 | devtool: 'eval-source-map', 26 | devServer: { 27 | port: 3000, 28 | hot: true, 29 | }, 30 | mode: 'development', 31 | plugins: [ 32 | new webpack.HotModuleReplacementPlugin(), 33 | new webpack.NoEmitOnErrorsPlugin(), 34 | new HtmlWebpackPlugin({ 35 | filename: path.join(__dirname, 'dist', 'index.html'), 36 | template: path.join(__dirname, 'src', 'index.html'), 37 | }), 38 | ], 39 | }; 40 | -------------------------------------------------------------------------------- /examples/rtk-query/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const path = require('path'); 4 | 5 | module.exports = { 6 | context: __dirname, 7 | entry: './src', 8 | output: { 9 | filename: '[name].js', 10 | path: path.join(__dirname, 'dist'), 11 | publicPath: '/', 12 | }, 13 | resolve: { 14 | extensions: ['.js', '.jsx'], 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.jsx?$/, 20 | exclude: /node_modules/, 21 | loader: 'babel-loader', 22 | }, 23 | ], 24 | }, 25 | devtool: 'eval-source-map', 26 | devServer: { 27 | port: 3000, 28 | hot: true, 29 | }, 30 | mode: 'development', 31 | plugins: [ 32 | new webpack.HotModuleReplacementPlugin(), 33 | new webpack.NoEmitOnErrorsPlugin(), 34 | new HtmlWebpackPlugin({ 35 | filename: path.join(__dirname, 'dist', 'index.html'), 36 | template: path.join(__dirname, 'src', 'index.html'), 37 | }), 38 | ], 39 | }; 40 | -------------------------------------------------------------------------------- /packages/normy/src/get-queries-dependent-on-mutation.ts: -------------------------------------------------------------------------------- 1 | import { NormalizedData } from './types'; 2 | 3 | export const getQueriesDependentOnMutation = ( 4 | dependentQueries: NormalizedData['dependentQueries'], 5 | mutationDependencies: ReadonlyArray, 6 | ): ReadonlyArray => { 7 | const queries: string[] = []; 8 | 9 | mutationDependencies.forEach(dependency => { 10 | if (dependentQueries[dependency]) { 11 | queries.push(...dependentQueries[dependency]); 12 | } 13 | }); 14 | 15 | return Array.from(new Set(queries)); 16 | }; 17 | 18 | export const getQueriesDependentOnArrayOperations = ( 19 | queriesWithArrays: NormalizedData['queriesWithArrays'], 20 | mutationDependencies: ReadonlyArray, 21 | ): ReadonlyArray => { 22 | const queries: string[] = []; 23 | 24 | mutationDependencies.forEach(dependency => { 25 | if (queriesWithArrays[dependency]) { 26 | queries.push(...queriesWithArrays[dependency]); 27 | } 28 | }); 29 | 30 | return Array.from(new Set(queries)); 31 | }; 32 | -------------------------------------------------------------------------------- /examples/react-query/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const path = require('path'); 4 | 5 | module.exports = { 6 | context: __dirname, 7 | entry: './src', 8 | output: { 9 | filename: '[name].js', 10 | path: path.join(__dirname, 'dist'), 11 | publicPath: '/', 12 | }, 13 | resolve: { 14 | extensions: ['.js', '.jsx'], 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.jsx?$/, 20 | exclude: /node_modules/, 21 | loader: 'babel-loader', 22 | }, 23 | ], 24 | }, 25 | devtool: 'eval-source-map', 26 | devServer: { 27 | port: 3000, 28 | hot: true, 29 | }, 30 | mode: 'development', 31 | plugins: [ 32 | new webpack.HotModuleReplacementPlugin(), 33 | new webpack.NoEmitOnErrorsPlugin(), 34 | new HtmlWebpackPlugin({ 35 | filename: path.join(__dirname, 'dist', 'index.html'), 36 | template: path.join(__dirname, 'src', 'index.html'), 37 | }), 38 | ], 39 | }; 40 | -------------------------------------------------------------------------------- /examples/vue-query/src/components/icons/IconCommunity.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /packages/normy/src/denormalize.ts: -------------------------------------------------------------------------------- 1 | import { Data, DataObject, DataPrimitiveArray, UsedKeys } from './types'; 2 | 3 | export const denormalize = ( 4 | data: Data, 5 | normalizedData: { [key: string]: Data }, 6 | usedKeys: UsedKeys, 7 | path = '', 8 | ): Data => { 9 | if (typeof data === 'string' && data.startsWith('@@')) { 10 | return denormalize(normalizedData[data], normalizedData, usedKeys, path); 11 | } else if (Array.isArray(data)) { 12 | return data.map(value => 13 | denormalize(value, normalizedData, usedKeys, path), 14 | ) as DataPrimitiveArray | DataObject[]; 15 | } else if ( 16 | data !== null && 17 | typeof data === 'object' && 18 | !(data instanceof Date) 19 | ) { 20 | const objectEntries = usedKeys[path] 21 | ? Object.entries(data).filter(([k]) => usedKeys[path].includes(k)) 22 | : Object.entries(data); 23 | 24 | return objectEntries.reduce((prev, [k, v]) => { 25 | prev[k] = denormalize(v, normalizedData, usedKeys, `${path}.${k}`); 26 | 27 | return prev; 28 | }, {} as DataObject); 29 | } 30 | 31 | return data; 32 | }; 33 | -------------------------------------------------------------------------------- /packages/normy/src/get-dependencies-diff.spec.ts: -------------------------------------------------------------------------------- 1 | import { getDependenciesDiff } from './get-dependencies-diff'; 2 | 3 | describe('getDependenciesDiff', () => { 4 | it('returns no diff for the same dependencies', () => { 5 | expect(getDependenciesDiff(['x', 'y'], ['x', 'y'])).toEqual({ 6 | addedDependencies: [], 7 | removedDependencies: [], 8 | }); 9 | }); 10 | 11 | it('calculates removed dependencies correctly', () => { 12 | expect(getDependenciesDiff(['x', 'y', 'z'], ['x'])).toEqual({ 13 | addedDependencies: [], 14 | removedDependencies: ['y', 'z'], 15 | }); 16 | }); 17 | 18 | it('calculates added dependencies correctly', () => { 19 | expect(getDependenciesDiff(['x'], ['x', 'y', 'z'])).toEqual({ 20 | addedDependencies: ['y', 'z'], 21 | removedDependencies: [], 22 | }); 23 | }); 24 | 25 | it('calculates added and removed dependencies at the same time', () => { 26 | expect(getDependenciesDiff(['x', 'y'], ['x', 'z'])).toEqual({ 27 | addedDependencies: ['z'], 28 | removedDependencies: ['y'], 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /examples/rtk-query/src/index.jsx: -------------------------------------------------------------------------------- 1 | import '@babel/polyfill'; 2 | import React from 'react'; 3 | import { createRoot } from 'react-dom/client'; 4 | import { configureStore } from '@reduxjs/toolkit'; 5 | import { Provider } from 'react-redux'; 6 | import { createNormalizationMiddleware } from '@normy/rtk-query'; 7 | 8 | import { api } from './api'; 9 | import App from './components/app'; 10 | 11 | const normalizationMiddleware = createNormalizationMiddleware(api, { 12 | devLogging: true, 13 | }); 14 | 15 | const store = configureStore({ 16 | reducer: { 17 | [api.reducerPath]: api.reducer, 18 | }, 19 | middleware: getDefaultMiddleware => [ 20 | ...getDefaultMiddleware(), 21 | api.middleware, 22 | normalizationMiddleware, 23 | ], 24 | }); 25 | 26 | const renderApp = () => { 27 | const container = document.getElementById('root'); 28 | const root = createRoot(container); 29 | root.render( 30 | 31 | 32 | , 33 | ); 34 | }; 35 | 36 | renderApp(); 37 | 38 | if (module.hot) { 39 | module.hot.accept('./components/app', renderApp); 40 | } 41 | -------------------------------------------------------------------------------- /packages/normy/src/get-queries-dependent-on-mutation.spec.ts: -------------------------------------------------------------------------------- 1 | import { getQueriesDependentOnMutation } from './get-queries-dependent-on-mutation'; 2 | 3 | describe('getQueriesDependentOnMutation', () => { 4 | it('returns empty array when no mutation dependencies passed', () => { 5 | expect(getQueriesDependentOnMutation({ x: ['query'] }, [])).toEqual([]); 6 | }); 7 | 8 | it('returns empty array when no dependencies found', () => { 9 | expect(getQueriesDependentOnMutation({ x: ['query'] }, ['y'])).toEqual([]); 10 | }); 11 | 12 | it('returns array with found query', () => { 13 | expect(getQueriesDependentOnMutation({ x: ['query'] }, ['x'])).toEqual([ 14 | 'query', 15 | ]); 16 | }); 17 | 18 | it('does not duplicate queries', () => { 19 | expect( 20 | getQueriesDependentOnMutation({ x: ['query'], y: ['query'] }, ['x', 'y']), 21 | ).toEqual(['query']); 22 | }); 23 | 24 | it('can find multiple queries from one object', () => { 25 | expect( 26 | getQueriesDependentOnMutation({ x: ['query', 'query2'] }, ['x']), 27 | ).toEqual(['query', 'query2']); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI Pipeline 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | ci: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | # Step 1: Check out the repository 17 | - name: Checkout code 18 | uses: actions/checkout@v3 19 | 20 | # Step 2: Set up Node.js and pnpm 21 | - name: Set up Node.js 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: 20 # Specify the Node.js version you want 25 | # cache: 'pnpm' 26 | 27 | # Step 3: Install pnpm 28 | - name: Install pnpm 29 | run: npm install -g pnpm`` 30 | 31 | # Step 4: Install dependencies 32 | - name: Install dependencies 33 | run: pnpm install 34 | 35 | # Step 5: Build the application 36 | - name: Build 37 | run: pnpm run build 38 | 39 | # Step 6: Lint the code 40 | - name: Lint 41 | run: pnpm run lint 42 | 43 | # Step 7: Run tests with coverage 44 | # - name: Test with coverage 45 | # run: pnpm run test:cover 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Konrad Lisiczyński 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 | -------------------------------------------------------------------------------- /packages/normy-vue-query/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | output: { 3 | library: 'NormyVueQuery', 4 | libraryTarget: 'umd', 5 | }, 6 | resolve: { 7 | extensions: ['.ts', '.tsx'], 8 | }, 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.tsx?$/, 13 | exclude: /node_modules/, 14 | loader: 'babel-loader', 15 | }, 16 | ], 17 | }, 18 | externals: { 19 | react: { 20 | commonjs: 'react', 21 | commonjs2: 'react', 22 | amd: 'react', 23 | root: 'React', 24 | }, 25 | '@normy/core': { 26 | commonjs: '@normy/core', 27 | commonjs2: '@normy/core', 28 | amd: '@normy/core', 29 | root: 'Normy', 30 | }, 31 | '@normy/query-core': { 32 | commonjs: '@normy/query-core', 33 | commonjs2: '@normy/query-core', 34 | amd: '@normy/query-core', 35 | root: 'NormyQueryCore', 36 | }, 37 | '@tanstack/vue-query': { 38 | commonjs: '@tanstack/vue-query', 39 | commonjs2: '@tanstack/vue-query', 40 | amd: '@tanstack/vue-query', 41 | root: 'VueQuery', 42 | }, 43 | }, 44 | devtool: 'source-map', 45 | }; 46 | -------------------------------------------------------------------------------- /packages/normy-query-core/README.md: -------------------------------------------------------------------------------- 1 | # @normy/query 2 | 3 | [![npm version](https://badge.fury.io/js/%40normy%2Fquery-core.svg)](https://badge.fury.io/js/%40normy%2Fquery-core) 4 | [![gzip size](https://img.badgesize.io/https://unpkg.com/@normy/query-core/dist/normy-query-core.min.js?compression=gzip)](https://unpkg.com/@normy/query-core) 5 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/klis87/normy/ci.yml?branch=master)](https://github.com/klis87/normy/actions) 6 | [![Coverage Status](https://coveralls.io/repos/github/klis87/normy/badge.svg?branch=master)](https://coveralls.io/github/klis87/normy?branch=master) 7 | [![lerna](https://img.shields.io/badge/maintained%20with-lerna-cc00ff.svg)](https://lernajs.io/) 8 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) 9 | 10 | This library is not meant to be used directly. It was created to simplify writing addons to `@tanstack/query` libraries. 11 | For now we already have `@normy/react-query` and `@normy/vue-query` libraries. 12 | 13 | ## Licence [:arrow_up:](#table-of-content) 14 | 15 | MIT 16 | -------------------------------------------------------------------------------- /packages/normy/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Konrad Lisiczyński 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 | -------------------------------------------------------------------------------- /packages/normy-swr/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Konrad Lisiczyński 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 | -------------------------------------------------------------------------------- /packages/normy-react-query/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | output: { 3 | library: 'NormyReactQuery', 4 | libraryTarget: 'umd', 5 | }, 6 | resolve: { 7 | extensions: ['.ts', '.tsx'], 8 | }, 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.tsx?$/, 13 | exclude: /node_modules/, 14 | loader: 'babel-loader', 15 | }, 16 | ], 17 | }, 18 | externals: { 19 | react: { 20 | commonjs: 'react', 21 | commonjs2: 'react', 22 | amd: 'react', 23 | root: 'React', 24 | }, 25 | '@normy/core': { 26 | commonjs: '@normy/core', 27 | commonjs2: '@normy/core', 28 | amd: '@normy/core', 29 | root: 'Normy', 30 | }, 31 | '@normy/query-core': { 32 | commonjs: '@normy/query-core', 33 | commonjs2: '@normy/query-core', 34 | amd: '@normy/query-core', 35 | root: 'NormyQueryCore', 36 | }, 37 | '@tanstack/react-query': { 38 | commonjs: '@tanstack/react-query', 39 | commonjs2: '@tanstack/react-query', 40 | amd: '@tanstack/react-query', 41 | root: 'ReactQuery', 42 | }, 43 | }, 44 | devtool: 'source-map', 45 | }; 46 | -------------------------------------------------------------------------------- /packages/normy-rtk-query/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Konrad Lisiczyński 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 | -------------------------------------------------------------------------------- /packages/normy-vue-query/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Konrad Lisiczyński 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 | -------------------------------------------------------------------------------- /packages/normy-query-core/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Konrad Lisiczyński 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 | -------------------------------------------------------------------------------- /packages/normy-react-query/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Konrad Lisiczyński 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 | -------------------------------------------------------------------------------- /examples/vue-query/src/components/icons/IconDocumentation.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /packages/normy-vue-query/src/vueQueryNormalizerPlugin.ts: -------------------------------------------------------------------------------- 1 | import type { NormalizerConfig } from '@normy/core'; 2 | import type { QueryClient } from '@tanstack/vue-query'; 3 | import { type App, type Plugin, inject } from 'vue'; 4 | import { createQueryNormalizer } from '@normy/query-core'; 5 | 6 | interface NormalizerPluginOptions { 7 | queryClient: QueryClient; 8 | normalizerConfig?: NormalizerConfig; 9 | } 10 | 11 | type QueryNormalizerType = ReturnType; 12 | 13 | declare module 'vue' { 14 | interface ComponentCustomProperties { 15 | $queryNormalizer: QueryNormalizerType; 16 | } 17 | } 18 | 19 | export const VueQueryNormalizerPlugin: Plugin = { 20 | install(app: App, options: NormalizerPluginOptions) { 21 | const normalizer = createQueryNormalizer( 22 | options.queryClient, 23 | options.normalizerConfig, 24 | ); 25 | normalizer.subscribe(); 26 | app.provide('queryNormalizer', normalizer); 27 | }, 28 | }; 29 | 30 | export const useQueryNormalizer = (): QueryNormalizerType => { 31 | const queryNormalizer = inject('queryNormalizer'); 32 | if (!queryNormalizer) { 33 | throw new Error( 34 | 'No query normalizer provided, this method can only be called in setup script', 35 | ); 36 | } 37 | return queryNormalizer; 38 | }; 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "lerna run build", 5 | "clean": "lerna run clean", 6 | "lint": "lerna run lint", 7 | "lint-examples": "eslint 'examples/**/src/**'", 8 | "test": "lerna run test", 9 | "test:cover": "jest --coverage packages/*/src", 10 | "coveralls": "cat ./coverage/lcov.info | coveralls", 11 | "prettify": "prettier --write '{packages,examples}/**/*.{js,jsx,ts,tsx,md}'" 12 | }, 13 | "devDependencies": { 14 | "@types/jest": "29.5.11", 15 | "@types/node": "20.10.4", 16 | "@typescript-eslint/eslint-plugin": "6.13.2", 17 | "@typescript-eslint/parser": "6.13.2", 18 | "coveralls": "3.1.1", 19 | "eslint": "8.55.0", 20 | "eslint-config-prettier": "9.1.0", 21 | "eslint-import-resolver-typescript": "3.6.1", 22 | "eslint-plugin-import": "2.29.0", 23 | "eslint-plugin-jsx-a11y": "6.8.0", 24 | "eslint-plugin-react": "7.33.2", 25 | "eslint-plugin-react-hooks": "4.6.0", 26 | "jest": "29.7.0", 27 | "lerna": "7.4.2", 28 | "prettier": "3.1.0" 29 | }, 30 | "bundlesize": [ 31 | { 32 | "path": "./packages/normy/dist/normy.min.js", 33 | "maxSize": "1.95 kB" 34 | }, 35 | { 36 | "path": "./packages/normy-react-query/dist/normy-react-query.min.js", 37 | "maxSize": "0.95 kB" 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /examples/trpc/src/index.tsx: -------------------------------------------------------------------------------- 1 | import '@babel/polyfill'; 2 | import * as React from 'react'; 3 | import { createRoot } from 'react-dom/client'; 4 | import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; 5 | import { QueryNormalizerProvider } from '@normy/react-query'; 6 | import { httpBatchLink } from '@trpc/client'; 7 | 8 | import App from './components/app'; 9 | import { trpc } from './trpc'; 10 | 11 | const trpcClient = trpc.createClient({ 12 | links: [ 13 | httpBatchLink({ 14 | url: 'http://localhost:3000/trpc', 15 | }), 16 | ], 17 | }); 18 | 19 | const queryClient = new QueryClient({ 20 | defaultOptions: { 21 | queries: { 22 | refetchOnWindowFocus: false, 23 | }, 24 | }, 25 | }); 26 | 27 | const renderApp = () => { 28 | const container = document.getElementById('root'); 29 | const root = createRoot(container); 30 | root.render( 31 | 35 | 36 | 37 | 38 | 39 | 40 | , 41 | ); 42 | }; 43 | 44 | renderApp(); 45 | 46 | if (module.hot) { 47 | module.hot.accept('./components/app', renderApp); 48 | } 49 | -------------------------------------------------------------------------------- /examples/trpc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "normy-trpc-example", 3 | "private": true, 4 | "description": "normy trpc example", 5 | "main": "src/index.js", 6 | "repository": "git@github.com:klis87/normy.git", 7 | "author": "Konrad Lisiczynski ", 8 | "license": "MIT", 9 | "scripts": { 10 | "start": "ts-node dev-server.ts" 11 | }, 12 | "dependencies": { 13 | "@babel/polyfill": "7.12.1", 14 | "@normy/react-query": "workspace:*", 15 | "@tanstack/react-query": "5.4.3", 16 | "@trpc/client": "11.0.0-next.92", 17 | "@trpc/react-query": "11.0.0-next.92", 18 | "@trpc/server": "11.0.0-next.92", 19 | "express": "4.21.2", 20 | "react": "18.2.0", 21 | "react-dom": "18.2.0", 22 | "zod": "^3.21.4" 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "7.26.10", 26 | "@babel/preset-env": "7.26.9", 27 | "@babel/preset-react": "7.26.3", 28 | "@babel/preset-typescript": "^7.21.0", 29 | "@types/express": "^4.17.17", 30 | "@types/node": "^22.13.10", 31 | "@types/react": "18.2.33", 32 | "@types/react-dom": "16.9.0", 33 | "@types/webpack-dev-middleware": "^5.3.0", 34 | "babel-loader": "10.0.0", 35 | "html-webpack-plugin": "5.6.3", 36 | "ts-node": "^10.9.1", 37 | "typescript": "^5.0.2", 38 | "webpack": "5.98.0", 39 | "webpack-cli": "6.0.1", 40 | "webpack-dev-middleware": "7.4.2" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/normy-vue-query/src/create-query-normalizer.spec.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/vue-query'; 2 | 3 | import { createQueryNormalizer } from '.'; 4 | 5 | describe('createQueryNormalizer', () => { 6 | it('has correct default state', () => { 7 | const normalizer = createQueryNormalizer(new QueryClient()); 8 | 9 | expect(normalizer.getNormalizedData()).toEqual({ 10 | queries: {}, 11 | dependentQueries: {}, 12 | objects: {}, 13 | queriesWithArrays: {}, 14 | }); 15 | }); 16 | 17 | it('updates normalizedData after a successful query', async () => { 18 | const client = new QueryClient(); 19 | const normalizer = createQueryNormalizer(client); 20 | normalizer.subscribe(); 21 | 22 | await client.prefetchQuery({ 23 | queryKey: ['book'], 24 | queryFn: () => 25 | Promise.resolve({ 26 | id: '1', 27 | name: 'Name', 28 | }), 29 | }); 30 | 31 | expect(normalizer.getNormalizedData()).toEqual({ 32 | queries: { 33 | '["book"]': { 34 | data: '@@1', 35 | dependencies: ['@@1'], 36 | usedKeys: { 37 | '': ['id', 'name'], 38 | }, 39 | arrayTypes: [], 40 | }, 41 | }, 42 | dependentQueries: { 43 | '@@1': ['["book"]'], 44 | }, 45 | objects: { 46 | '@@1': { 47 | id: '1', 48 | name: 'Name', 49 | }, 50 | }, 51 | queriesWithArrays: {}, 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /packages/normy-react-query/src/QueryNormalizerProvider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { QueryClient } from '@tanstack/react-query'; 3 | import { NormalizerConfig } from '@normy/core'; 4 | import { createQueryNormalizer } from '@normy/query-core'; 5 | 6 | const QueryNormalizerContext = React.createContext< 7 | undefined | ReturnType 8 | >(undefined); 9 | 10 | export const QueryNormalizerProvider = ({ 11 | queryClient, 12 | normalizerConfig, 13 | children, 14 | }: { 15 | queryClient: QueryClient; 16 | children: React.ReactNode; 17 | normalizerConfig?: Omit & { 18 | normalize?: boolean; 19 | }; 20 | }) => { 21 | const [queryNormalizer] = React.useState(() => 22 | createQueryNormalizer(queryClient, normalizerConfig), 23 | ); 24 | 25 | React.useEffect(() => { 26 | queryNormalizer.subscribe(); 27 | 28 | return () => { 29 | queryNormalizer.unsubscribe(); 30 | queryNormalizer.clear(); 31 | }; 32 | }, []); 33 | 34 | return ( 35 | 36 | {children} 37 | 38 | ); 39 | }; 40 | 41 | export const useQueryNormalizer = () => { 42 | const queryNormalizer = React.useContext(QueryNormalizerContext); 43 | 44 | if (!queryNormalizer) { 45 | throw new Error( 46 | 'No QueryNormalizer set, use QueryNormalizerProvider to set one', 47 | ); 48 | } 49 | 50 | return queryNormalizer; 51 | }; 52 | -------------------------------------------------------------------------------- /packages/normy-react-query/src/create-query-normalizer.spec.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | 3 | import { createQueryNormalizer } from '.'; 4 | 5 | describe('createQueryNormalizer', () => { 6 | it('has correct default state', () => { 7 | const normalizer = createQueryNormalizer(new QueryClient()); 8 | 9 | expect(normalizer.getNormalizedData()).toEqual({ 10 | queries: {}, 11 | dependentQueries: {}, 12 | objects: {}, 13 | queriesWithArrays: {}, 14 | }); 15 | }); 16 | 17 | it('updates normalizedData after a successful query', async () => { 18 | const client = new QueryClient(); 19 | const normalizer = createQueryNormalizer(client); 20 | normalizer.subscribe(); 21 | 22 | await client.prefetchQuery({ 23 | queryKey: ['book'], 24 | queryFn: () => 25 | Promise.resolve({ 26 | id: '1', 27 | name: 'Name', 28 | }), 29 | }); 30 | 31 | expect(normalizer.getNormalizedData()).toEqual({ 32 | queries: { 33 | '["book"]': { 34 | data: '@@1', 35 | dependencies: ['@@1'], 36 | usedKeys: { 37 | '': ['id', 'name'], 38 | }, 39 | arrayTypes: [], 40 | }, 41 | }, 42 | dependentQueries: { 43 | '@@1': ['["book"]'], 44 | }, 45 | objects: { 46 | '@@1': { 47 | id: '1', 48 | name: 'Name', 49 | }, 50 | }, 51 | queriesWithArrays: {}, 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /packages/normy/src/add-or-remove-dependencies.spec.ts: -------------------------------------------------------------------------------- 1 | import { addOrRemoveDependencies } from './add-or-remove-dependencies'; 2 | 3 | describe('addOrRemoveDependencies', () => { 4 | it('correctly adds new dependency to a new object', () => { 5 | expect(addOrRemoveDependencies({}, {}, 'queryKey', ['x'], [])).toEqual({ 6 | dependentQueries: { 7 | x: ['queryKey'], 8 | }, 9 | objects: {}, 10 | }); 11 | }); 12 | 13 | it('correctly adds new dependency to an existing object', () => { 14 | expect( 15 | addOrRemoveDependencies({ x: ['queryKey'] }, {}, 'queryKey2', ['x'], []), 16 | ).toEqual({ 17 | dependentQueries: { 18 | x: ['queryKey', 'queryKey2'], 19 | }, 20 | objects: {}, 21 | }); 22 | }); 23 | 24 | it('correctly removes a dependency', () => { 25 | expect( 26 | addOrRemoveDependencies( 27 | { x: ['queryKey', 'queryKey2'] }, 28 | { x: { id: 1 } }, 29 | 'queryKey', 30 | [], 31 | ['x'], 32 | ), 33 | ).toEqual({ 34 | dependentQueries: { 35 | x: ['queryKey2'], 36 | }, 37 | objects: { x: { id: 1 } }, 38 | }); 39 | }); 40 | 41 | it('cleans after removing the last dependency of object', () => { 42 | expect( 43 | addOrRemoveDependencies( 44 | { x: ['queryKey'] }, 45 | { x: { id: 1 } }, 46 | 'queryKey', 47 | [], 48 | ['x'], 49 | ), 50 | ).toEqual({ 51 | dependentQueries: {}, 52 | objects: {}, 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /examples/vue-query/src/components/WelcomeItem.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 88 | -------------------------------------------------------------------------------- /examples/vue-query/src/components/icons/IconEcosystem.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /packages/normy/src/types.ts: -------------------------------------------------------------------------------- 1 | export type DataPrimitive = string | number | boolean | null | undefined | Date; 2 | 3 | export type DataPrimitiveArray = 4 | | string[] 5 | | number[] 6 | | boolean[] 7 | | null[] 8 | | undefined[] 9 | | Date[]; 10 | 11 | export type DataObject = { 12 | // eslint-disable-next-line no-use-before-define 13 | [index: string]: Data; 14 | }; 15 | 16 | export type Data = 17 | | DataPrimitive 18 | | DataObject 19 | | DataPrimitiveArray 20 | | DataObject[]; 21 | 22 | export type NormalizerConfig = { 23 | getNormalizationObjectKey?: (obj: DataObject) => string | undefined; 24 | devLogging?: boolean; 25 | structuralSharing?: boolean; 26 | getArrayType?: (props: { 27 | array: ReadonlyArray; 28 | queryKey: string; 29 | parentObj?: DataObject; 30 | arrayKey?: string; // this is parentObj key that contains the array 31 | }) => string | undefined; 32 | // eslint-disable-next-line no-use-before-define 33 | customArrayOperations?: ArrayOperations; 34 | }; 35 | 36 | export type ArrayOperation = { 37 | arrayType: string; 38 | type: string; 39 | props?: Record; 40 | node: DataObject; 41 | }; 42 | 43 | export type ArrayOperations = { 44 | [operationName: string]: (props: { 45 | array: ReadonlyArray; 46 | operation: ArrayOperation; 47 | config: Required; 48 | getObjectById: ( 49 | id: string, 50 | exampleObject?: DataObject, 51 | ) => DataObject | undefined; 52 | }) => ReadonlyArray; 53 | }; 54 | 55 | export type UsedKeys = { [path: string]: ReadonlyArray }; 56 | 57 | export type NormalizedData = { 58 | queries: { 59 | [queryKey: string]: { 60 | data: Data; 61 | dependencies: ReadonlyArray; 62 | usedKeys: UsedKeys; 63 | arrayTypes: ReadonlyArray; 64 | }; 65 | }; 66 | objects: { [objectId: string]: DataObject }; 67 | dependentQueries: { [objectId: string]: ReadonlyArray }; 68 | queriesWithArrays: { [arrayType: string]: ReadonlyArray }; 69 | }; 70 | -------------------------------------------------------------------------------- /examples/rtk-query/src/components/app.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { getNormalizer } from '@normy/rtk-query'; 4 | 5 | import { 6 | useGetBooksQuery, 7 | useGetBookQuery, 8 | useUpdateBookMutation, 9 | useAddBookMutation, 10 | useUpdateBookOptimisticallyMutation, 11 | } from '../api'; 12 | 13 | const Books = () => { 14 | const { data: booksData = [] } = useGetBooksQuery(undefined); 15 | 16 | return booksData.map(book => ( 17 |
18 | {book.name} {book.author && book.author.name} 19 |
20 | )); 21 | }; 22 | 23 | const BooksApp = () => { 24 | const dispatch = useDispatch(); 25 | const normalizer = getNormalizer(dispatch); 26 | const { data: bookData } = useGetBookQuery(); 27 | 28 | const [updateBookName] = useUpdateBookMutation(); 29 | const [addBook] = useAddBookMutation(); 30 | const [updateBookOptimistically] = useUpdateBookOptimisticallyMutation(); 31 | 32 | return ( 33 |
34 | {' '} 37 | {' '} 40 | {' '} 43 | 53 |
54 |

Books

55 | 56 |
57 | {bookData && ( 58 | <> 59 |

Book detail

60 | {bookData.name} {bookData.author && bookData.author.name} 61 | 62 | )} 63 |
64 | ); 65 | }; 66 | 67 | const App = () => ( 68 |
69 |

Normy rtk Query example

70 | 71 |
72 | ); 73 | 74 | export default App; 75 | -------------------------------------------------------------------------------- /packages/normy/src/add-or-remove-dependencies.ts: -------------------------------------------------------------------------------- 1 | import { NormalizedData } from './types'; 2 | 3 | export const addOrRemoveDependencies = ( 4 | dependentQueries: NormalizedData['dependentQueries'], 5 | objects: NormalizedData['objects'], 6 | queryKey: string, 7 | dependenciesToAdd: ReadonlyArray, 8 | dependenciesToRemove: ReadonlyArray, 9 | ) => { 10 | dependentQueries = { ...dependentQueries }; 11 | objects = { ...objects }; 12 | 13 | dependenciesToAdd.forEach(dependency => { 14 | if (!dependentQueries[dependency]) { 15 | dependentQueries[dependency] = [queryKey]; 16 | } 17 | 18 | if (!dependentQueries[dependency].includes(queryKey)) { 19 | dependentQueries[dependency] = [ 20 | ...dependentQueries[dependency], 21 | queryKey, 22 | ]; 23 | } 24 | }); 25 | 26 | dependenciesToRemove.forEach(dependency => { 27 | if (dependentQueries[dependency].length > 1) { 28 | dependentQueries[dependency] = dependentQueries[dependency].filter( 29 | v => v !== queryKey, 30 | ); 31 | } else { 32 | delete dependentQueries[dependency]; 33 | delete objects[dependency]; 34 | } 35 | }); 36 | 37 | return { dependentQueries, objects }; 38 | }; 39 | 40 | export const addOrRemoveQueriesWithArrays = ( 41 | queriesWithArrays: NormalizedData['queriesWithArrays'], 42 | queryKey: string, 43 | arrayTypesToAdd: ReadonlyArray, 44 | arrayTypesToRemove: ReadonlyArray, 45 | ) => { 46 | queriesWithArrays = { ...queriesWithArrays }; 47 | 48 | arrayTypesToAdd.forEach(dependency => { 49 | if (!queriesWithArrays[dependency]) { 50 | queriesWithArrays[dependency] = [queryKey]; 51 | } 52 | 53 | if (!queriesWithArrays[dependency].includes(queryKey)) { 54 | queriesWithArrays[dependency] = [ 55 | ...queriesWithArrays[dependency], 56 | queryKey, 57 | ]; 58 | } 59 | }); 60 | 61 | arrayTypesToRemove.forEach(dependency => { 62 | if (queriesWithArrays[dependency].length > 1) { 63 | queriesWithArrays[dependency] = queriesWithArrays[dependency].filter( 64 | v => v !== queryKey, 65 | ); 66 | } else { 67 | delete queriesWithArrays[dependency]; 68 | } 69 | }); 70 | 71 | return { queriesWithArrays }; 72 | }; 73 | -------------------------------------------------------------------------------- /examples/rtk-query/src/api.js: -------------------------------------------------------------------------------- 1 | import { getNormalizer } from '@normy/rtk-query'; 2 | import { createApi } from '@reduxjs/toolkit/query/react'; 3 | 4 | const sleep = () => new Promise(resolve => setTimeout(resolve, 1000)); 5 | 6 | export const api = createApi({ 7 | reducerPath: 'api', 8 | endpoints: builder => ({ 9 | getBooks: builder.query({ 10 | queryFn: () => ({ 11 | data: [ 12 | { id: '0', name: 'Name 0', author: null }, 13 | { id: '1', name: 'Name 1', author: { id: '1000', name: 'User1' } }, 14 | { id: '2', name: 'Name 2', author: { id: '1001', name: 'User2' } }, 15 | ], 16 | }), 17 | }), 18 | getBook: builder.query({ 19 | queryFn: () => ({ 20 | data: { 21 | id: '1', 22 | name: 'Name 1', 23 | author: { id: '1000', name: 'User1' }, 24 | }, 25 | }), 26 | }), 27 | updateBook: builder.mutation({ 28 | queryFn: () => ({ 29 | data: { 30 | id: '1', 31 | name: 'Name 1 Updated', 32 | }, 33 | }), 34 | }), 35 | updateBookOptimistically: builder.mutation({ 36 | queryFn: async () => { 37 | await sleep(); 38 | 39 | return { 40 | data: { 41 | id: '1', 42 | name: 'Name 1 Updated', 43 | }, 44 | }; 45 | }, 46 | onQueryStarted: async (_, { dispatch, queryFulfilled }) => { 47 | const normalizer = getNormalizer(dispatch); 48 | 49 | normalizer.setNormalizedData({ 50 | id: '1', 51 | name: 'Name 1 Updated', 52 | }); 53 | 54 | try { 55 | await queryFulfilled; 56 | } catch { 57 | normalizer.setNormalizedData({ 58 | id: '1', 59 | name: 'Name 1', 60 | }); 61 | } 62 | }, 63 | }), 64 | addBook: builder.mutation({ 65 | queryFn: async () => ({ 66 | data: { 67 | id: '3', 68 | name: 'Name 3', 69 | author: { id: '1002', name: 'User3' }, 70 | }, 71 | }), 72 | onQueryStarted: async (_, { dispatch, queryFulfilled }) => { 73 | const { data: mutationData } = await queryFulfilled; 74 | 75 | dispatch( 76 | api.util.updateQueryData('getBooks', undefined, data => [ 77 | ...data, 78 | mutationData, 79 | ]), 80 | ); 81 | }, 82 | }), 83 | }), 84 | }); 85 | 86 | export const { 87 | useGetBooksQuery, 88 | useGetBookQuery, 89 | useUpdateBookMutation, 90 | useUpdateBookOptimisticallyMutation, 91 | useAddBookMutation, 92 | } = api; 93 | -------------------------------------------------------------------------------- /packages/normy-query-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@normy/query-core", 3 | "version": "0.21.0", 4 | "description": "query-core addon for normy - automatic normalization and data updates for data fetching libraries", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "jsnext:main": "es/index.js", 8 | "unpkg": "dist/normy-query-core.min.js", 9 | "repository": "git@github.com:klis87/normy.git", 10 | "author": "Konrad Lisiczynski ", 11 | "license": "MIT", 12 | "typings": "types/index.d.ts", 13 | "keywords": [ 14 | "normalization", 15 | "query-core" 16 | ], 17 | "homepage": "https://github.com/klis87/normy", 18 | "bugs": { 19 | "url": "https://github.com/klis87/normy/issues" 20 | }, 21 | "scripts": { 22 | "clean": "rimraf es lib dist types", 23 | "lint": "eslint 'src/**'", 24 | "test": "jest src --passWithNoTests", 25 | "test:cover": "jest --coverage src --passWithNoTests", 26 | "build-types": "tsc src/index.ts --strict --esModuleInterop --lib es2018,dom --skipLibCheck --jsx react --declaration --emitDeclarationOnly --declarationDir types", 27 | "build:commonjs": "cross-env BABEL_ENV=cjs babel src --extensions '.ts,.tsx' --out-dir lib --ignore 'src/**/*.spec.js'", 28 | "build:es": "babel src --extensions '.ts,.tsx' --out-dir es --ignore 'src/**/*.spec.js'", 29 | "build:umd": "webpack --mode development -o dist --output-filename normy-query-core.js", 30 | "build:umd:min": "webpack --mode production -o dist --output-filename normy-query-core.min.js", 31 | "build": "npm-run-all clean build-types -p build:commonjs build:es build:umd build:umd:min", 32 | "build:watch": "nodemon --watch src --ignore src/**/*.spec.js --exec 'pnpm run build:es'", 33 | "prepare": "pnpm run build" 34 | }, 35 | "peerDependencies": { 36 | "@tanstack/query-core": ">=5.4.3" 37 | }, 38 | "dependencies": { 39 | "@normy/core": "workspace:*" 40 | }, 41 | "devDependencies": { 42 | "@babel/cli": "7.23.4", 43 | "@babel/core": "7.23.5", 44 | "@babel/preset-env": "7.23.5", 45 | "@babel/preset-typescript": "7.23.3", 46 | "@babel/types": "7.23.5", 47 | "@tanstack/query-core": "5.4.3", 48 | "@types/node": "20.10.4", 49 | "@typescript-eslint/eslint-plugin": "6.13.2", 50 | "@typescript-eslint/parser": "6.13.2", 51 | "babel-loader": "9.1.3", 52 | "cross-env": "7.0.3", 53 | "eslint": "8.55.0", 54 | "eslint-config-prettier": "9.1.0", 55 | "eslint-import-resolver-typescript": "3.6.1", 56 | "eslint-plugin-import": "2.29.0", 57 | "eslint-plugin-jsx-a11y": "6.8.0", 58 | "jest": "29.7.0", 59 | "nodemon": "2.0.6", 60 | "npm-run-all": "4.1.5", 61 | "rimraf": "3.0.2", 62 | "ts-jest": "29.1.1", 63 | "typescript": "5.6.3", 64 | "webpack": "5.89.0", 65 | "webpack-cli": "5.1.4" 66 | }, 67 | "publishConfig": { 68 | "access": "public" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /packages/normy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@normy/core", 3 | "version": "0.14.0", 4 | "description": "Automatic normalization and data updates for data fetching libraries", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "jsnext:main": "es/index.js", 8 | "unpkg": "dist/normy.min.js", 9 | "repository": "git@github.com:klis87/normy.git", 10 | "author": "Konrad Lisiczynski ", 11 | "license": "MIT", 12 | "typings": "types/index.d.ts", 13 | "keywords": [ 14 | "normalization", 15 | "react-query" 16 | ], 17 | "homepage": "https://github.com/klis87/normy", 18 | "bugs": { 19 | "url": "https://github.com/klis87/normy/issues" 20 | }, 21 | "sideEffects": false, 22 | "scripts": { 23 | "clean": "rimraf es lib dist types", 24 | "lint": "eslint 'src/**'", 25 | "test": "jest src", 26 | "test:cover": "jest --coverage src", 27 | "build-types": "tsc src/index.ts --strict --esModuleInterop --lib es2017,dom --declaration --emitDeclarationOnly --declarationDir types", 28 | "build:commonjs": "cross-env BABEL_ENV=cjs babel src --extensions '.ts' --out-dir lib --ignore 'src/**/*.spec.js'", 29 | "build:es": "babel src --extensions '.ts' --out-dir es --ignore 'src/**/*.spec.js'", 30 | "build:umd": "webpack --mode development -o dist --output-filename normy.js", 31 | "build:umd:min": "webpack --mode production -o dist --output-filename normy.min.js", 32 | "build": "npm-run-all clean build-types -p build:commonjs build:es build:umd build:umd:min", 33 | "build:watch": "nodemon --watch src --ignore src/**/*.spec.js --exec 'pnpm run build:es'", 34 | "prepare": "pnpm run build" 35 | }, 36 | "devDependencies": { 37 | "@babel/cli": "7.23.4", 38 | "@babel/core": "7.23.5", 39 | "@babel/plugin-transform-runtime": "7.23.4", 40 | "@babel/preset-env": "7.23.5", 41 | "@babel/preset-typescript": "7.23.3", 42 | "@babel/types": "^7.23.5", 43 | "@types/jest": "29.5.11", 44 | "@types/node": "20.10.4", 45 | "@typescript-eslint/eslint-plugin": "6.13.2", 46 | "@typescript-eslint/parser": "6.13.2", 47 | "babel-loader": "9.1.3", 48 | "babel-plugin-dev-expression": "^0.2.3", 49 | "cross-env": "7.0.3", 50 | "eslint": "8.55.0", 51 | "eslint-config-prettier": "9.1.0", 52 | "eslint-import-resolver-typescript": "3.6.1", 53 | "eslint-plugin-import": "2.29.0", 54 | "eslint-plugin-jsx-a11y": "6.8.0", 55 | "eslint-plugin-react": "7.33.2", 56 | "eslint-plugin-react-hooks": "4.6.0", 57 | "jest": "29.7.0", 58 | "nodemon": "2.0.6", 59 | "npm-run-all": "4.1.5", 60 | "rimraf": "3.0.2", 61 | "ts-jest": "29.1.1", 62 | "typescript": "5.6.3", 63 | "webpack": "5.89.0", 64 | "webpack-cli": "5.1.4" 65 | }, 66 | "dependencies": { 67 | "@babel/runtime": "^7.23.5", 68 | "deepmerge": "4.3.1" 69 | }, 70 | "publishConfig": { 71 | "access": "public" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/normy-vue-query/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@normy/vue-query", 3 | "version": "0.21.0", 4 | "description": "vue-query addon for normy - automatic normalization and data updates for data fetching libraries", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "jsnext:main": "es/index.js", 8 | "unpkg": "dist/normy-vue-query.min.js", 9 | "repository": "git@github.com:klis87/normy.git", 10 | "author": "Konrad Lisiczynski ", 11 | "license": "MIT", 12 | "typings": "types/index.d.ts", 13 | "keywords": [ 14 | "normalization", 15 | "vue-query" 16 | ], 17 | "homepage": "https://github.com/klis87/normy", 18 | "bugs": { 19 | "url": "https://github.com/klis87/normy/issues" 20 | }, 21 | "scripts": { 22 | "clean": "rimraf es lib dist types", 23 | "lint": "eslint 'src/**'", 24 | "test": "jest src --passWithNoTests", 25 | "test:cover": "jest --coverage src --passWithNoTests", 26 | "build-types": "tsc src/index.ts --strict --esModuleInterop --lib es2018,dom --skipLibCheck --jsx react --declaration --emitDeclarationOnly --declarationDir types", 27 | "build:commonjs": "cross-env BABEL_ENV=cjs babel src --extensions '.ts,.tsx' --out-dir lib --ignore 'src/**/*.spec.js'", 28 | "build:es": "babel src --extensions '.ts,.tsx' --out-dir es --ignore 'src/**/*.spec.js'", 29 | "build:umd": "webpack --mode development -o dist --output-filename normy-vue-query.js", 30 | "build:umd:min": "webpack --mode production -o dist --output-filename normy-vue-query.min.js", 31 | "build": "npm-run-all clean build-types -p build:commonjs build:es build:umd build:umd:min", 32 | "build:watch": "nodemon --watch src --ignore src/**/*.spec.js --exec 'pnpm run build:es'", 33 | "prepare": "pnpm run build" 34 | }, 35 | "peerDependencies": { 36 | "@tanstack/vue-query": ">=5.4.3", 37 | "vue": ">=3.4.29" 38 | }, 39 | "dependencies": { 40 | "@normy/core": "workspace:*", 41 | "@normy/query-core": "workspace:*" 42 | }, 43 | "devDependencies": { 44 | "@babel/cli": "7.23.4", 45 | "@babel/core": "7.23.5", 46 | "@babel/preset-env": "7.23.5", 47 | "@babel/preset-react": "7.23.3", 48 | "@babel/preset-typescript": "7.23.3", 49 | "@babel/types": "7.23.5", 50 | "@tanstack/vue-query": "5.4.3", 51 | "@types/node": "20.10.4", 52 | "@typescript-eslint/eslint-plugin": "6.13.2", 53 | "@typescript-eslint/parser": "6.13.2", 54 | "babel-loader": "9.1.3", 55 | "cross-env": "7.0.3", 56 | "eslint": "8.55.0", 57 | "eslint-config-prettier": "9.1.0", 58 | "eslint-import-resolver-typescript": "3.6.1", 59 | "eslint-plugin-import": "2.29.0", 60 | "eslint-plugin-jsx-a11y": "6.8.0", 61 | "jest": "29.7.0", 62 | "nodemon": "2.0.6", 63 | "npm-run-all": "4.1.5", 64 | "rimraf": "3.0.2", 65 | "ts-jest": "29.1.1", 66 | "typescript": "5.3.3", 67 | "webpack": "5.89.0", 68 | "webpack-cli": "5.1.4" 69 | }, 70 | "publishConfig": { 71 | "access": "public" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/normy-swr/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@normy/swr", 3 | "version": "0.3.0", 4 | "description": "swr addon for normy - automatic normalization and data updates for data fetching libraries", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "jsnext:main": "es/index.js", 8 | "unpkg": "dist/normy-swr.min.js", 9 | "repository": "git@github.com:klis87/normy.git", 10 | "author": "Konrad Lisiczynski ", 11 | "license": "MIT", 12 | "typings": "types/index.d.ts", 13 | "keywords": [ 14 | "normalization", 15 | "swr" 16 | ], 17 | "homepage": "https://github.com/klis87/normy", 18 | "bugs": { 19 | "url": "https://github.com/klis87/normy/issues" 20 | }, 21 | "scripts": { 22 | "clean": "rimraf es lib dist types", 23 | "lint": "eslint 'src/**'", 24 | "test": "jest --passWithNoTests src", 25 | "test:cover": "jest --coverage --passWithNoTests src", 26 | "build-types": "tsc src/index.ts --strict --esModuleInterop --lib es2018,dom --skipLibCheck --jsx react --declaration --emitDeclarationOnly --declarationDir types", 27 | "build:commonjs": "cross-env BABEL_ENV=cjs babel src --extensions '.ts,.tsx' --out-dir lib --ignore 'src/**/*.spec.js'", 28 | "build:es": "babel src --extensions '.ts,.tsx' --out-dir es --ignore 'src/**/*.spec.js'", 29 | "build:umd": "webpack --mode development -o dist --output-filename normy-swr.js", 30 | "build:umd:min": "webpack --mode production -o dist --output-filename normy-swr.min.js", 31 | "build": "npm-run-all clean build-types -p build:commonjs build:es build:umd build:umd:min", 32 | "build:watch": "nodemon --watch src --ignore src/**/*.spec.js --exec 'pnpm run build:es'", 33 | "prepare": "pnpm run build" 34 | }, 35 | "peerDependencies": { 36 | "swr": ">=2.2.4" 37 | }, 38 | "dependencies": { 39 | "@normy/core": "workspace:*" 40 | }, 41 | "devDependencies": { 42 | "@babel/cli": "7.23.4", 43 | "@babel/core": "7.23.5", 44 | "@babel/preset-env": "7.23.5", 45 | "@babel/preset-react": "7.23.3", 46 | "@babel/preset-typescript": "7.23.3", 47 | "@babel/types": "7.23.5", 48 | "@types/node": "20.10.4", 49 | "@types/react": "18.2.43", 50 | "@typescript-eslint/eslint-plugin": "6.13.2", 51 | "@typescript-eslint/parser": "6.13.2", 52 | "babel-loader": "9.1.3", 53 | "cross-env": "7.0.3", 54 | "eslint": "8.55.0", 55 | "eslint-config-prettier": "9.1.0", 56 | "eslint-import-resolver-typescript": "3.6.1", 57 | "eslint-plugin-import": "2.29.0", 58 | "eslint-plugin-jsx-a11y": "6.8.0", 59 | "eslint-plugin-react": "7.33.2", 60 | "eslint-plugin-react-hooks": "4.6.0", 61 | "jest": "29.7.0", 62 | "nodemon": "2.0.6", 63 | "npm-run-all": "4.1.5", 64 | "react": "18.2.0", 65 | "react-dom": "18.2.0", 66 | "rimraf": "3.0.2", 67 | "swr": "2.2.4", 68 | "ts-jest": "29.1.1", 69 | "typescript": "5.6.3", 70 | "webpack": "5.89.0", 71 | "webpack-cli": "5.1.4" 72 | }, 73 | "publishConfig": { 74 | "access": "public" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /packages/normy-rtk-query/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@normy/rtk-query", 3 | "version": "0.3.0", 4 | "description": "rtk-query addon for normy - automatic normalization and data updates for data fetching libraries", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "jsnext:main": "es/index.js", 8 | "unpkg": "dist/normy-rtk-query.min.js", 9 | "repository": "git@github.com:klis87/normy.git", 10 | "author": "Konrad Lisiczynski ", 11 | "license": "MIT", 12 | "typings": "types/index.d.ts", 13 | "keywords": [ 14 | "normalization", 15 | "rtk-query", 16 | "redux" 17 | ], 18 | "homepage": "https://github.com/klis87/normy", 19 | "bugs": { 20 | "url": "https://github.com/klis87/normy/issues" 21 | }, 22 | "scripts": { 23 | "clean": "rimraf es lib dist types", 24 | "lint": "eslint 'src/**'", 25 | "test": "jest --passWithNoTests src", 26 | "test:cover": "jest --coverage --passWithNoTests src", 27 | "build-types": "tsc src/index.ts --strict --esModuleInterop --lib es2018,dom --skipLibCheck --jsx react --declaration --emitDeclarationOnly --declarationDir types", 28 | "build:commonjs": "cross-env BABEL_ENV=cjs babel src --extensions '.ts,.tsx' --out-dir lib --ignore 'src/**/*.spec.js'", 29 | "build:es": "babel src --extensions '.ts,.tsx' --out-dir es --ignore 'src/**/*.spec.js'", 30 | "build:umd": "webpack --mode development -o dist --output-filename normy-rtk-query.js", 31 | "build:umd:min": "webpack --mode production -o dist --output-filename normy-rtk-query.min.js", 32 | "build": "npm-run-all clean build-types -p build:commonjs build:es build:umd build:umd:min", 33 | "build:watch": "nodemon --watch src --ignore src/**/*.spec.js --exec 'pnpm run build:es'", 34 | "prepare": "pnpm run build" 35 | }, 36 | "peerDependencies": { 37 | "@reduxjs/toolkit": ">=2.0.1" 38 | }, 39 | "dependencies": { 40 | "@normy/core": "workspace:*" 41 | }, 42 | "devDependencies": { 43 | "@babel/cli": "7.23.4", 44 | "@babel/core": "7.23.5", 45 | "@babel/preset-env": "7.23.5", 46 | "@babel/preset-react": "7.23.3", 47 | "@babel/preset-typescript": "7.23.3", 48 | "@babel/types": "7.23.5", 49 | "@reduxjs/toolkit": "2.0.1", 50 | "@types/node": "20.10.4", 51 | "@types/react": "18.2.43", 52 | "@typescript-eslint/eslint-plugin": "6.13.2", 53 | "@typescript-eslint/parser": "6.13.2", 54 | "babel-loader": "9.1.3", 55 | "cross-env": "7.0.3", 56 | "eslint": "8.55.0", 57 | "eslint-config-prettier": "9.1.0", 58 | "eslint-import-resolver-typescript": "3.6.1", 59 | "eslint-plugin-import": "2.29.0", 60 | "eslint-plugin-jsx-a11y": "6.8.0", 61 | "eslint-plugin-react": "7.33.2", 62 | "eslint-plugin-react-hooks": "4.6.0", 63 | "jest": "29.7.0", 64 | "nodemon": "2.0.6", 65 | "npm-run-all": "4.1.5", 66 | "react": "18.2.0", 67 | "react-dom": "18.2.0", 68 | "rimraf": "3.0.2", 69 | "ts-jest": "29.1.1", 70 | "typescript": "5.6.3", 71 | "webpack": "5.89.0", 72 | "webpack-cli": "5.1.4" 73 | }, 74 | "publishConfig": { 75 | "access": "public" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/normy-react-query/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@normy/react-query", 3 | "version": "0.21.0", 4 | "description": "react-query addon for normy - automatic normalization and data updates for data fetching libraries", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "jsnext:main": "es/index.js", 8 | "unpkg": "dist/normy-react-query.min.js", 9 | "repository": "git@github.com:klis87/normy.git", 10 | "author": "Konrad Lisiczynski ", 11 | "license": "MIT", 12 | "typings": "types/index.d.ts", 13 | "keywords": [ 14 | "normalization", 15 | "react-query" 16 | ], 17 | "homepage": "https://github.com/klis87/normy", 18 | "bugs": { 19 | "url": "https://github.com/klis87/normy/issues" 20 | }, 21 | "scripts": { 22 | "clean": "rimraf es lib dist types", 23 | "lint": "eslint 'src/**'", 24 | "test": "jest src", 25 | "test:cover": "jest --coverage src", 26 | "build-types": "tsc src/index.ts --strict --esModuleInterop --lib es2018,dom --skipLibCheck --jsx react --declaration --emitDeclarationOnly --declarationDir types", 27 | "build:commonjs": "cross-env BABEL_ENV=cjs babel src --extensions '.ts,.tsx' --out-dir lib --ignore 'src/**/*.spec.js'", 28 | "build:es": "babel src --extensions '.ts,.tsx' --out-dir es --ignore 'src/**/*.spec.js'", 29 | "build:umd": "webpack --mode development -o dist --output-filename normy-react-query.js", 30 | "build:umd:min": "webpack --mode production -o dist --output-filename normy-react-query.min.js", 31 | "build": "npm-run-all clean build-types -p build:commonjs build:es build:umd build:umd:min", 32 | "build:watch": "nodemon --watch src --ignore src/**/*.spec.js --exec 'pnpm run build:es'", 33 | "prepare": "pnpm run build" 34 | }, 35 | "peerDependencies": { 36 | "@tanstack/react-query": ">=5.4.3" 37 | }, 38 | "dependencies": { 39 | "@normy/core": "workspace:*", 40 | "@normy/query-core": "workspace:*" 41 | }, 42 | "devDependencies": { 43 | "@babel/cli": "7.23.4", 44 | "@babel/core": "7.23.5", 45 | "@babel/preset-env": "7.23.5", 46 | "@babel/preset-react": "7.23.3", 47 | "@babel/preset-typescript": "7.23.3", 48 | "@babel/types": "7.23.5", 49 | "@tanstack/react-query": "5.4.3", 50 | "@types/node": "20.10.4", 51 | "@types/react": "18.2.43", 52 | "@typescript-eslint/eslint-plugin": "6.13.2", 53 | "@typescript-eslint/parser": "6.13.2", 54 | "babel-loader": "9.1.3", 55 | "cross-env": "7.0.3", 56 | "eslint": "8.55.0", 57 | "eslint-config-prettier": "9.1.0", 58 | "eslint-import-resolver-typescript": "3.6.1", 59 | "eslint-plugin-import": "2.29.0", 60 | "eslint-plugin-jsx-a11y": "6.8.0", 61 | "eslint-plugin-react": "7.33.2", 62 | "eslint-plugin-react-hooks": "4.6.0", 63 | "jest": "29.7.0", 64 | "nodemon": "2.0.6", 65 | "npm-run-all": "4.1.5", 66 | "react": "18.2.0", 67 | "react-dom": "18.2.0", 68 | "rimraf": "3.0.2", 69 | "ts-jest": "29.1.1", 70 | "typescript": "5.6.3", 71 | "webpack": "5.89.0", 72 | "webpack-cli": "5.1.4" 73 | }, 74 | "publishConfig": { 75 | "access": "public" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /examples/trpc/src/components/app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useMutation } from '@tanstack/react-query'; 3 | import { useQueryNormalizer } from '@normy/react-query'; 4 | 5 | import { trpc } from '../trpc'; 6 | 7 | const sleep = () => new Promise(resolve => setTimeout(resolve, 10)); 8 | 9 | const Books = () => { 10 | const { data: booksData = [] } = trpc.books.useQuery(); 11 | 12 | return booksData.map(book => ( 13 |
14 | {book.name} {book.author && book.author.name} 15 |
16 | )); 17 | }; 18 | 19 | const BooksApp = () => { 20 | const trpcContext = trpc.useContext(); 21 | const queryNormalizer = useQueryNormalizer(); 22 | 23 | const { data: bookData } = trpc.book.useQuery(undefined, { 24 | select: data => ({ ...data, nameLong: data.name, name: undefined }), 25 | }); 26 | 27 | const updateBookNameMutation = trpc.updateBookName.useMutation(); 28 | const updateBookAuthorMutation = trpc.updateBookAuthor.useMutation(); 29 | const addBookMutation = useMutation({ 30 | mutationFn: async () => { 31 | await sleep(); 32 | 33 | return { 34 | id: '3', 35 | name: 'Name 3', 36 | author: { id: '1002', name: 'User3' }, 37 | }; 38 | }, 39 | onSuccess: newData => { 40 | trpcContext.books.setData(undefined, data => data.concat(newData)); 41 | }, 42 | }); 43 | 44 | const updateBookNameMutationOptimistic = trpc.updateBookName.useMutation({ 45 | onMutate: () => ({ 46 | optimisticData: { 47 | id: '1', 48 | name: 'Name 1 Updated', 49 | }, 50 | rollbackData: { 51 | id: '1', 52 | name: 'Name 1', 53 | }, 54 | }), 55 | meta: { 56 | normalize: false, 57 | }, 58 | }); 59 | 60 | return ( 61 |
62 | {' '} 65 | {' '} 69 | {' '} 72 | 78 | 88 |
89 |

Books

90 | 91 |
92 | {bookData && ( 93 | <> 94 |

Book detail

95 | {bookData.nameLong} {bookData.author && bookData.author.name} 96 | 97 | )} 98 |
99 | ); 100 | }; 101 | 102 | const App = () => ( 103 |
104 |

Normy trpc example

105 | 106 |
107 | ); 108 | 109 | export default App; 110 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 7 | 'plugin:react/recommended', 8 | 'plugin:react-hooks/recommended', 9 | 'prettier', 10 | 'plugin:import/recommended', 11 | 'plugin:import/typescript', 12 | ], 13 | plugins: ['@typescript-eslint', 'import', 'react'], 14 | parser: '@typescript-eslint/parser', 15 | parserOptions: { 16 | project: './tsconfig.eslint.json', 17 | tsconfigRootDir: __dirname, 18 | }, 19 | env: { 20 | jest: true, 21 | browser: true, 22 | es6: true, 23 | node: true, 24 | }, 25 | settings: { 26 | react: { 27 | version: 'detect', 28 | }, 29 | 'import/parsers': { 30 | '@typescript-eslint/parser': ['.ts', '.tsx'], 31 | }, 32 | 'import/resolver': { 33 | typescript: { 34 | alwaysTryTypes: true, 35 | }, 36 | }, 37 | }, 38 | rules: { 39 | camelcase: 2, 40 | 'no-lonely-if': 2, 41 | 'no-tabs': 2, 42 | 'no-unneeded-ternary': 2, 43 | 'prefer-exponentiation-operator': 2, 44 | 'prefer-object-spread': 2, 45 | 'unicode-bom': 2, 46 | 'no-useless-rename': 2, 47 | 'no-var': 2, 48 | 'object-shorthand': 2, 49 | 'prefer-const': 2, 50 | 'no-useless-constructor': 2, 51 | 'no-useless-computed-key': 2, 52 | 'no-duplicate-imports': 2, 53 | 'prefer-template': 2, 54 | 'no-use-before-define': 2, 55 | 'no-undef-init': 2, 56 | 'no-shadow': 2, 57 | 'require-await': 2, 58 | 'no-useless-return': 2, 59 | 'no-useless-concat': 2, 60 | 'no-return-assign': 2, 61 | 'no-return-await': 2, 62 | 'no-template-curly-in-string': 2, 63 | 'no-unreachable-loop': 2, 64 | 'require-atomic-updates': 2, 65 | 'consistent-return': 2, 66 | curly: 2, 67 | eqeqeq: 2, 68 | 'no-else-return': 2, 69 | 'no-extra-bind': 2, 70 | 'no-lone-blocks': 2, 71 | 'no-self-compare': 2, 72 | 'no-sequences': 2, 73 | 'arrow-body-style': 2, 74 | 'no-console': 1, 75 | 76 | 'import/no-unresolved': 2, 77 | 'import/no-self-import': 2, 78 | 'import/first': 2, 79 | 'import/newline-after-import': 2, 80 | 'import/no-named-default': 2, 81 | 'import/dynamic-import-chunkname': 2, 82 | 'import/no-unused-modules': [ 83 | 0, 84 | { 85 | missingExports: false, 86 | unusedExports: true, 87 | ignoreExports: ['**/*/index.js'], 88 | }, 89 | ], 90 | 'import/no-extraneous-dependencies': 2, 91 | 'import/order': [ 92 | 2, 93 | { 94 | groups: ['builtin', 'external', 'parent', 'sibling', 'index'], 95 | 'newlines-between': 'always', 96 | }, 97 | ], 98 | 'import/named': 2, 99 | 100 | 'react/prop-types': 0, 101 | 'react/no-array-index-key': 0, 102 | 'react/button-has-type': 2, 103 | 'react/no-access-state-in-setstate': 2, 104 | 'react/no-redundant-should-component-update': 2, 105 | 'react/no-unsafe': 2, 106 | 'react/no-unused-state': 2, 107 | 'react/self-closing-comp': 2, 108 | 'react/style-prop-object': 2, 109 | 'react/jsx-boolean-value': 2, 110 | 'react/jsx-fragments': 2, 111 | 'react/jsx-no-script-url': 2, 112 | 'react/jsx-no-useless-fragment': 2, 113 | 'react/jsx-pascal-case': 2, 114 | 'react/jsx-filename-extension': [2, { extensions: ['.jsx', '.tsx'] }], 115 | 116 | 'react-hooks/exhaustive-deps': 0, 117 | }, 118 | }; 119 | -------------------------------------------------------------------------------- /examples/vue-query/src/components/TheWelcome.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 89 | -------------------------------------------------------------------------------- /packages/normy/src/denormalize.spec.ts: -------------------------------------------------------------------------------- 1 | import { denormalize } from './denormalize'; 2 | 3 | describe('denormalize', () => { 4 | it('should not affect objects without any reference', () => { 5 | expect(denormalize({ key: 'value' }, {}, {})).toEqual({ key: 'value' }); 6 | expect(denormalize('', {}, {})).toEqual(''); 7 | expect(denormalize('x', {}, {})).toEqual('x'); 8 | expect(denormalize(null, {}, {})).toEqual(null); 9 | }); 10 | 11 | it('should handle reference in direct string', () => { 12 | expect( 13 | denormalize( 14 | '@@1', 15 | { 16 | '@@1': { 17 | id: '1', 18 | a: 'b', 19 | extra: 1, 20 | }, 21 | }, 22 | { '': ['id', 'a'] }, 23 | ), 24 | ).toEqual({ 25 | id: '1', 26 | a: 'b', 27 | }); 28 | }); 29 | 30 | it('should handle single reference in object', () => { 31 | expect( 32 | denormalize( 33 | { key: 'value', ref: '@@1' }, 34 | { '@@1': { id: '1', a: 'b', extra: 1 } }, 35 | { '.ref': ['id', 'a'] }, 36 | ), 37 | ).toEqual({ key: 'value', ref: { id: '1', a: 'b' } }); 38 | }); 39 | 40 | it('should handle multiple references', () => { 41 | expect( 42 | denormalize( 43 | { key: 'value', ref: '@@1', nested: { k: 'v', anotherRef: '@@2' } }, 44 | { 45 | '@@1': { id: '1', a: 'b', extra: 1 }, 46 | '@@2': { id: '2', c: 'd', extra: 1 }, 47 | }, 48 | { '.ref': ['id', 'a'], '.nested.anotherRef': ['id', 'c'] }, 49 | ), 50 | ).toEqual({ 51 | key: 'value', 52 | ref: { id: '1', a: 'b' }, 53 | nested: { k: 'v', anotherRef: { id: '2', c: 'd' } }, 54 | }); 55 | }); 56 | 57 | it('should handle reference nested in another references', () => { 58 | expect( 59 | denormalize( 60 | { key: 'value', ref: '@@1' }, 61 | { 62 | '@@1': { id: '1', nested: '@@2', extra: 1 }, 63 | '@@2': { id: '2', c: 'd', extra: 1 }, 64 | }, 65 | { '.ref': ['id', 'nested'], '.ref.nested': ['id', 'c'] }, 66 | ), 67 | ).toEqual({ 68 | key: 'value', 69 | ref: { id: '1', nested: { id: '2', c: 'd' } }, 70 | }); 71 | }); 72 | 73 | it('should handle arrays', () => { 74 | expect( 75 | denormalize( 76 | { x: 1, flat: ['@@1', '@@2'], nested: [{ x: '@@1' }, { x: '@@2' }] }, 77 | { 78 | '@@1': { id: '1', nested: '@@3' }, 79 | '@@2': { id: '2', nested: '@@4' }, 80 | '@@3': { id: '3', x: 1, extra: 1 }, 81 | '@@4': { id: '4', x: 2, extra: 2 }, 82 | }, 83 | { 84 | '.flat': ['id'], 85 | '.nested.x': ['id', 'nested'], 86 | '.nested.x.nested': ['id', 'x'], 87 | }, 88 | ), 89 | ).toEqual({ 90 | x: 1, 91 | flat: [{ id: '1' }, { id: '2' }], 92 | nested: [ 93 | { x: { id: '1', nested: { id: '3', x: 1 } } }, 94 | { x: { id: '2', nested: { id: '4', x: 2 } } }, 95 | ], 96 | }); 97 | }); 98 | 99 | it('should handle one to one relationships', () => { 100 | expect( 101 | denormalize( 102 | ['@@1', '@@2', '@@3'], 103 | { 104 | '@@1': { id: '1', bestFriend: '@@2', extra: 1 }, 105 | '@@2': { id: '2', bestFriend: '@@1', extra: 1 }, 106 | '@@3': { id: '3', bestFriend: '@@3', extra: 1 }, 107 | }, 108 | { '': ['id', 'bestFriend'], '.bestFriend': ['id'] }, 109 | ), 110 | ).toEqual([ 111 | { id: '1', bestFriend: { id: '2' } }, 112 | { id: '2', bestFriend: { id: '1' } }, 113 | { id: '3', bestFriend: { id: '3' } }, 114 | ]); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /packages/normy-swr/src/SWRNormalizerProvider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | type NormalizerConfig, 4 | createNormalizer, 5 | type Data, 6 | } from '@normy/core'; 7 | import { useSWRConfig, SWRConfig, type Key } from 'swr'; 8 | 9 | const createSwrNormalizer = ( 10 | normalizerConfig: Omit & { 11 | normalize?: (queryKey: string) => boolean; 12 | } = {}, 13 | ) => { 14 | const normalizer = createNormalizer(normalizerConfig); 15 | // we solve chicken egg problem this way, we need normalizer to create swr context, and we cannot have mutate before it is created 16 | let mutate: ReturnType['mutate'] | null = null; 17 | 18 | return { 19 | ...normalizer, 20 | addMutate: (mutateCallback: ReturnType['mutate']) => { 21 | mutate = mutateCallback; 22 | }, 23 | normalize: normalizerConfig.normalize, 24 | setNormalizedData: (data: Data) => { 25 | const queriesToUpdate = normalizer.getQueriesToUpdate(data); 26 | 27 | queriesToUpdate.forEach(query => { 28 | void mutate?.(query.queryKey, query.data, { 29 | revalidate: false, 30 | }); 31 | }); 32 | }, 33 | }; 34 | }; 35 | 36 | const SWRNormalizerContext = React.createContext< 37 | undefined | ReturnType 38 | >(undefined); 39 | 40 | class CacheMap extends Map { 41 | normalizer: ReturnType | undefined; 42 | 43 | addNormalizer(normalizer: ReturnType) { 44 | this.normalizer = normalizer; 45 | } 46 | 47 | set(key: string, value: { data?: Data }) { 48 | if (value.data && (this.normalizer?.normalize?.(key) ?? true)) { 49 | this.normalizer?.setQuery(key, value.data); 50 | } 51 | 52 | return super.set(key, value); 53 | } 54 | 55 | delete(key: string) { 56 | this.normalizer?.removeQuery(key); 57 | return super.delete(key); 58 | } 59 | } 60 | 61 | const SWRNormalizerProviderInternal = ({ 62 | swrNormalizer, 63 | children, 64 | }: { 65 | swrNormalizer: ReturnType; 66 | children: React.ReactNode; 67 | }) => { 68 | const { mutate } = useSWRConfig(); 69 | 70 | React.useEffect(() => swrNormalizer.addMutate(mutate), []); 71 | 72 | return ( 73 | 74 | {children} 75 | 76 | ); 77 | }; 78 | 79 | export const SWRNormalizerProvider = ({ 80 | normalizerConfig, 81 | swrConfigValue, 82 | children, 83 | }: { 84 | normalizerConfig?: Omit & { 85 | normalize: (queryKey: Key) => boolean; 86 | }; 87 | swrConfigValue: React.ComponentProps['value']; 88 | children: React.ReactNode; 89 | }) => { 90 | const [swrNormalizer] = React.useState(() => 91 | createSwrNormalizer(normalizerConfig), 92 | ); 93 | 94 | const [cacheProvider] = React.useState(() => () => { 95 | const map = new CacheMap(); 96 | map.addNormalizer(swrNormalizer); 97 | return map; 98 | }); 99 | 100 | React.useEffect(() => () => swrNormalizer.clearNormalizedData(), []); 101 | 102 | return ( 103 | 109 | 110 | {children} 111 | 112 | 113 | ); 114 | }; 115 | 116 | export const useSWRNormalizer = () => { 117 | const swrNormalizer = React.useContext(SWRNormalizerContext); 118 | 119 | if (!swrNormalizer) { 120 | throw new Error( 121 | 'No SWRNormalizer set, use SWRNormalizerProvider to set one', 122 | ); 123 | } 124 | 125 | return swrNormalizer; 126 | }; 127 | -------------------------------------------------------------------------------- /packages/normy/src/normalize.ts: -------------------------------------------------------------------------------- 1 | import { defaultConfig } from './default-config'; 2 | import { mergeData } from './merge-data'; 3 | import { 4 | Data, 5 | NormalizerConfig, 6 | DataPrimitiveArray, 7 | DataObject, 8 | UsedKeys, 9 | } from './types'; 10 | 11 | const stipFromDeps = ( 12 | data: Data, 13 | config: Required, 14 | root = true, 15 | ): Data => { 16 | if (Array.isArray(data)) { 17 | return data.map(v => stipFromDeps(v, config)) as 18 | | DataPrimitiveArray 19 | | DataObject[]; 20 | } 21 | 22 | if (data !== null && typeof data === 'object' && !(data instanceof Date)) { 23 | const objectKey = config.getNormalizationObjectKey(data); 24 | 25 | if (objectKey !== undefined && root) { 26 | return `@@${objectKey}`; 27 | } 28 | 29 | return Object.entries(data).reduce((prev, [k, v]) => { 30 | prev[k] = stipFromDeps(v, config); 31 | 32 | return prev; 33 | }, {} as DataObject); 34 | } 35 | 36 | return data; 37 | }; 38 | 39 | export const getDependencies = ( 40 | data: Data, 41 | queryKey: string, 42 | config = defaultConfig, 43 | usedKeys?: UsedKeys, 44 | arrayTypes?: string[], 45 | path = '', 46 | parentObj?: DataObject, 47 | parentObjKey?: string, 48 | ): [DataObject[], UsedKeys, string[]] => { 49 | usedKeys = usedKeys || {}; 50 | arrayTypes = arrayTypes || []; 51 | 52 | if (Array.isArray(data)) { 53 | const arrayType = config.getArrayType({ 54 | array: data as DataObject[], 55 | parentObj: parentObj as DataObject, 56 | arrayKey: parentObjKey as string, 57 | queryKey, 58 | }); 59 | 60 | if (arrayType !== undefined && !arrayTypes.includes(arrayType)) { 61 | arrayTypes.push(arrayType); 62 | } 63 | 64 | return [ 65 | (data as DataObject[]).reduce( 66 | (prev: DataObject[], current: Data) => [ 67 | ...prev, 68 | ...getDependencies( 69 | current, 70 | queryKey, 71 | config, 72 | usedKeys, 73 | arrayTypes, 74 | path, 75 | )[0], 76 | ], 77 | [] as DataObject[], 78 | ), 79 | usedKeys, 80 | arrayTypes, 81 | ]; 82 | } 83 | 84 | if (data !== null && typeof data === 'object' && !(data instanceof Date)) { 85 | if (config.getNormalizationObjectKey(data) !== undefined) { 86 | usedKeys[path] = Object.keys(data); 87 | } 88 | 89 | return [ 90 | Object.entries(data).reduce( 91 | (prev, [k, v]) => [ 92 | ...prev, 93 | ...getDependencies( 94 | v, 95 | queryKey, 96 | config, 97 | usedKeys, 98 | arrayTypes, 99 | `${path}.${k}`, 100 | data, 101 | k, 102 | )[0], 103 | ], 104 | config.getNormalizationObjectKey(data) !== undefined ? [data] : [], 105 | ), 106 | usedKeys, 107 | arrayTypes, 108 | ]; 109 | } 110 | 111 | return [[], usedKeys, arrayTypes]; 112 | }; 113 | 114 | export const normalize = ( 115 | data: Data, 116 | queryKey: string, 117 | config = defaultConfig, 118 | ): [Data, { [objectId: string]: DataObject }, UsedKeys, string[]] => { 119 | const [dependencies, usedKeys, arrayTypes] = getDependencies( 120 | data, 121 | queryKey, 122 | config, 123 | undefined, 124 | undefined, 125 | '', 126 | ); 127 | 128 | return [ 129 | stipFromDeps(data, config, true), 130 | dependencies.reduce( 131 | (prev, v) => { 132 | const key = config.getNormalizationObjectKey(v) as string; 133 | 134 | prev[`@@${key}`] = prev[`@@${key}`] 135 | ? mergeData(prev[`@@${key}`], stipFromDeps(v, config, false)) 136 | : stipFromDeps(v, config, false); 137 | 138 | return prev; 139 | }, 140 | {} as { [objectId: string]: DataObject }, 141 | ) as { 142 | [objectId: string]: DataObject; 143 | }, 144 | usedKeys, 145 | arrayTypes, 146 | ]; 147 | }; 148 | -------------------------------------------------------------------------------- /examples/swr/src/components/app.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useSWR from 'swr'; 3 | import { 4 | SWRNormalizerProvider, 5 | useNormalizedSWRMutation, 6 | useSWRNormalizer, 7 | } from '@normy/swr'; 8 | 9 | const sleep = (x = 10) => new Promise(resolve => setTimeout(resolve, x)); 10 | 11 | const Books = () => { 12 | const { data: booksData = [] } = useSWR('/books', () => 13 | Promise.resolve([ 14 | { id: '0', name: 'Name 0', author: null }, 15 | { id: '1', name: 'Name 1', author: { id: '1000', name: 'User1' } }, 16 | { id: '2', name: 'Name 2', author: { id: '1001', name: 'User2' } }, 17 | ]), 18 | ); 19 | 20 | return booksData.map(book => ( 21 |
22 | {book.name} {book.author && book.author.name} 23 |
24 | )); 25 | }; 26 | 27 | const BooksApp = () => { 28 | const normalizer = useSWRNormalizer(); 29 | 30 | const { data: bookData } = useSWR( 31 | '/book', 32 | () => 33 | Promise.resolve({ 34 | id: '1', 35 | name: 'Name 1', 36 | author: { id: '1000', name: 'User1' }, 37 | }), 38 | { normalize: false }, 39 | ); 40 | 41 | const updateBookNameMutation = useNormalizedSWRMutation( 42 | '/book', 43 | async () => { 44 | await sleep(3000); 45 | return { 46 | id: '1', 47 | name: 'Name 1 Updated', 48 | author: { id: '1000', name: 'User1 updated' }, 49 | }; 50 | }, 51 | { 52 | optimisticData: { 53 | id: '1', 54 | name: 'Name 1 Updated', 55 | author: { id: '1000', name: 'User1 updated' }, 56 | }, 57 | rollbackData: { 58 | id: '1', 59 | name: 'Name 1', 60 | author: { id: '1000', name: 'User1' }, 61 | }, 62 | }, 63 | ); 64 | const updateBookAuthorMutation = useNormalizedSWRMutation( 65 | '/book/update-author', 66 | async () => { 67 | await sleep(1000); 68 | return { 69 | id: '0', 70 | author: { id: '1004', name: 'User4 new' }, 71 | }; 72 | }, 73 | ); 74 | const addBookMutation = useNormalizedSWRMutation( 75 | '/books', 76 | async () => { 77 | await sleep(2000); 78 | 79 | return { 80 | id: '3', 81 | name: 'Name 3', 82 | author: { id: '1002', name: 'User3' }, 83 | }; 84 | }, 85 | { 86 | populateCache: (newBook, books) => books.concat(newBook), 87 | }, 88 | ); 89 | 90 | return ( 91 |
92 | {' '} 95 | {' '} 99 | {' '} 102 | 112 |
113 |

Books

114 | 115 |
116 | {bookData && ( 117 | <> 118 |

Book detail

119 | {bookData.name} {bookData.author && bookData.author.name} 120 | 121 | )} 122 |
123 | ); 124 | }; 125 | 126 | const App = () => { 127 | return ( 128 |
129 |

Normy Swr example

130 | 138 | 139 | 140 |
141 | ); 142 | }; 143 | 144 | export default App; 145 | -------------------------------------------------------------------------------- /packages/normy-swr/src/useNormalizedSWRMutation.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type { Data as NormyData } from '@normy/core'; 3 | import { useSWRConfig, type Key } from 'swr'; 4 | import useSWRMutation, { 5 | type MutationFetcher, 6 | type SWRMutationConfiguration, 7 | type SWRMutationResponse, 8 | } from 'swr/mutation'; 9 | 10 | import { useSWRNormalizer } from './SWRNormalizerProvider'; 11 | 12 | type NormyOptions = { 13 | normalize?: boolean; 14 | optimisticData?: NormyData; 15 | rollbackData?: NormyData; 16 | }; 17 | 18 | interface NormalizedSWRMutationHook { 19 | < 20 | Data = any, 21 | Error = any, 22 | SWRMutationKey extends Key = Key, 23 | ExtraArg = never, 24 | SWRData = Data, 25 | >( 26 | key: SWRMutationKey, 27 | fetcher: MutationFetcher, 28 | options?: SWRMutationConfiguration< 29 | Data, 30 | Error, 31 | SWRMutationKey, 32 | ExtraArg, 33 | SWRData 34 | > & 35 | NormyOptions, 36 | ): SWRMutationResponse; 37 | < 38 | Data = any, 39 | Error = any, 40 | SWRMutationKey extends Key = Key, 41 | ExtraArg = never, 42 | SWRData = Data, 43 | >( 44 | key: SWRMutationKey, 45 | fetcher: MutationFetcher, 46 | options?: SWRMutationConfiguration< 47 | Data, 48 | Error, 49 | SWRMutationKey, 50 | ExtraArg, 51 | SWRData 52 | > & { throwOnError: false } & NormyOptions, 53 | ): SWRMutationResponse; 54 | < 55 | Data = any, 56 | Error = any, 57 | SWRMutationKey extends Key = Key, 58 | ExtraArg = never, 59 | SWRData = Data, 60 | >( 61 | key: SWRMutationKey, 62 | fetcher: MutationFetcher, 63 | options?: SWRMutationConfiguration< 64 | Data, 65 | Error, 66 | SWRMutationKey, 67 | ExtraArg, 68 | SWRData 69 | > & { throwOnError: true } & NormyOptions, 70 | ): SWRMutationResponse; 71 | } 72 | 73 | export const useNormalizedSWRMutation: NormalizedSWRMutationHook = ( 74 | key, 75 | fetcher, 76 | options, 77 | ) => { 78 | const { mutate } = useSWRConfig(); 79 | const normalizer = useSWRNormalizer(); 80 | 81 | return useSWRMutation( 82 | key, 83 | // @ts-expect-error swr types compatiblity issue, perhaps due to ts version mismatch 84 | (k, opts) => { 85 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 86 | if (options?.optimisticData) { 87 | const queriesToUpdate = normalizer.getQueriesToUpdate( 88 | options?.optimisticData as NormyData, 89 | ); 90 | 91 | queriesToUpdate.forEach(query => { 92 | void mutate(query.queryKey, query.data, { 93 | revalidate: false, 94 | }); 95 | }); 96 | } 97 | 98 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 99 | return fetcher(k, opts); 100 | }, 101 | { 102 | populateCache: false, 103 | revalidate: false, 104 | ...options, 105 | optimisticData: undefined, 106 | onSuccess: (data, mutationKey, config) => { 107 | if (options?.normalize ?? true) { 108 | const queriesToUpdate = normalizer.getQueriesToUpdate( 109 | data as NormyData, 110 | ); 111 | 112 | queriesToUpdate.forEach(query => { 113 | void mutate(query.queryKey, query.data, { 114 | revalidate: false, 115 | }); 116 | }); 117 | } 118 | 119 | return options?.onSuccess?.(data, mutationKey, config); 120 | }, 121 | onError: (error, mutationKey, config) => { 122 | if (options?.rollbackData) { 123 | const queriesToUpdate = normalizer.getQueriesToUpdate( 124 | options?.rollbackData as NormyData, 125 | ); 126 | 127 | queriesToUpdate.forEach(query => { 128 | void mutate(query.queryKey, query.data, { 129 | revalidate: false, 130 | }); 131 | }); 132 | } 133 | 134 | return options?.onError?.(error, mutationKey, config); 135 | }, 136 | }, 137 | ); 138 | }; 139 | -------------------------------------------------------------------------------- /examples/react-query/src/components/app.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 3 | import { useQueryNormalizer } from '@normy/react-query'; 4 | 5 | const sleep = (timeout = 10) => 6 | new Promise(resolve => setTimeout(resolve, timeout)); 7 | 8 | const Books = () => { 9 | const { data: booksData } = useQuery({ 10 | queryKey: ['books'], 11 | queryFn: () => 12 | Promise.resolve({ 13 | books: [ 14 | { id: '0', name: 'Name 0', author: null }, 15 | { id: '1', name: 'Name 1', author: { id: '1000', name: 'User1' } }, 16 | { id: '2', name: 'Name 2', author: { id: '1001', name: 'User2' } }, 17 | ], 18 | }), 19 | }); 20 | 21 | return booksData?.books.map(book => ( 22 |
23 | {book.name} {book.author && book.author.name} 24 |
25 | )); 26 | }; 27 | 28 | const BooksApp = () => { 29 | const queryClient = useQueryClient(); 30 | const queryNormalizer = useQueryNormalizer(); 31 | 32 | const { data: bookData } = useQuery({ 33 | queryKey: ['book'], 34 | queryFn: () => 35 | Promise.resolve({ 36 | id: '1', 37 | name: 'Name 1', 38 | author: { id: '1000', name: 'User1' }, 39 | }), 40 | select: data => ({ ...data, nameLong: data.name, name: undefined }), 41 | }); 42 | const updateBookNameMutation = useMutation({ 43 | mutationFn: async () => { 44 | await sleep(); 45 | return { 46 | id: '1', 47 | name: 'Name 1 Updated', 48 | }; 49 | }, 50 | }); 51 | const updateBookAuthorMutation = useMutation({ 52 | mutationFn: async () => { 53 | await sleep(); 54 | return { 55 | id: '0', 56 | author: { id: '1004', name: 'User4 new' }, 57 | }; 58 | }, 59 | }); 60 | const addBookMutation = useMutation({ 61 | mutationFn: async () => { 62 | await sleep(); 63 | 64 | return [ 65 | { 66 | id: '3', 67 | name: 'Name 3', 68 | author: { id: '1002', name: 'User3' }, 69 | __append: ['books'], 70 | }, 71 | ]; 72 | }, 73 | }); 74 | 75 | const updateBookNameMutationOptimistic = useMutation({ 76 | mutationFn: async () => { 77 | await sleep(2000); 78 | 79 | throw new Error('test'); 80 | }, 81 | onMutate: () => ({ 82 | optimisticData: { 83 | id: '1', 84 | name: 'Name 1 Updated', 85 | }, 86 | }), 87 | meta: { 88 | normalize: false, 89 | }, 90 | }); 91 | 92 | return ( 93 |
94 | {' '} 97 | {' '} 101 | {' '} 104 | 110 | 120 | 128 |
129 |

Books

130 | 131 |
132 | {bookData && ( 133 | <> 134 |

Book detail

135 | {bookData.nameLong} {bookData.author && bookData.author.name} 136 | 137 | )} 138 |
139 | ); 140 | }; 141 | 142 | const App = () => ( 143 |
144 |

Normy React Query example

145 | 146 |
147 | ); 148 | 149 | export default App; 150 | -------------------------------------------------------------------------------- /examples/vue-query/src/App.vue: -------------------------------------------------------------------------------- 1 | 119 | 120 | 165 | -------------------------------------------------------------------------------- /packages/normy-rtk-query/src/index.ts: -------------------------------------------------------------------------------- 1 | import { type Dispatch, type Middleware, isAction } from '@reduxjs/toolkit'; 2 | import type { createApi } from '@reduxjs/toolkit/query'; 3 | import { 4 | createNormalizer, 5 | type Data, 6 | type NormalizerConfig, 7 | } from '@normy/core'; 8 | 9 | export { getId, arrayHelpers, createArrayHelpers } from '@normy/core'; 10 | 11 | type GetNormalizerAction = { 12 | type: 'getNormalization'; 13 | }; 14 | 15 | export const getNormalizer = ( 16 | dispatch: Dispatch, 17 | ): ReturnType => 18 | dispatch({ type: 'getNormalization' }) as unknown as ReturnType< 19 | typeof createNormalizer 20 | >; 21 | 22 | type QueryFulfilledAction = { 23 | type: 'api/executeQuery/fulfilled'; 24 | payload: Data; 25 | meta: { 26 | arg: { 27 | queryCacheKey: string; 28 | originalArgs: unknown; 29 | }; 30 | }; 31 | }; 32 | 33 | type QueryRemovedAction = { 34 | type: 'api/queries/removeQueryResult'; 35 | payload: { 36 | queryCacheKey: string; 37 | }; 38 | }; 39 | 40 | type MutationFulfilledAction = { 41 | type: 'api/executeMutation/fulfilled'; 42 | payload: Data; 43 | meta: { 44 | arg: { 45 | queryCacheKey: string; 46 | originalArgs: unknown; 47 | endpointName: string; 48 | }; 49 | }; 50 | }; 51 | 52 | type QueryPatchedAction = { 53 | type: 'api/queries/queryResultPatched'; 54 | payload: { 55 | queryCacheKey: string; 56 | }; 57 | }; 58 | 59 | type NormalizerAction = 60 | | GetNormalizerAction 61 | | QueryFulfilledAction 62 | | QueryRemovedAction 63 | | MutationFulfilledAction 64 | | QueryPatchedAction; 65 | 66 | type ActionType = NormalizerAction['type']; 67 | 68 | const allTypes: ReadonlyArray = [ 69 | 'api/executeMutation/fulfilled', 70 | 'api/executeQuery/fulfilled', 71 | 'api/queries/queryResultPatched', 72 | 'api/queries/removeQueryResult', 73 | 'getNormalization', 74 | ]; 75 | 76 | const isNormalizerAction = (action: unknown): action is NormalizerAction => 77 | isAction(action) && (allTypes as ReadonlyArray).includes(action.type); 78 | 79 | export const createNormalizationMiddleware = ( 80 | api: ReturnType, 81 | normalizerConfig?: Omit & { 82 | normalizeQuery?: (queryType: string) => boolean; 83 | normalizeMutation?: (mutationEndpointName: string) => boolean; 84 | }, 85 | ): Middleware => { 86 | const normalizer = createNormalizer({ 87 | ...normalizerConfig, 88 | // TODO: we wait for rtk-query maintainers to make this work 89 | structuralSharing: false, 90 | }); 91 | 92 | const args: Record = {}; 93 | 94 | return store => next => action => { 95 | if (!isNormalizerAction(action)) { 96 | return next(action); 97 | } 98 | 99 | if ( 100 | action.type === 'api/queries/queryResultPatched' && 101 | (normalizerConfig?.normalizeQuery?.(action.payload.queryCacheKey) ?? true) 102 | ) { 103 | const response = next(action); 104 | 105 | normalizer.setQuery( 106 | action.payload.queryCacheKey, 107 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 108 | store.getState()[api.reducerPath].queries[action.payload.queryCacheKey] 109 | .data as Data, 110 | ); 111 | 112 | return response; 113 | } 114 | 115 | if (action.type === 'getNormalization') { 116 | return { 117 | ...normalizer, 118 | setNormalizedData: (data: Data) => { 119 | const queriesToUpdate = normalizer.getQueriesToUpdate(data); 120 | 121 | queriesToUpdate.forEach(query => { 122 | const endpoint = query.queryKey.split('(')[0]; 123 | 124 | store.dispatch( 125 | // @ts-expect-error this is generic api, which is not typed 126 | api.util.updateQueryData( 127 | // @ts-expect-error this is generic api, which is not typed 128 | endpoint, 129 | args[query.queryKey], 130 | () => query.data, 131 | ), 132 | ); 133 | }); 134 | }, 135 | }; 136 | } 137 | 138 | if ( 139 | action.type === 'api/executeQuery/fulfilled' && 140 | (normalizerConfig?.normalizeQuery?.(action.meta.arg.queryCacheKey) ?? 141 | true) 142 | ) { 143 | normalizer.setQuery(action.meta.arg.queryCacheKey, action.payload); 144 | args[action.meta.arg.queryCacheKey] = action.meta.arg.originalArgs; 145 | } else if (action.type === 'api/queries/removeQueryResult') { 146 | normalizer.removeQuery(action.payload.queryCacheKey); 147 | delete args[action.payload.queryCacheKey]; 148 | } else if ( 149 | action.type === 'api/executeMutation/fulfilled' && 150 | (normalizerConfig?.normalizeMutation?.(action.meta.arg.endpointName) ?? 151 | true) 152 | ) { 153 | const queriesToUpdate = normalizer.getQueriesToUpdate(action.payload); 154 | 155 | queriesToUpdate.forEach(query => { 156 | const endpoint = query.queryKey.split('(')[0]; 157 | 158 | store.dispatch( 159 | // @ts-expect-error ddd 160 | api.util.updateQueryData( 161 | // @ts-expect-error ddd 162 | endpoint, 163 | args[query.queryKey], 164 | () => query.data, 165 | ), 166 | ); 167 | }); 168 | } 169 | 170 | return next(action); 171 | }; 172 | }; 173 | -------------------------------------------------------------------------------- /packages/normy-query-core/src/create-query-normalizer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createNormalizer, 3 | type Data, 4 | type NormalizerConfig, 5 | } from '@normy/core'; 6 | import type { QueryClient, QueryKey } from '@tanstack/query-core'; 7 | 8 | const shouldBeNormalized = ( 9 | globalNormalize: boolean, 10 | localNormalize: boolean | undefined, 11 | ) => { 12 | if (localNormalize === undefined) { 13 | return globalNormalize; 14 | } 15 | 16 | return localNormalize; 17 | }; 18 | 19 | const updateQueriesFromMutationData = ( 20 | mutationData: Data, 21 | normalizer: ReturnType, 22 | queryClient: QueryClient, 23 | ) => { 24 | const queriesToUpdate = normalizer.getQueriesToUpdate(mutationData); 25 | 26 | queriesToUpdate.forEach(query => { 27 | const queryKey = JSON.parse(query.queryKey) as QueryKey; 28 | const cachedQuery = queryClient.getQueryCache().find({ queryKey }); 29 | 30 | // react-query resets some state when setQueryData() is called. 31 | // We'll remember and reapply state that shouldn't 32 | // be reset when a query is updated via Normy. 33 | 34 | // dataUpdatedAt and isInvalidated determine if a query is stale or not, 35 | // and we only want data updates from the network to change it. 36 | const dataUpdatedAt = cachedQuery?.state.dataUpdatedAt; 37 | const isInvalidated = cachedQuery?.state.isInvalidated; 38 | const error = cachedQuery?.state.error; 39 | const status = cachedQuery?.state.status; 40 | 41 | queryClient.setQueryData(queryKey, () => query.data, { 42 | updatedAt: dataUpdatedAt, 43 | }); 44 | 45 | cachedQuery?.setState({ isInvalidated, error, status }); 46 | }); 47 | }; 48 | 49 | export const createQueryNormalizer = ( 50 | queryClient: QueryClient, 51 | normalizerConfig: Omit & { 52 | normalize?: boolean; 53 | } = {}, 54 | ) => { 55 | const normalize = normalizerConfig.normalize ?? true; 56 | const normalizer = createNormalizer(normalizerConfig); 57 | 58 | let unsubscribeQueryCache: null | (() => void) = null; 59 | let unsubscribeMutationCache: null | (() => void) = null; 60 | 61 | return { 62 | getNormalizedData: normalizer.getNormalizedData, 63 | setNormalizedData: (data: Data) => 64 | updateQueriesFromMutationData(data, normalizer, queryClient), 65 | clear: normalizer.clearNormalizedData, 66 | subscribe: () => { 67 | unsubscribeQueryCache = queryClient.getQueryCache().subscribe(event => { 68 | if (event.type === 'removed') { 69 | normalizer.removeQuery(JSON.stringify(event.query.queryKey)); 70 | } else if ( 71 | event.type === 'added' && 72 | event.query.state.data !== undefined && 73 | shouldBeNormalized(normalize, event.query.meta?.normalize) 74 | ) { 75 | normalizer.setQuery( 76 | JSON.stringify(event.query.queryKey), 77 | event.query.state.data as Data, 78 | ); 79 | } else if ( 80 | event.type === 'updated' && 81 | event.action.type === 'success' && 82 | event.action.data !== undefined && 83 | shouldBeNormalized(normalize, event.query.meta?.normalize) 84 | ) { 85 | normalizer.setQuery( 86 | JSON.stringify(event.query.queryKey), 87 | event.action.data as Data, 88 | ); 89 | } 90 | }); 91 | 92 | unsubscribeMutationCache = queryClient 93 | .getMutationCache() 94 | .subscribe(event => { 95 | if ( 96 | event.type === 'updated' && 97 | event.action.type === 'success' && 98 | event.action.data && 99 | shouldBeNormalized(normalize, event.mutation.meta?.normalize) 100 | ) { 101 | updateQueriesFromMutationData( 102 | event.action.data as Data, 103 | normalizer, 104 | queryClient, 105 | ); 106 | } else if ( 107 | event.type === 'updated' && 108 | event.action.type === 'pending' && 109 | (event.mutation.state?.context as { optimisticData?: Data }) 110 | ?.optimisticData 111 | ) { 112 | const context = event.mutation.state.context as { 113 | optimisticData: Data; 114 | rollbackData?: Data; 115 | }; 116 | 117 | if (!context.rollbackData) { 118 | const rollbackDataToInject = normalizer.getCurrentData( 119 | context.optimisticData, 120 | ); 121 | 122 | normalizer.log( 123 | 'calculated automatically rollbackData:', 124 | rollbackDataToInject, 125 | ); 126 | context.rollbackData = rollbackDataToInject; 127 | } 128 | 129 | updateQueriesFromMutationData( 130 | context.optimisticData, 131 | normalizer, 132 | queryClient, 133 | ); 134 | } else if ( 135 | event.type === 'updated' && 136 | event.action.type === 'error' && 137 | (event.mutation.state?.context as { rollbackData?: Data }) 138 | ?.rollbackData 139 | ) { 140 | updateQueriesFromMutationData( 141 | (event.mutation.state.context as { rollbackData: Data }) 142 | .rollbackData, 143 | normalizer, 144 | queryClient, 145 | ); 146 | } 147 | }); 148 | }, 149 | unsubscribe: () => { 150 | unsubscribeQueryCache?.(); 151 | unsubscribeMutationCache?.(); 152 | unsubscribeQueryCache = null; 153 | unsubscribeMutationCache = null; 154 | }, 155 | getObjectById: normalizer.getObjectById, 156 | getQueryFragment: normalizer.getQueryFragment, 157 | getDependentQueries: (mutationData: Data) => 158 | normalizer 159 | .getDependentQueries(mutationData) 160 | .map(key => JSON.parse(key) as QueryKey), 161 | getDependentQueriesByIds: (ids: ReadonlyArray) => 162 | normalizer 163 | .getDependentQueriesByIds(ids) 164 | .map(key => JSON.parse(key) as QueryKey), 165 | }; 166 | }; 167 | -------------------------------------------------------------------------------- /packages/normy/src/array-helpers.ts: -------------------------------------------------------------------------------- 1 | import { DataObject } from './types'; 2 | 3 | export const createArrayHelpers = < 4 | NodelessOps extends Record, 5 | NodeOps extends Record, 6 | >( 7 | props: { 8 | nodelessOperations?: NodelessOps; 9 | nodeOperations?: NodeOps; 10 | } = {}, 11 | ) => { 12 | const { nodelessOperations, nodeOperations } = props; 13 | 14 | const baseHelpers = { 15 | remove: >( 16 | node: N, 17 | arrayType: string, 18 | ): N => ({ 19 | ...node, 20 | __remove: Array.isArray(node.__remove) 21 | ? [...(node.__remove as string[]), arrayType] 22 | : [arrayType], 23 | }), 24 | 25 | append: >( 26 | node: N, 27 | arrayType: string, 28 | ): N => ({ 29 | ...node, 30 | __append: Array.isArray(node.__append) 31 | ? [...(node.__append as string[]), arrayType] 32 | : [arrayType], 33 | }), 34 | 35 | prepend: >( 36 | node: N, 37 | arrayType: string, 38 | ): N => ({ 39 | ...node, 40 | __prepend: Array.isArray(node.__prepend) 41 | ? [...(node.__prepend as string[]), arrayType] 42 | : [arrayType], 43 | }), 44 | 45 | insert: >( 46 | node: N, 47 | arrayType: string, 48 | config: { index: number }, 49 | ): N => ({ 50 | ...node, 51 | __insert: Array.isArray(node.__insert) 52 | ? [...(node.__insert as unknown[]), { arrayType, index: config.index }] 53 | : [{ arrayType, index: config.index }], 54 | }), 55 | 56 | replace: >( 57 | node: N, 58 | arrayType: string, 59 | config: { index: number }, 60 | ): N => ({ 61 | ...node, 62 | __replace: Array.isArray(node.__replace) 63 | ? [...(node.__replace as unknown[]), { arrayType, index: config.index }] 64 | : [{ arrayType, index: config.index }], 65 | }), 66 | 67 | move: >( 68 | node: N, 69 | arrayType: string, 70 | config: { toIndex: number }, 71 | ): N => ({ 72 | ...node, 73 | __move: Array.isArray(node.__move) 74 | ? [ 75 | ...(node.__move as unknown[]), 76 | { arrayType, toIndex: config.toIndex }, 77 | ] 78 | : [{ arrayType, toIndex: config.toIndex }], 79 | }), 80 | 81 | swap: >( 82 | node: N, 83 | arrayType: string, 84 | config: { toIndex: number }, 85 | ): N => ({ 86 | ...node, 87 | __swap: Array.isArray(node.__swap) 88 | ? [ 89 | ...(node.__swap as unknown[]), 90 | { arrayType, toIndex: config.toIndex }, 91 | ] 92 | : [{ arrayType, toIndex: config.toIndex }], 93 | }), 94 | 95 | clear: (arrayType: string) => ({ 96 | __clear: arrayType, 97 | }), 98 | 99 | replaceAll: (arrayType: string, config: { value: DataObject[] }) => ({ 100 | __replaceAll: { arrayTypes: arrayType, value: config.value }, 101 | }), 102 | } as const; 103 | 104 | const helpers = { 105 | ...baseHelpers, 106 | ...(nodelessOperations || ({} as NodelessOps)), 107 | ...(nodeOperations || ({} as NodeOps)), 108 | } as typeof baseHelpers & NodelessOps & NodeOps; 109 | 110 | // Extract parameter types from chainable operations, excluding the first 'node' parameter 111 | type ChainOpParams = T extends ( 112 | node: Record, 113 | ...args: infer P 114 | ) => Record 115 | ? P 116 | : never; 117 | 118 | type ChainApi = { 119 | apply: () => N; 120 | // Base chainable operations (exclude clear and replaceAll which don't take node) 121 | remove: (arrayType: string) => ChainApi; 122 | append: (arrayType: string) => ChainApi; 123 | prepend: (arrayType: string) => ChainApi; 124 | insert: (arrayType: string, config: { index: number }) => ChainApi; 125 | replace: (arrayType: string, config: { index: number }) => ChainApi; 126 | move: (arrayType: string, config: { toIndex: number }) => ChainApi; 127 | swap: (arrayType: string, config: { toIndex: number }) => ChainApi; 128 | } & { 129 | [K in keyof NodeOps]: (...args: ChainOpParams) => ChainApi; 130 | }; 131 | 132 | const callHelper = ( 133 | fn: ( 134 | node: Record, 135 | ...args: ReadonlyArray 136 | ) => Record, 137 | current: Record, 138 | args: ReadonlyArray, 139 | ): Record => fn(current, ...args); 140 | 141 | const chain = >(node: N): ChainApi => { 142 | const create = (current: N): ChainApi => { 143 | const api: Record & { apply: () => N } = { 144 | apply: () => current, 145 | }; 146 | 147 | ( 148 | [ 149 | 'remove', 150 | 'append', 151 | 'prepend', 152 | 'insert', 153 | 'replace', 154 | 'move', 155 | 'swap', 156 | ] as const 157 | ).forEach(key => { 158 | const fn = helpers[key] as unknown as ( 159 | n: Record, 160 | ...a: ReadonlyArray 161 | ) => Record; 162 | 163 | (api as Record)[key] = ( 164 | ...args: ReadonlyArray 165 | ) => create(callHelper(fn, current, args) as N); 166 | }); 167 | 168 | Object.keys(nodeOperations || {}).forEach(key => { 169 | (api as Record)[key] = ( 170 | ...args: ReadonlyArray 171 | ) => { 172 | const fn = helpers[key] as ( 173 | ...params: unknown[] 174 | ) => Record; 175 | const result = fn(current, ...args); 176 | return create(result as N); 177 | }; 178 | }); 179 | 180 | return api as ChainApi; 181 | }; 182 | 183 | return create(node); 184 | }; 185 | 186 | return { 187 | ...helpers, 188 | chain, 189 | } as typeof baseHelpers & NodelessOps & NodeOps & { chain: typeof chain }; 190 | }; 191 | 192 | export const arrayHelpers = createArrayHelpers({}); 193 | -------------------------------------------------------------------------------- /packages/normy/src/create-normalizer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addOrRemoveDependencies, 3 | addOrRemoveQueriesWithArrays, 4 | } from './add-or-remove-dependencies'; 5 | import { defaultConfig } from './default-config'; 6 | import { denormalize } from './denormalize'; 7 | import { getDependenciesDiff } from './get-dependencies-diff'; 8 | import { getId } from './get-id'; 9 | import { 10 | getQueriesDependentOnArrayOperations, 11 | getQueriesDependentOnMutation, 12 | } from './get-queries-dependent-on-mutation'; 13 | import { mergeData } from './merge-data'; 14 | import { normalize } from './normalize'; 15 | import { Data, DataObject, NormalizedData, NormalizerConfig } from './types'; 16 | import { warning } from './warning'; 17 | import { 18 | getArrayOperationsToApply, 19 | applyArrayOperations, 20 | } from './array-transformations'; 21 | 22 | const initialData: NormalizedData = { 23 | queries: {}, 24 | objects: {}, 25 | dependentQueries: {}, 26 | queriesWithArrays: {}, 27 | }; 28 | 29 | const isMutationObjectDifferent = ( 30 | mutationData: Data, 31 | normalizedData: Data, 32 | ): boolean => { 33 | if (Array.isArray(mutationData) && Array.isArray(normalizedData)) { 34 | if (mutationData.length !== normalizedData.length) { 35 | return true; 36 | } 37 | 38 | return mutationData.some((v, i) => 39 | isMutationObjectDifferent(v, (normalizedData as Data[])[i]), 40 | ); 41 | } 42 | 43 | if (mutationData instanceof Date && normalizedData instanceof Date) { 44 | return mutationData.getTime() !== normalizedData.getTime(); 45 | } 46 | 47 | if ( 48 | mutationData !== null && 49 | typeof mutationData === 'object' && 50 | normalizedData !== null && 51 | typeof normalizedData === 'object' 52 | ) { 53 | return Object.entries(mutationData).some( 54 | ([key, value]) => 55 | (normalizedData as DataObject)?.[key] !== undefined && 56 | isMutationObjectDifferent( 57 | value as Data, 58 | (normalizedData as DataObject)[key], 59 | ), 60 | ); 61 | } 62 | 63 | return mutationData !== normalizedData; 64 | }; 65 | 66 | export const createNormalizer = ( 67 | normalizerConfig?: NormalizerConfig, 68 | initialNormalizedData?: NormalizedData, 69 | ) => { 70 | const config = { ...defaultConfig, ...normalizerConfig }; 71 | 72 | let normalizedData: NormalizedData = initialNormalizedData ?? initialData; 73 | let currentDataReferences: Record = {}; 74 | 75 | const setQuery = (queryKey: string, queryData: Data) => { 76 | if (config.structuralSharing) { 77 | if (currentDataReferences[queryKey] === queryData) { 78 | return; 79 | } 80 | 81 | currentDataReferences[queryKey] = queryData; 82 | } 83 | 84 | const [normalizedQueryData, normalizedObjectsData, usedKeys, arrayTypes] = 85 | normalize(queryData, queryKey, config); 86 | 87 | const { addedDependencies, removedDependencies } = getDependenciesDiff( 88 | normalizedData.queries[queryKey] 89 | ? normalizedData.queries[queryKey].dependencies 90 | : [], 91 | Object.keys(normalizedObjectsData), 92 | ); 93 | 94 | const { 95 | addedDependencies: addedArrayTypes, 96 | removedDependencies: removedArrayTypes, 97 | } = getDependenciesDiff( 98 | normalizedData.queries[queryKey] 99 | ? normalizedData.queries[queryKey].arrayTypes 100 | : [], 101 | arrayTypes, 102 | ); 103 | 104 | normalizedData = { 105 | queries: { 106 | ...normalizedData.queries, 107 | [queryKey]: { 108 | data: normalizedQueryData, 109 | usedKeys, 110 | arrayTypes, 111 | dependencies: Object.keys(normalizedObjectsData), 112 | }, 113 | }, 114 | ...addOrRemoveDependencies( 115 | normalizedData.dependentQueries, 116 | mergeData(normalizedData.objects, normalizedObjectsData), 117 | queryKey, 118 | addedDependencies, 119 | removedDependencies, 120 | ), 121 | ...addOrRemoveQueriesWithArrays( 122 | normalizedData.queriesWithArrays, 123 | queryKey, 124 | addedArrayTypes, 125 | removedArrayTypes, 126 | ), 127 | }; 128 | 129 | warning( 130 | config.devLogging, 131 | 'set query:', 132 | queryKey, 133 | '\nwith data:', 134 | queryData, 135 | '\nnormalizedData:', 136 | normalizedData, 137 | ); 138 | }; 139 | 140 | const removeQuery = (queryKey: string) => { 141 | setQuery(queryKey, null); 142 | 143 | const queries = { ...normalizedData.queries }; 144 | delete queries[queryKey]; 145 | delete currentDataReferences[queryKey]; 146 | 147 | normalizedData = { 148 | ...normalizedData, 149 | queries, 150 | }; 151 | 152 | warning( 153 | config.devLogging, 154 | 'removed query:', 155 | queryKey, 156 | '\nnormalizedData:', 157 | normalizedData, 158 | ); 159 | }; 160 | 161 | const filterMutationObjects = ( 162 | mutationObjects: DataObject, 163 | normalizedDataObjects: DataObject, 164 | ) => { 165 | const differentObjects: DataObject = {}; 166 | 167 | for (const key in mutationObjects) { 168 | if ( 169 | isMutationObjectDifferent( 170 | mutationObjects[key], 171 | normalizedDataObjects[key], 172 | ) 173 | ) { 174 | differentObjects[key] = mutationObjects[key]; 175 | } 176 | } 177 | 178 | return differentObjects; 179 | }; 180 | 181 | const getDependentQueries = (mutationData: Data) => { 182 | const [, normalizedObjectsData] = normalize(mutationData, '', config); 183 | 184 | return getQueriesDependentOnMutation( 185 | normalizedData.dependentQueries, 186 | Object.keys(normalizedObjectsData), 187 | ); 188 | }; 189 | 190 | const getDependentQueriesByIds = (ids: ReadonlyArray) => 191 | getQueriesDependentOnMutation( 192 | normalizedData.dependentQueries, 193 | ids.map(getId), 194 | ); 195 | 196 | const getQueryFragment = ( 197 | fragment: Data, 198 | exampleObject?: T, 199 | ): T | undefined => { 200 | let usedKeys = {}; 201 | 202 | if (exampleObject) { 203 | const [, , keys] = normalize(exampleObject, '', config); 204 | usedKeys = keys; 205 | } 206 | 207 | try { 208 | const response = denormalize(fragment, normalizedData.objects, usedKeys); 209 | return response as T; 210 | } catch (error) { 211 | if (error instanceof RangeError) { 212 | warning( 213 | true, 214 | 'Recursive dependency detected. Pass example object as second argument.', 215 | ); 216 | 217 | return undefined; 218 | } 219 | 220 | throw error; 221 | } 222 | }; 223 | 224 | const getObjectById = ( 225 | id: string, 226 | exampleObject?: T, 227 | ): T | undefined => getQueryFragment(`@@${id}`, exampleObject); 228 | 229 | const getCurrentData = (newData: T): T | undefined => { 230 | const [fragment] = normalize(newData, '', config); 231 | 232 | return getQueryFragment(fragment, newData); 233 | }; 234 | 235 | const getQueriesToUpdate = (mutationData: Data) => { 236 | const [, normalizedObjectsData] = normalize(mutationData, '', config); 237 | 238 | const updatedObjects = filterMutationObjects( 239 | normalizedObjectsData, 240 | normalizedData.objects, 241 | ); 242 | 243 | const normalizedDataWithMutation = mergeData( 244 | normalizedData.objects, 245 | updatedObjects, 246 | ); 247 | 248 | const arrayOperations = getArrayOperationsToApply(mutationData, config); 249 | const arrayOperationTypes = Array.from( 250 | new Set(arrayOperations.map(operation => operation.arrayType)), 251 | ); 252 | 253 | const foundQueriesWithArrayOperations = 254 | getQueriesDependentOnArrayOperations( 255 | normalizedData.queriesWithArrays, 256 | arrayOperationTypes, 257 | ); 258 | 259 | const foundQueries = getQueriesDependentOnMutation( 260 | normalizedData.dependentQueries, 261 | Object.keys(updatedObjects), 262 | ); 263 | 264 | const queriesWithOnlyMutation = foundQueries.filter( 265 | query => !foundQueriesWithArrayOperations.includes(query), 266 | ); 267 | 268 | return [ 269 | ...queriesWithOnlyMutation.map(queryKey => ({ 270 | queryKey, 271 | data: denormalize( 272 | normalizedData.queries[queryKey].data, 273 | normalizedDataWithMutation, 274 | normalizedData.queries[queryKey].usedKeys, 275 | ), 276 | })), 277 | ...foundQueriesWithArrayOperations.map(queryKey => ({ 278 | queryKey, 279 | data: applyArrayOperations( 280 | denormalize( 281 | normalizedData.queries[queryKey].data, 282 | normalizedDataWithMutation, 283 | normalizedData.queries[queryKey].usedKeys, 284 | ), 285 | queryKey, 286 | arrayOperations, 287 | config, 288 | getObjectById, 289 | ), 290 | })), 291 | ]; 292 | }; 293 | 294 | return { 295 | getNormalizedData: () => normalizedData, 296 | clearNormalizedData: () => { 297 | normalizedData = initialData; 298 | currentDataReferences = {}; 299 | }, 300 | setQuery, 301 | removeQuery, 302 | getQueriesToUpdate, 303 | getObjectById, 304 | getQueryFragment, 305 | getDependentQueries, 306 | getDependentQueriesByIds, 307 | getCurrentData, 308 | log: (...messages: unknown[]) => { 309 | warning(config.devLogging, ...messages); 310 | }, 311 | }; 312 | }; 313 | -------------------------------------------------------------------------------- /packages/normy/src/array-transformations.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArrayOperation, 3 | ArrayOperations, 4 | Data, 5 | DataObject, 6 | NormalizerConfig, 7 | } from './types'; 8 | import { warning } from './warning'; 9 | 10 | function isStringArray(arr: unknown[]): arr is string[] { 11 | return arr.every(item => typeof item === 'string'); 12 | } 13 | 14 | function isObject(item: unknown): item is { 15 | arrayTypes: string | ReadonlyArray; 16 | [key: string]: unknown; 17 | } { 18 | return ( 19 | typeof item === 'object' && 20 | item !== null && 21 | 'arrayTypes' in item && 22 | (typeof item.arrayTypes === 'string' || Array.isArray(item.arrayTypes)) 23 | ); 24 | } 25 | 26 | function isObjectArray( 27 | arr: unknown[], 28 | ): arr is { arrayType: string; [key: string]: unknown }[] { 29 | return arr.every( 30 | item => 31 | typeof item === 'object' && 32 | item !== null && 33 | 'arrayType' in item && 34 | typeof item.arrayType === 'string', 35 | ); 36 | } 37 | 38 | const filterExcessiveProps = ( 39 | operationNode: DataObject, 40 | template: DataObject, 41 | ): DataObject => { 42 | const filteredNode: DataObject = {}; 43 | 44 | Object.keys(template).forEach(key => { 45 | if (key in operationNode) { 46 | filteredNode[key] = operationNode[key]; 47 | } 48 | }); 49 | 50 | return filteredNode; 51 | }; 52 | 53 | const validateArrayStructureConsistency = ( 54 | node: DataObject, 55 | templateArray: ReadonlyArray, 56 | operationType: string, 57 | arrayType: string, 58 | ): void => { 59 | if (templateArray.length === 0 || !node) { 60 | return; 61 | } 62 | 63 | const templateKeys = Object.keys(templateArray[0]); 64 | const nodeKeys = Object.keys(node); 65 | const missingKeys = templateKeys.filter(key => !nodeKeys.includes(key)); 66 | 67 | warning( 68 | missingKeys.length > 0, 69 | `Array item for ${operationType} operation on array type "${arrayType}" is missing required properties: ${missingKeys.join( 70 | ', ', 71 | )}. ` + 72 | `All array items must have consistent structure for proper denormalization. ` + 73 | `Expected properties: ${templateKeys.join(', ')}, got: ${nodeKeys.join( 74 | ', ', 75 | )}`, 76 | ); 77 | }; 78 | 79 | const arrayOperations: ArrayOperations = { 80 | __insert: ({ array, operation, config, getObjectById }) => { 81 | if ( 82 | operation.props?.index === undefined || 83 | typeof operation.props.index !== 'number' 84 | ) { 85 | warning(true, 'Index is required for insert operation.'); 86 | return array; 87 | } 88 | 89 | let insertIndex = operation.props.index; 90 | 91 | if (insertIndex < 0) { 92 | insertIndex = array.length + insertIndex + 1; 93 | 94 | if (insertIndex < 0) { 95 | insertIndex = 0; 96 | } 97 | } else if (insertIndex > array.length) { 98 | insertIndex = array.length; 99 | } 100 | 101 | const objectKey = config.getNormalizationObjectKey( 102 | operation.node, 103 | ) as string; 104 | 105 | const existingIndex = array.findIndex(item => { 106 | if (typeof item !== 'object' || item === null) { 107 | return false; 108 | } 109 | 110 | return config.getNormalizationObjectKey(item) === objectKey; 111 | }); 112 | 113 | if (existingIndex !== -1) { 114 | warning( 115 | true, 116 | `Item with key ${objectKey} already exists in array at index ${existingIndex}.`, 117 | ); 118 | return array; 119 | } 120 | 121 | const node = 122 | array.length === 0 123 | ? operation.node 124 | : { 125 | ...filterExcessiveProps(operation.node, array[0]), 126 | ...getObjectById(objectKey, array[0]), 127 | }; 128 | 129 | validateArrayStructureConsistency( 130 | node, 131 | array, 132 | operation.type, 133 | operation.arrayType, 134 | ); 135 | 136 | return [...array.slice(0, insertIndex), node, ...array.slice(insertIndex)]; 137 | }, 138 | __append: ({ array, operation, config, getObjectById }) => 139 | arrayOperations.__insert({ 140 | array, 141 | operation: { 142 | ...operation, 143 | props: { ...operation.props, index: array.length }, 144 | }, 145 | config, 146 | getObjectById, 147 | }), 148 | __prepend: ({ array, operation, config, getObjectById }) => 149 | arrayOperations.__insert({ 150 | array, 151 | operation: { 152 | ...operation, 153 | props: { ...operation.props, index: 0 }, 154 | }, 155 | config, 156 | getObjectById, 157 | }), 158 | __remove: ({ array, operation, config }) => { 159 | const objectKey = config.getNormalizationObjectKey(operation.node); 160 | 161 | return array.filter(item => { 162 | if (typeof item !== 'object' || item === null) { 163 | return true; 164 | } 165 | 166 | return config.getNormalizationObjectKey(item) !== objectKey; 167 | }); 168 | }, 169 | __clear: () => [], 170 | __replaceAll: ({ array, operation }) => { 171 | if (!Array.isArray(operation.props?.value)) { 172 | warning(true, 'Value is required for replaceAll operation.'); 173 | return array; 174 | } 175 | 176 | return operation.props.value as ReadonlyArray; 177 | }, 178 | __replace: ({ array, operation, config, getObjectById }) => { 179 | if (typeof operation.props?.index !== 'number') { 180 | warning(true, 'Index is required for replace operation.'); 181 | return array; 182 | } 183 | 184 | const indexToRemove = operation.props.index; 185 | 186 | return arrayOperations.__insert({ 187 | array: array.filter((_, index) => index !== indexToRemove), 188 | operation, 189 | config, 190 | getObjectById, 191 | }); 192 | }, 193 | __move: ({ array, operation, config }) => { 194 | if (typeof operation.props?.toIndex !== 'number') { 195 | warning(true, 'toIndex is required for move operation.'); 196 | return array; 197 | } 198 | 199 | const objectKey = config.getNormalizationObjectKey(operation.node); 200 | const toIndex = operation.props.toIndex; 201 | 202 | const fromIndex = array.findIndex(item => { 203 | if (typeof item !== 'object' || item === null) { 204 | return false; 205 | } 206 | 207 | return config.getNormalizationObjectKey(item) === objectKey; 208 | }); 209 | 210 | if (fromIndex === -1) { 211 | warning( 212 | true, 213 | `Item with key ${objectKey} not found in array for move operation.`, 214 | ); 215 | return array; 216 | } 217 | 218 | if (toIndex < 0 || toIndex >= array.length) { 219 | warning( 220 | true, 221 | `toIndex ${toIndex} is out of bounds for array of length ${array.length}.`, 222 | ); 223 | return array; 224 | } 225 | 226 | if (fromIndex === toIndex) { 227 | return array; 228 | } 229 | 230 | const newArray = [...array]; 231 | const [movedItem] = newArray.splice(fromIndex, 1); 232 | newArray.splice(toIndex, 0, movedItem); 233 | 234 | return newArray; 235 | }, 236 | __swap: ({ array, operation, config }) => { 237 | if (typeof operation.props?.toIndex !== 'number') { 238 | warning(true, 'toIndex is required for swap operation.'); 239 | return array; 240 | } 241 | 242 | const objectKey = config.getNormalizationObjectKey(operation.node); 243 | const toIndex = operation.props.toIndex; 244 | 245 | const fromIndex = array.findIndex(item => { 246 | if (typeof item !== 'object' || item === null) { 247 | return false; 248 | } 249 | 250 | return config.getNormalizationObjectKey(item) === objectKey; 251 | }); 252 | 253 | if (fromIndex === -1) { 254 | warning( 255 | true, 256 | `Item with key ${objectKey} not found in array for swap operation.`, 257 | ); 258 | return array; 259 | } 260 | 261 | if (toIndex < 0 || toIndex >= array.length) { 262 | warning( 263 | true, 264 | `toIndex ${toIndex} is out of bounds for array of length ${array.length}.`, 265 | ); 266 | return array; 267 | } 268 | 269 | if (fromIndex === toIndex) { 270 | return array; 271 | } 272 | 273 | const newArray = [...array]; 274 | [newArray[fromIndex], newArray[toIndex]] = [ 275 | newArray[toIndex], 276 | newArray[fromIndex], 277 | ]; 278 | 279 | return newArray; 280 | }, 281 | }; 282 | 283 | const convertUserArrayOperation = ( 284 | name: string, 285 | operation: unknown, 286 | node: DataObject, 287 | config: Required, 288 | ): ReadonlyArray => { 289 | const allArrayOperations = { 290 | ...arrayOperations, 291 | ...config.customArrayOperations, 292 | }; 293 | 294 | const filteredNode = { ...node }; 295 | 296 | Object.keys(allArrayOperations).forEach(key => { 297 | delete filteredNode[key]; 298 | }); 299 | 300 | if (typeof operation === 'string') { 301 | return [ 302 | { 303 | arrayType: operation, 304 | type: name, 305 | node: filteredNode, 306 | }, 307 | ]; 308 | } 309 | 310 | if (Array.isArray(operation) && isStringArray(operation)) { 311 | return operation.map(item => ({ 312 | arrayType: item, 313 | type: name, 314 | node: filteredNode, 315 | })); 316 | } 317 | 318 | if (Array.isArray(operation) && isObjectArray(operation)) { 319 | return operation.map(item => { 320 | const { arrayType, ...props } = item; 321 | 322 | return { 323 | arrayType, 324 | type: name, 325 | node: filteredNode, 326 | props, 327 | }; 328 | }); 329 | } 330 | 331 | if (!isObject(operation)) { 332 | return []; 333 | } 334 | 335 | const { arrayTypes, ...props } = operation; 336 | 337 | if (typeof arrayTypes === 'string') { 338 | return [ 339 | { 340 | arrayType: arrayTypes, 341 | type: name, 342 | node: filteredNode, 343 | props, 344 | }, 345 | ]; 346 | } 347 | 348 | return arrayTypes.map(arrayType => ({ 349 | arrayType, 350 | type: name, 351 | node: filteredNode, 352 | props, 353 | })); 354 | }; 355 | 356 | export const getArrayOperationsToApply = ( 357 | mutationData: Data, 358 | config: Required, 359 | ): ReadonlyArray => { 360 | const operations: ArrayOperation[] = []; 361 | 362 | const processData = (data: Data): void => { 363 | if (Array.isArray(data)) { 364 | data.forEach(item => processData(item)); 365 | return; 366 | } 367 | 368 | if (data !== null && typeof data === 'object' && !(data instanceof Date)) { 369 | Object.keys({ 370 | ...arrayOperations, 371 | ...config.customArrayOperations, 372 | }).forEach(key => { 373 | if (key in data) { 374 | const operation = data[key]; 375 | 376 | convertUserArrayOperation(key, operation, data, config).forEach( 377 | op => { 378 | operations.push(op); 379 | }, 380 | ); 381 | } 382 | }); 383 | 384 | Object.values(data).forEach(value => processData(value)); 385 | } 386 | }; 387 | 388 | processData(mutationData); 389 | 390 | return operations; 391 | }; 392 | 393 | const getUpdatedArray = ( 394 | data: ReadonlyArray, 395 | operation: ArrayOperation, 396 | config: Required, 397 | getObjectById: ( 398 | id: string, 399 | exampleObject?: DataObject, 400 | ) => DataObject | undefined, 401 | ) => { 402 | const allArrayOperations = { 403 | ...arrayOperations, 404 | ...config.customArrayOperations, 405 | }; 406 | 407 | if (allArrayOperations[operation.type]) { 408 | return allArrayOperations[operation.type]({ 409 | array: data, 410 | operation, 411 | config, 412 | getObjectById, 413 | }); 414 | } 415 | 416 | return data; 417 | }; 418 | 419 | export const applyArrayOperations = ( 420 | data: Data, 421 | queryKey: string, 422 | operations: ReadonlyArray, 423 | config: Required, 424 | getObjectById: ( 425 | id: string, 426 | exampleObject?: DataObject, 427 | ) => DataObject | undefined, 428 | parentObj?: DataObject, 429 | parentObjKey?: string, 430 | ): Data => { 431 | if (Array.isArray(data)) { 432 | const arrayType = config.getArrayType?.({ 433 | array: data as DataObject[], 434 | parentObj, 435 | arrayKey: parentObjKey, 436 | queryKey, 437 | }); 438 | 439 | const operationsForArray = operations.filter( 440 | operation => operation.arrayType === arrayType, 441 | ); 442 | 443 | const updatedData = operationsForArray.reduce( 444 | (prev, operation) => 445 | getUpdatedArray(prev, operation, config, getObjectById) as DataObject[], 446 | data as DataObject[], 447 | ); 448 | 449 | return updatedData.map(item => 450 | applyArrayOperations(item, queryKey, operations, config, getObjectById), 451 | ) as DataObject[]; 452 | } 453 | 454 | if (data !== null && typeof data === 'object' && !(data instanceof Date)) { 455 | return Object.entries(data).reduce((prev, [k, v]) => { 456 | prev[k] = applyArrayOperations( 457 | v, 458 | queryKey, 459 | operations, 460 | config, 461 | getObjectById, 462 | data, 463 | k, 464 | ); 465 | 466 | return prev; 467 | }, {} as DataObject); 468 | } 469 | 470 | return data; 471 | }; 472 | -------------------------------------------------------------------------------- /packages/normy/src/array-helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { createArrayHelpers, arrayHelpers } from './array-helpers'; 2 | 3 | describe('createArrayHelpers', () => { 4 | const testNode = { id: 1, name: 'test' }; 5 | 6 | describe('Base Operations', () => { 7 | test('remove operation', () => { 8 | const result = arrayHelpers.remove(testNode, 'items'); 9 | expect(result).toEqual({ 10 | id: 1, 11 | name: 'test', 12 | __remove: ['items'], 13 | }); 14 | }); 15 | 16 | test('append operation', () => { 17 | const result = arrayHelpers.append(testNode, 'items'); 18 | expect(result).toEqual({ 19 | id: 1, 20 | name: 'test', 21 | __append: ['items'], 22 | }); 23 | }); 24 | 25 | test('prepend operation', () => { 26 | const result = arrayHelpers.prepend(testNode, 'items'); 27 | expect(result).toEqual({ 28 | id: 1, 29 | name: 'test', 30 | __prepend: ['items'], 31 | }); 32 | }); 33 | 34 | test('insert operation', () => { 35 | const result = arrayHelpers.insert(testNode, 'items', { index: 2 }); 36 | expect(result).toEqual({ 37 | id: 1, 38 | name: 'test', 39 | __insert: [{ arrayType: 'items', index: 2 }], 40 | }); 41 | }); 42 | 43 | test('replace operation', () => { 44 | const result = arrayHelpers.replace(testNode, 'items', { index: 1 }); 45 | expect(result).toEqual({ 46 | id: 1, 47 | name: 'test', 48 | __replace: [{ arrayType: 'items', index: 1 }], 49 | }); 50 | }); 51 | 52 | test('move operation', () => { 53 | const result = arrayHelpers.move(testNode, 'items', { toIndex: 3 }); 54 | expect(result).toEqual({ 55 | id: 1, 56 | name: 'test', 57 | __move: [{ arrayType: 'items', toIndex: 3 }], 58 | }); 59 | }); 60 | 61 | test('swap operation', () => { 62 | const result = arrayHelpers.swap(testNode, 'items', { toIndex: 2 }); 63 | expect(result).toEqual({ 64 | id: 1, 65 | name: 'test', 66 | __swap: [{ arrayType: 'items', toIndex: 2 }], 67 | }); 68 | }); 69 | 70 | test('clear operation', () => { 71 | const result = arrayHelpers.clear('items'); 72 | expect(result).toEqual({ 73 | __clear: 'items', 74 | }); 75 | }); 76 | 77 | test('replaceAll operation', () => { 78 | const newItems = [{ id: 1 }, { id: 2 }]; 79 | const result = arrayHelpers.replaceAll('items', { value: newItems }); 80 | expect(result).toEqual({ 81 | __replaceAll: { arrayTypes: 'items', value: newItems }, 82 | }); 83 | }); 84 | }); 85 | 86 | describe('Base Operations - chaining', () => { 87 | test('remove operation', () => { 88 | expect(arrayHelpers.chain(testNode).remove('items').apply()).toEqual({ 89 | id: 1, 90 | name: 'test', 91 | __remove: ['items'], 92 | }); 93 | 94 | expect( 95 | arrayHelpers.chain(testNode).remove('items').remove('items2').apply(), 96 | ).toEqual({ 97 | id: 1, 98 | name: 'test', 99 | __remove: ['items', 'items2'], 100 | }); 101 | }); 102 | 103 | test('append operation', () => { 104 | expect(arrayHelpers.chain(testNode).append('items').apply()).toEqual({ 105 | id: 1, 106 | name: 'test', 107 | __append: ['items'], 108 | }); 109 | 110 | expect( 111 | arrayHelpers.chain(testNode).append('items').append('items2').apply(), 112 | ).toEqual({ 113 | id: 1, 114 | name: 'test', 115 | __append: ['items', 'items2'], 116 | }); 117 | }); 118 | 119 | test('prepend operation', () => { 120 | expect(arrayHelpers.chain(testNode).prepend('items').apply()).toEqual({ 121 | id: 1, 122 | name: 'test', 123 | __prepend: ['items'], 124 | }); 125 | 126 | expect( 127 | arrayHelpers.chain(testNode).prepend('items').prepend('items2').apply(), 128 | ).toEqual({ 129 | id: 1, 130 | name: 'test', 131 | __prepend: ['items', 'items2'], 132 | }); 133 | }); 134 | 135 | test('insert operation', () => { 136 | expect( 137 | arrayHelpers.chain(testNode).insert('items', { index: 1 }).apply(), 138 | ).toEqual({ 139 | id: 1, 140 | name: 'test', 141 | __insert: [{ arrayType: 'items', index: 1 }], 142 | }); 143 | 144 | expect( 145 | arrayHelpers 146 | .chain(testNode) 147 | .insert('items', { index: 1 }) 148 | .insert('items2', { index: 2 }) 149 | .apply(), 150 | ).toEqual({ 151 | id: 1, 152 | name: 'test', 153 | __insert: [ 154 | { arrayType: 'items', index: 1 }, 155 | { arrayType: 'items2', index: 2 }, 156 | ], 157 | }); 158 | }); 159 | 160 | test('replace operation', () => { 161 | expect( 162 | arrayHelpers.chain(testNode).replace('items', { index: 1 }).apply(), 163 | ).toEqual({ 164 | id: 1, 165 | name: 'test', 166 | __replace: [{ arrayType: 'items', index: 1 }], 167 | }); 168 | 169 | expect( 170 | arrayHelpers 171 | .chain(testNode) 172 | .replace('items', { index: 1 }) 173 | .replace('items2', { index: 2 }) 174 | .apply(), 175 | ).toEqual({ 176 | id: 1, 177 | name: 'test', 178 | __replace: [ 179 | { arrayType: 'items', index: 1 }, 180 | { arrayType: 'items2', index: 2 }, 181 | ], 182 | }); 183 | }); 184 | 185 | test('move operation', () => { 186 | expect( 187 | arrayHelpers.chain(testNode).move('items', { toIndex: 3 }).apply(), 188 | ).toEqual({ 189 | id: 1, 190 | name: 'test', 191 | __move: [{ arrayType: 'items', toIndex: 3 }], 192 | }); 193 | 194 | expect( 195 | arrayHelpers 196 | .chain(testNode) 197 | .move('items', { toIndex: 1 }) 198 | .move('items2', { toIndex: 2 }) 199 | .apply(), 200 | ).toEqual({ 201 | id: 1, 202 | name: 'test', 203 | __move: [ 204 | { arrayType: 'items', toIndex: 1 }, 205 | { arrayType: 'items2', toIndex: 2 }, 206 | ], 207 | }); 208 | }); 209 | 210 | test('swap operation', () => { 211 | expect( 212 | arrayHelpers.chain(testNode).swap('items', { toIndex: 3 }).apply(), 213 | ).toEqual({ 214 | id: 1, 215 | name: 'test', 216 | __swap: [{ arrayType: 'items', toIndex: 3 }], 217 | }); 218 | 219 | expect( 220 | arrayHelpers 221 | .chain(testNode) 222 | .swap('items', { toIndex: 1 }) 223 | .swap('items2', { toIndex: 2 }) 224 | .apply(), 225 | ).toEqual({ 226 | id: 1, 227 | name: 'test', 228 | __swap: [ 229 | { arrayType: 'items', toIndex: 1 }, 230 | { arrayType: 'items2', toIndex: 2 }, 231 | ], 232 | }); 233 | }); 234 | 235 | test('clear operation should not be chainable', () => { 236 | const chain = arrayHelpers.chain({ id: 1, name: 'test' }); 237 | expect('clear' in chain).toBe(false); 238 | }); 239 | 240 | test('replaceAll operation should not be chainable', () => { 241 | const chain = arrayHelpers.chain({ id: 1, name: 'test' }); 242 | expect('replaceAll' in chain).toBe(false); 243 | }); 244 | }); 245 | 246 | describe('Custom Operations', () => { 247 | const helpers = createArrayHelpers({ 248 | nodelessOperations: { 249 | sort: (arrayType: string) => ({ 250 | __sort: arrayType, 251 | }), 252 | replaceWith: (arrayType: string, value: unknown) => ({ 253 | __replaceWith: arrayType, 254 | value, 255 | }), 256 | }, 257 | nodeOperations: { 258 | inject: >( 259 | nodeParam: N, 260 | arrayType: string, 261 | config: { index: number }, 262 | ): N => ({ 263 | ...nodeParam, 264 | __inject: { arrayType, index: config.index }, 265 | }), 266 | reverse: >( 267 | nodeParam: N, 268 | arrayType: string, 269 | ): N => ({ 270 | ...nodeParam, 271 | __reverse: arrayType, 272 | }), 273 | }, 274 | }); 275 | 276 | test('sort custom operation (nodeless)', () => { 277 | const result = helpers.sort('items'); 278 | expect(result).toEqual({ 279 | __sort: 'items', 280 | }); 281 | }); 282 | 283 | test('inject custom operation (chainable)', () => { 284 | const result = helpers.inject(testNode, 'items', { index: 5 }); 285 | expect(result).toEqual({ 286 | id: 1, 287 | name: 'test', 288 | __inject: { arrayType: 'items', index: 5 }, 289 | }); 290 | }); 291 | 292 | test('reverse custom operation (chainable)', () => { 293 | const result = helpers.reverse(testNode, 'items'); 294 | expect(result).toEqual({ 295 | id: 1, 296 | name: 'test', 297 | __reverse: 'items', 298 | }); 299 | }); 300 | 301 | test('replaceWith custom operation (nodeless)', () => { 302 | const result = helpers.replaceWith('items', [1, 2, 3]); 303 | expect(result).toEqual({ 304 | __replaceWith: 'items', 305 | value: [1, 2, 3], 306 | }); 307 | }); 308 | 309 | test('includes all base operations', () => { 310 | expect(typeof helpers.remove).toBe('function'); 311 | expect(typeof helpers.append).toBe('function'); 312 | expect(typeof helpers.prepend).toBe('function'); 313 | expect(typeof helpers.insert).toBe('function'); 314 | expect(typeof helpers.replace).toBe('function'); 315 | expect(typeof helpers.move).toBe('function'); 316 | expect(typeof helpers.swap).toBe('function'); 317 | expect(typeof helpers.clear).toBe('function'); 318 | expect(typeof helpers.replaceAll).toBe('function'); 319 | }); 320 | }); 321 | 322 | describe('Chain Operations - Base Only', () => { 323 | const baseHelpers = createArrayHelpers(); 324 | 325 | test('single operation chain', () => { 326 | const result = baseHelpers.chain(testNode).append('items').apply(); 327 | 328 | expect(result).toEqual({ 329 | id: 1, 330 | name: 'test', 331 | __append: ['items'], 332 | }); 333 | }); 334 | 335 | test('multiple operations chain', () => { 336 | const result = baseHelpers 337 | .chain(testNode) 338 | .append('items') 339 | .remove('oldItems') 340 | .remove('oldItems2') 341 | .insert('newItems', { index: 2 }) 342 | .apply(); 343 | 344 | expect(result).toEqual({ 345 | id: 1, 346 | name: 'test', 347 | __append: ['items'], 348 | __remove: ['oldItems', 'oldItems2'], 349 | __insert: [{ arrayType: 'newItems', index: 2 }], 350 | }); 351 | }); 352 | 353 | test('operations with configs', () => { 354 | const result = baseHelpers 355 | .chain(testNode) 356 | .insert('items', { index: 1 }) 357 | .replace('items', { index: 2 }) 358 | .move('items', { toIndex: 3 }) 359 | .swap('items', { toIndex: 4 }) 360 | .apply(); 361 | 362 | expect(result).toEqual({ 363 | id: 1, 364 | name: 'test', 365 | __insert: [{ arrayType: 'items', index: 1 }], 366 | __replace: [{ arrayType: 'items', index: 2 }], 367 | __move: [{ arrayType: 'items', toIndex: 3 }], 368 | __swap: [{ arrayType: 'items', toIndex: 4 }], 369 | }); 370 | }); 371 | 372 | test('chain does NOT include clear and replaceAll (they are standalone)', () => { 373 | const chain = baseHelpers.chain(testNode); 374 | expect('clear' in chain).toBe(false); 375 | expect('replaceAll' in chain).toBe(false); 376 | }); 377 | }); 378 | 379 | describe('Chain Operations - With Custom Operations', () => { 380 | const chainHelpers = createArrayHelpers({ 381 | nodelessOperations: { 382 | reverse: (nodeParam: Record, arrayType: string) => ({ 383 | ...nodeParam, 384 | __reverse: arrayType, 385 | }), 386 | }, 387 | nodeOperations: { 388 | inject: ( 389 | nodeParam: Record, 390 | arrayType: string, 391 | config: { index: number }, 392 | ) => ({ 393 | ...nodeParam, 394 | __inject: { arrayType, index: config.index }, 395 | }), 396 | }, 397 | }); 398 | 399 | test('chain with custom operations', () => { 400 | const result = chainHelpers 401 | .chain(testNode) 402 | .append('items') 403 | .inject('items', { index: 3 }) 404 | .apply(); 405 | 406 | expect(result).toEqual({ 407 | id: 1, 408 | name: 'test', 409 | __append: ['items'], 410 | __inject: { arrayType: 'items', index: 3 }, 411 | }); 412 | }); 413 | 414 | test('complex chain mixing base and custom operations', () => { 415 | const result = chainHelpers 416 | .chain(testNode) 417 | .prepend('newItems') 418 | .insert('middleItems', { index: 1 }) 419 | .replace('items', { index: 0 }) 420 | .inject('items', { index: 2 }) 421 | .apply(); 422 | 423 | expect(result).toEqual({ 424 | id: 1, 425 | name: 'test', 426 | __prepend: ['newItems'], 427 | __insert: [{ arrayType: 'middleItems', index: 1 }], 428 | __replace: [{ arrayType: 'items', index: 0 }], 429 | __inject: { arrayType: 'items', index: 2 }, 430 | }); 431 | }); 432 | 433 | test('custom operations requiring configs work in chain', () => { 434 | const result = chainHelpers 435 | .chain(testNode) 436 | .inject('items', { index: 5 }) 437 | .apply(); 438 | 439 | expect(result).toEqual({ 440 | id: 1, 441 | name: 'test', 442 | __inject: { arrayType: 'items', index: 5 }, 443 | }); 444 | }); 445 | }); 446 | }); 447 | -------------------------------------------------------------------------------- /packages/normy-rtk-query/README.md: -------------------------------------------------------------------------------- 1 | # @normy/rtk-query 2 | 3 | [![npm version](https://badge.fury.io/js/%40normy%2Frtk-query.svg)](https://badge.fury.io/js/%40normy%2Frtk-query) 4 | [![gzip size](https://img.badgesize.io/https://unpkg.com/@normy/rtk-query/dist/normy-rtk-query.min.js?compression=gzip)](https://unpkg.com/@normy/rtk-query) 5 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/klis87/normy/ci.yml?branch=master)](https://github.com/klis87/normy/actions) 6 | [![Coverage Status](https://coveralls.io/repos/github/klis87/normy/badge.svg?branch=master)](https://coveralls.io/github/klis87/normy?branch=master) 7 | [![lerna](https://img.shields.io/badge/maintained%20with-lerna-cc00ff.svg)](https://lernajs.io/) 8 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) 9 | 10 | `rtk-query` integration with `normy` - automatic normalization and data updates for data fetching libraries 11 | 12 | ## Table of content 13 | 14 | - [Introduction](#introduction-arrow_up) 15 | - [Motivation](#motivation-arrow_up) 16 | - [Installation](#installation-arrow_up) 17 | - [Basic usage](#basic-usage-arrow_up) 18 | - [Disabling of normalization per query and mutation](#disabling-of-normalization-per-query-and-mutation-arrow_up) 19 | - [getNormalizer and manual updates](#getNormalizer-and-manual-updates-arrow_up) 20 | - [Optimistic updates](#optimistic-updates-arrow_up) 21 | - [getObjectById and getQueryFragment](#getObjectById-and-getQueryFragment-arrow_up) 22 | - [Garbage collection](#garbage-collection-arrow_up) 23 | - [Examples](#examples-arrow_up) 24 | 25 | ## Introduction [:arrow_up:](#table-of-content) 26 | 27 | This is the official `rtk-query` integration with `normy`, a library, which allows your application data to be normalized automatically. This documentation will cover only `rtk-query` specifics, so if you did not already do that, you can 28 | find `normy` documentation [here](https://github.com/klis87/normy/tree/master). 29 | 30 | ## Motivation [:arrow_up:](#table-of-content) 31 | 32 | In order to understand what `@normy/rtk-query` actually does, it is the best to see an example: 33 | 34 | ```diff 35 | import React from 'react'; 36 | import { createApi } from '@reduxjs/toolkit/query/react'; 37 | + import { createNormalizationMiddleware } from '@normy/rtk-query'; 38 | 39 | const api = createApi({ 40 | reducerPath: 'api', 41 | endpoints: builder => ({ 42 | getBooks: builder.query({ 43 | queryFn: () => ({ 44 | data: [ 45 | { id: '0', name: 'Name 0', author: null }, 46 | { id: '1', name: 'Name 1', author: { id: '1000', name: 'User1' } }, 47 | { id: '2', name: 'Name 2', author: { id: '1001', name: 'User2' } }, 48 | ], 49 | }), 50 | }), 51 | getBook: builder.query({ 52 | queryFn: () => ({ 53 | data: { 54 | id: '1', 55 | name: 'Name 1', 56 | author: { id: '1000', name: 'User1' }, 57 | }, 58 | }), 59 | }), 60 | updateBook: builder.mutation({ 61 | queryFn: () => ({ 62 | data: { 63 | id: '1', 64 | name: 'Name 1 Updated', 65 | }, 66 | }), 67 | - onQueryStarted: async (_, { dispatch, queryFulfilled }) => { 68 | - const { data: mutationData } = await queryFulfilled; 69 | - 70 | - dispatch( 71 | - api.util.updateQueryData('getBooks', undefined, data => 72 | - data.map(book => 73 | - book.id === mutationData.id ? { ...book, ...mutationData } : book, 74 | - ), 75 | - ), 76 | - ); 77 | - 78 | - dispatch( 79 | - api.util.updateQueryData('getBook', undefined, data => 80 | - data.id === mutationData.id ? { ...data, ...mutationData } : data, 81 | - ), 82 | - ); 83 | - }, 84 | }), 85 | addBook: builder.mutation({ 86 | queryFn: async () => ({ 87 | data: { 88 | id: '3', 89 | name: 'Name 3', 90 | author: { id: '1002', name: 'User3' }, 91 | }, 92 | }), 93 | onQueryStarted: async (_, { dispatch, queryFulfilled }) => { 94 | const { data: mutationData } = await queryFulfilled; 95 | 96 | // with data with top level arrays, you still need to update data manually 97 | dispatch( 98 | api.util.updateQueryData('getBooks', undefined, data => [ 99 | ...data, 100 | mutationData, 101 | ]), 102 | ); 103 | }, 104 | }), 105 | }), 106 | }); 107 | 108 | const store = configureStore({ 109 | reducer: { 110 | [api.reducerPath]: api.reducer, 111 | }, 112 | middleware: getDefaultMiddleware => [ 113 | ...getDefaultMiddleware(), 114 | api.middleware, 115 | + createNormalizationMiddleware(api), 116 | ], 117 | }); 118 | 119 | ``` 120 | 121 | So, as you can see, apart from top level arrays, no manual data updates are necessary anymore. This is especially handy if a given mutation 122 | should update data for multiple queries. Not only this is verbose to do updates manually, but also you need to exactly know, 123 | which queries to update. The more queries you have, the bigger advantages `normy` brings. 124 | 125 | ## Installation [:arrow_up:](#table-of-content) 126 | 127 | To install the package, just run: 128 | 129 | ``` 130 | $ npm install @normy/rtk-query 131 | ``` 132 | 133 | or you can just use CDN: `https://unpkg.com/@normy/rtk-query`. 134 | 135 | You do not need to install `@normy/core`, because it will be installed as `@normy/rtk-query` direct dependency. 136 | 137 | ## Basic usage [:arrow_up:](#table-of-content) 138 | 139 | For the basic usage, see `Motivation` paragraph. The only thing which you need to actually do is to pass `createNormalizationMiddleware` result 140 | to list of Redux middleware. After doing this, you can use `rtk-query` as you normally do, but you don't need to make any data updates 141 | most of the time anymore. 142 | 143 | `createNormalizationMiddleware` accepts two props: 144 | 145 | - `api` - this is just an object returned by `createApi` from `rtk-query`, 146 | - `normalizerConfig` - this is `normy` config, which you might need to meet requirements for data normalization to work - see 147 | [explanation](https://github.com/klis87/normy/tree/master/#required-conditions-arrow_up) for more details. Additionally to `normy` config, you can also pass `normalizeQuery` and `normalizeMutation` options (see the next paragraph) 148 | 149 | ## Disabling of normalization per query and mutation [:arrow_up:](#table-of-content) 150 | 151 | By default all your queries and mutations will be normalized. That means that for each query there will be normalized representation 152 | of its data and for each mutation its response data will be read and all dependent normalized queries will be updated. 153 | 154 | However, it does not always make sense to normalize all data. You might want to disable data normalization, for example for performance reason for some extreme big queries, 155 | or just if you do not need it for a given query, for instance if a query data will be never updated. 156 | 157 | Anyway, you might want to change this globally by passing `normalizeQuery` and `normalizeMutation` options: 158 | 159 | ```js 160 | createNormalizationMiddleware(api, { 161 | normalizeQuery: queryType => queryTypesToNormalizeArray.includes(queryType), 162 | normalizeQuery: mutationEndpointName => 163 | mutationsEndpointsToNormalizeArray.includes(mutationEndpointName), 164 | }); 165 | ``` 166 | 167 | ## getNormalizer and manual updates [:arrow_up:](#table-of-content) 168 | 169 | Sometimes you might need to update your data manually, without having API response. One of examples could be having a websocket event that 170 | an object name has been changed. Now, instead of manually updating all your relevant queries, instead you could do below: 171 | 172 | ```jsx 173 | import { useDispatch } from 'react-redux'; 174 | import { useQueryNormalizer } from '@normy/react-query'; 175 | 176 | const SomeComponent = () => { 177 | const dispatch = useDispatch(); 178 | const normalizer = getNormalizer(dispatch); 179 | 180 | return ( 181 | 188 | ); 189 | }; 190 | ``` 191 | 192 | What it will do is updating normalized store, as well as finding all queries which contain user with `id` equal `'1'` and updating them with `name: 'Updated name'`. You can call `getNormalizer` wherever you have access to `dispatch`, for example like in the next paragraph. 193 | 194 | ## Optimistic updates [:arrow_up:](#table-of-content) 195 | 196 | For normal mutations there is nothing you need to do, `normy` will inspect response data, calculate dependent queries, 197 | update normalized data and update all relevant queries. With optimistic updates though, you need to prepare optimistic data 198 | yourself. You can do it similarly like recommended by `rtk-query` docs, but thanks to `setNormalizedData`, easier: 199 | 200 | ```js 201 | export const api = createApi({ 202 | endpoints: builder => ({ 203 | updateBookOptimistically: builder.mutation({ 204 | queryFn: async () => ({ 205 | data: { 206 | id: '1', 207 | name: 'Name 1 Updated', 208 | }, 209 | }), 210 | onQueryStarted: async (_, { dispatch, queryFulfilled }) => { 211 | const normalizer = getNormalizer(dispatch); 212 | 213 | normalizer.setNormalizedData({ 214 | id: '1', 215 | name: 'Name 1 Updated', 216 | }); 217 | 218 | try { 219 | await queryFulfilled; 220 | } catch { 221 | normalizer.setNormalizedData({ 222 | id: '1', 223 | name: 'Name 1', 224 | }); 225 | } 226 | }, 227 | }), 228 | }), 229 | }); 230 | ``` 231 | 232 | The above code will immediately update all queries which have object with `id: 1` in their data. In case of 233 | a mutation error, data will be reverted as set in `catch` block. 234 | 235 | ## getObjectById and getQueryFragment [:arrow_up:](#table-of-content) 236 | 237 | Sometimes it is useful to get an object from normalized store by id. You do not even need to know in which 238 | query/queries this object could be, all you need is an id. For example, you might want to get it just to display it: 239 | 240 | ```jsx 241 | import { useDispatch } from 'react-redux'; 242 | import { useQueryNormalizer } from '@normy/react-query'; 243 | 244 | const BookDetail = ({ bookId }) => { 245 | const dispatch = useDispatch(); 246 | const normalizer = getNormalizer(dispatch); 247 | const book = normalizer.getObjectById(bookId); 248 | 249 | // 250 | }; 251 | ``` 252 | 253 | In above example, imagine you want to display a component with a book detail. You might already have this book 254 | fetched from a book list query, so you would like to show something to your user even before a detail book query is even fetched. 255 | 256 | ### getObjectById and recursive relationships 257 | 258 | Because `getObjectById` denormalizes an object with an id, you might get some issues with recursive relationships. 259 | Take below object: 260 | 261 | ```js 262 | const user = { 263 | id: '1', 264 | name: 'X', 265 | bestFriend: { 266 | id: '2', 267 | name: 'Y', 268 | bestFriend: { 269 | id: '1', 270 | name: 'X', 271 | }, 272 | }, 273 | }; 274 | ``` 275 | 276 | Typically `normy` saves data structure for each query automatically, so that query normalization and denormalization 277 | gives exactly the same results, even for above case. But `getObjectById` is different, as a given object could be 278 | present in multiple queries, with different attributes. 279 | 280 | With above example, you will end up with infinite recursion error and `getObjectById` will just return `undefined`. 281 | You will also see a warning in the console, to use a second argument for this case, which tells `getObjectById` 282 | what structure is should have, for example: 283 | 284 | ```js 285 | const user = normalizer.getObjectById('1', { 286 | id: '', 287 | name: '', 288 | bestFriend: { id: '', name: '' }, 289 | }); 290 | ``` 291 | 292 | In above case, `user` would be: 293 | 294 | ```js 295 | const user = { 296 | id: '1', 297 | name: 'X', 298 | bestFriend: { 299 | id: '2', 300 | name: 'Y', 301 | }, 302 | }; 303 | ``` 304 | 305 | Notice that 2nd argument - data structure you pass - contains empty strings. Why? Because it does not matter 306 | what primitive values you will use there, only data type is important. 307 | 308 | And now, for typescript users there is a gift - when you provide data structure as 2nd argument, `getObjectById` 309 | response will be properly typed, so in our user example `user` will have type: 310 | 311 | ```ts 312 | type User = { 313 | id: string; 314 | name: string; 315 | bestFriend: { id: string; name: string }; 316 | }; 317 | ``` 318 | 319 | So, passing optional 2nd argument has the following use cases: 320 | 321 | - controlling structure of returned object, for example you might be interested only in `{ id: '', name: '' }` 322 | - preventing infinite recursions for relationships like friends 323 | - having automatic Typescript type 324 | 325 | ### getQueryFragment 326 | 327 | `getQueryFragment` is a more powerful version of `getObjectById`, actually `getObjectById` uses `getQueryFragment` 328 | under the hood. Basically `getQueryFragment` allows you to get multiple objects in any data structure you need, 329 | for example: 330 | 331 | ```js 332 | import { getId } from '@normy/rtk-query'; 333 | tk; 334 | 335 | const users = normalizer.getQueryFragment([getId('1'), getId('2')]); 336 | const usersAndBook = normalizer.getQueryFragment({ 337 | users: [getId('1'), getId('2')], 338 | book: getId('3'), 339 | }); 340 | ``` 341 | 342 | Notice we need to use `getId` helper, which transform `id` you pass into its internal format. 343 | 344 | Anyway. if any object does not exist, it will be `undefined`. For example, assuming user with id `1` exists and `2` does not, 345 | `users` will be: 346 | 347 | ```js 348 | [ 349 | { 350 | id: '1', 351 | name: 'Name 1', 352 | }, 353 | undefined, 354 | ]; 355 | ``` 356 | 357 | Like for `getObjectById`, you can also pass data structure, for example: 358 | 359 | ```js 360 | import { getId } from '@normy/rtk-query'; 361 | 362 | const usersAndBook = normalizer.getQueryFragment( 363 | { users: [getId('1'), getId('2')], book: getId('3') }, 364 | { 365 | users: [{ id: '', name: '' }], 366 | book: { id: '', name: '', author: '' }, 367 | }, 368 | ); 369 | ``` 370 | 371 | Notice that to define an array type, you just need to pass one item, even though we want to have two users. 372 | This is because we care only about data structure. 373 | 374 | ## Garbage collection [:arrow_up:](#table-of-content) 375 | 376 | `normy` know how to clean after itself. When a query is removed from the store, `normy` will do the same, removing all redundant 377 | information. 378 | 379 | ## Examples [:arrow_up:](#table-of-content) 380 | 381 | I highly recommend to try examples how this package could be used in real applications. 382 | 383 | There are following examples currently: 384 | 385 | - [rtk-query](https://github.com/klis87/normy/tree/master/examples/rtk-query) 386 | 387 | ## Licence [:arrow_up:](#table-of-content) 388 | 389 | MIT 390 | --------------------------------------------------------------------------------