├── .eslintignore ├── .prettierignore ├── admin ├── src │ ├── translations │ │ └── en.json │ ├── pluginId.ts │ ├── components │ │ ├── PluginIcon.tsx │ │ ├── collections │ │ │ ├── index.tsx │ │ │ ├── CollectionTableHeader.tsx │ │ │ ├── CardSkeleton.tsx │ │ │ ├── CollectionTable.tsx │ │ │ └── CollectionColumn.tsx │ │ ├── settings │ │ │ ├── index.tsx │ │ │ └── Credentials.tsx │ │ ├── Initializer.tsx │ │ └── tabs.tsx │ ├── utils │ │ ├── getTranslation.ts │ │ └── serverRestartWatcher.ts │ ├── pages │ │ ├── App.tsx │ │ └── HomePage.tsx │ ├── index.ts │ ├── hooks │ │ ├── useAlert.tsx │ │ ├── useCredential.tsx │ │ └── useCollection.tsx │ └── constants.ts ├── custom.d.ts ├── tsconfig.json └── tsconfig.build.json ├── server ├── src │ ├── middlewares │ │ └── index.ts │ ├── policies │ │ ├── index.ts │ │ └── isAdmin.ts │ ├── destroy.ts │ ├── register.ts │ ├── services │ │ ├── upstash-search │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── adapter.ts │ │ │ └── connector.ts │ │ ├── index.ts │ │ ├── error │ │ │ ├── index.ts │ │ │ └── error.ts │ │ ├── lifecycle │ │ │ ├── index.ts │ │ │ └── lifecycle.ts │ │ ├── content-types │ │ │ ├── index.ts │ │ │ └── content-types.ts │ │ └── store │ │ │ ├── store.ts │ │ │ ├── index.ts │ │ │ ├── credential.ts │ │ │ ├── content-type-indexes.ts │ │ │ ├── listened-content-types.ts │ │ │ └── indexed-content-types.ts │ ├── controllers │ │ ├── index.ts │ │ ├── reload.ts │ │ ├── credential.ts │ │ └── content-type.ts │ ├── routes │ │ ├── constants.ts │ │ └── index.ts │ ├── index.ts │ └── bootstrap.ts ├── tsconfig.json └── tsconfig.build.json ├── .prettierrc ├── .editorconfig ├── LICENCE ├── .github └── workflows │ └── release.yaml ├── .gitignore ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | -------------------------------------------------------------------------------- /admin/src/translations/en.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /server/src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /admin/src/pluginId.ts: -------------------------------------------------------------------------------- 1 | export const PLUGIN_ID = 'upstash-search'; 2 | -------------------------------------------------------------------------------- /admin/custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@strapi/design-system/*'; 2 | declare module '@strapi/design-system'; 3 | -------------------------------------------------------------------------------- /server/src/policies/index.ts: -------------------------------------------------------------------------------- 1 | import isAdmin from './isAdmin'; 2 | 3 | export default { 4 | isAdmin, 5 | }; 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "tabWidth": 2, 4 | "printWidth": 100, 5 | "singleQuote": true, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /admin/src/components/PluginIcon.tsx: -------------------------------------------------------------------------------- 1 | import { Search } from '@strapi/icons'; 2 | 3 | const PluginIcon = () => ; 4 | 5 | export { PluginIcon }; 6 | -------------------------------------------------------------------------------- /admin/src/components/collections/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as CollectionTable } from './CollectionTable'; 2 | export { default as CollectionColumn } from './CollectionColumn'; 3 | -------------------------------------------------------------------------------- /admin/src/utils/getTranslation.ts: -------------------------------------------------------------------------------- 1 | import { PLUGIN_ID } from '../pluginId'; 2 | 3 | const getTranslation = (id: string) => `${PLUGIN_ID}.${id}`; 4 | 5 | export { getTranslation }; 6 | -------------------------------------------------------------------------------- /server/src/destroy.ts: -------------------------------------------------------------------------------- 1 | import type { Core } from '@strapi/strapi'; 2 | 3 | const destroy = ({ strapi }: { strapi: Core.Strapi }) => { 4 | // destroy phase 5 | }; 6 | 7 | export default destroy; 8 | -------------------------------------------------------------------------------- /server/src/register.ts: -------------------------------------------------------------------------------- 1 | import type { Core } from '@strapi/strapi'; 2 | 3 | const register = ({ strapi }: { strapi: Core.Strapi }) => { 4 | // register phase 5 | }; 6 | 7 | export default register; 8 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@strapi/typescript-utils/tsconfigs/server", 3 | "include": ["./src"], 4 | "compilerOptions": { 5 | "rootDir": "../", 6 | "baseUrl": "." 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /admin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@strapi/typescript-utils/tsconfigs/admin", 3 | "include": ["./src", "./custom.d.ts"], 4 | "compilerOptions": { 5 | "rootDir": "../", 6 | "baseUrl": "." 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /server/src/policies/isAdmin.ts: -------------------------------------------------------------------------------- 1 | export default policyContext => { 2 | const isAdmin = policyContext?.state?.user?.roles.find( 3 | role => role.code === 'strapi-super-admin', 4 | ) 5 | if (isAdmin) return true 6 | return false 7 | } 8 | -------------------------------------------------------------------------------- /server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "include": ["./src"], 4 | "exclude": ["**/*.test.ts"], 5 | "compilerOptions": { 6 | "rootDir": "../", 7 | "baseUrl": ".", 8 | "outDir": "./dist" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /server/src/services/upstash-search/client.ts: -------------------------------------------------------------------------------- 1 | import { Search } from '@upstash/search'; 2 | 3 | type Config = { 4 | url: string; 5 | token: string; 6 | }; 7 | 8 | export default (config: Config) => { 9 | return new Search({ 10 | ...config, 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /admin/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "include": ["./src", "./custom.d.ts"], 4 | "exclude": ["**/*.test.ts", "**/*.test.tsx"], 5 | "compilerOptions": { 6 | "rootDir": "../", 7 | "baseUrl": ".", 8 | "outDir": "./dist" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /server/src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | import contentTypeController from './content-type'; 2 | import credentialController from './credential'; 3 | import reloadController from './reload'; 4 | 5 | export default { 6 | contentTypeController, 7 | credentialController, 8 | reloadController, 9 | }; 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [{package.json,*.yml}] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /admin/src/components/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react' 2 | import { Box } from '@strapi/design-system' 3 | import Credentials from './Credentials' 4 | 5 | const Settings = () => { 6 | return ( 7 | 8 | 9 | 10 | ) 11 | } 12 | 13 | export { Settings } 14 | -------------------------------------------------------------------------------- /server/src/services/index.ts: -------------------------------------------------------------------------------- 1 | import contentType from './content-types'; 2 | import store from './store'; 3 | import upstashSearch from './upstash-search'; 4 | import lifecycle from './lifecycle'; 5 | import error from './error'; 6 | 7 | export default { 8 | contentType, 9 | store, 10 | upstashSearch, 11 | lifecycle, 12 | error, 13 | }; 14 | -------------------------------------------------------------------------------- /server/src/services/error/index.ts: -------------------------------------------------------------------------------- 1 | import error from './error'; 2 | import type { Core } from '@strapi/strapi'; 3 | 4 | const errorService = ({ strapi }: { strapi: Core.Strapi }) => { 5 | return { 6 | ...error({ strapi }), 7 | }; 8 | }; 9 | 10 | export type ErrorService = ReturnType; 11 | 12 | export default errorService; 13 | -------------------------------------------------------------------------------- /server/src/routes/constants.ts: -------------------------------------------------------------------------------- 1 | const ACTIONS = { 2 | read: 'plugin::upstash-search.read', 3 | settings: 'plugin::upstash-search.settings.edit', 4 | create: 'plugin::upstash-search.collections.create', 5 | update: 'plugin::upstash-search.collections.update', 6 | delete: 'plugin::upstash-search.collections.delete', 7 | }; 8 | 9 | export { ACTIONS }; 10 | -------------------------------------------------------------------------------- /server/src/services/lifecycle/index.ts: -------------------------------------------------------------------------------- 1 | import lifecycle from './lifecycle'; 2 | import type { Core } from '@strapi/strapi'; 3 | 4 | const lifecycleService = ({ strapi }: { strapi: Core.Strapi }) => { 5 | return { 6 | ...lifecycle({ strapi }), 7 | }; 8 | }; 9 | 10 | export type LifecycleService = ReturnType; 11 | 12 | export default lifecycleService; 13 | -------------------------------------------------------------------------------- /server/src/services/content-types/index.ts: -------------------------------------------------------------------------------- 1 | import contentTypeProvider from './content-types'; 2 | import type { Core } from '@strapi/strapi'; 3 | 4 | const contentTypeService = ({ strapi }: { strapi: Core.Strapi }) => { 5 | return { 6 | ...contentTypeProvider({ strapi }), 7 | }; 8 | }; 9 | 10 | export type ContentTypeService = ReturnType; 11 | 12 | export default contentTypeService; 13 | -------------------------------------------------------------------------------- /admin/src/components/Initializer.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | import { PLUGIN_ID } from '../pluginId'; 4 | 5 | type InitializerProps = { 6 | setPlugin: (id: string) => void; 7 | }; 8 | 9 | const Initializer = ({ setPlugin }: InitializerProps) => { 10 | const ref = useRef(setPlugin); 11 | 12 | useEffect(() => { 13 | ref.current(PLUGIN_ID); 14 | }, []); 15 | 16 | return null; 17 | }; 18 | 19 | export { Initializer }; 20 | -------------------------------------------------------------------------------- /admin/src/pages/App.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router-dom'; 2 | import { Page } from '@strapi/strapi/admin'; 3 | 4 | import { PERMISSIONS } from '../constants'; 5 | import { HomePage } from './HomePage'; 6 | 7 | const App = () => { 8 | return ( 9 | 10 | 11 | } /> 12 | } /> 13 | 14 | 15 | ); 16 | }; 17 | 18 | export { App }; 19 | -------------------------------------------------------------------------------- /server/src/services/upstash-search/index.ts: -------------------------------------------------------------------------------- 1 | import connectorService from './connector'; 2 | import adapterService from './adapter'; 3 | import type { Core } from '@strapi/strapi'; 4 | 5 | const upstashSearchService = ({ strapi }: { strapi: Core.Strapi }) => { 6 | const adapter = adapterService({ strapi }); 7 | return { 8 | ...connectorService({ strapi, adapter }), 9 | ...adapterService({ strapi }), 10 | }; 11 | }; 12 | 13 | export type UpstashSearchService = ReturnType; 14 | 15 | export default upstashSearchService; 16 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Application methods 3 | */ 4 | import bootstrap from './bootstrap'; 5 | import destroy from './destroy'; 6 | import register from './register'; 7 | 8 | /** 9 | * Plugin server methods 10 | */ 11 | import controllers from './controllers'; 12 | import middlewares from './middlewares'; 13 | import policies from './policies'; 14 | import routes from './routes'; 15 | import services from './services'; 16 | 17 | export default { 18 | register, 19 | bootstrap, 20 | destroy, 21 | controllers, 22 | routes, 23 | services, 24 | policies, 25 | middlewares, 26 | }; 27 | -------------------------------------------------------------------------------- /admin/src/pages/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | BackButton, 3 | Layouts, 4 | Page, 5 | private_AutoReloadOverlayBlockerProvider as AutoReloadOverlayBlockerProvider, 6 | } from '@strapi/strapi/admin'; 7 | 8 | import PluginTabs from '../components/tabs'; 9 | 10 | const HomePage = () => { 11 | return ( 12 | 13 | 14 | } 18 | /> 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | export { HomePage }; 28 | -------------------------------------------------------------------------------- /server/src/controllers/reload.ts: -------------------------------------------------------------------------------- 1 | import type { Core } from '@strapi/strapi'; 2 | 3 | async function reloadServer({ strapi }: { strapi: Core.Strapi }) { 4 | const { 5 | config: { autoReload }, 6 | } = strapi; 7 | if (!autoReload) { 8 | return { 9 | message: 'Reload is only possible in develop mode. Please reload server manually.', 10 | title: 'Reload failed', 11 | error: true, 12 | link: 'https://docs.strapi.io/cms/cli#strapi-start', 13 | }; 14 | } else { 15 | strapi.reload.isWatching = false; 16 | strapi.reload(); 17 | return { message: 'ok' }; 18 | } 19 | } 20 | 21 | export default ({ strapi }: { strapi: Core.Strapi }) => { 22 | return { 23 | reload(ctx) { 24 | ctx.send({ message: 'ok' }); 25 | return reloadServer({ strapi }); 26 | }, 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /server/src/services/store/store.ts: -------------------------------------------------------------------------------- 1 | import type { Core } from '@strapi/strapi'; 2 | 3 | const createStoreConnector = ({ strapi }: { strapi: Core.Strapi }) => { 4 | const strapiStore = strapi.store({ 5 | type: 'plugin', 6 | name: 'upstash-search', 7 | }); 8 | 9 | return { 10 | getStoreKey: async function ({ key }: { key: string }) { 11 | return strapiStore.get({ key }); 12 | }, 13 | 14 | setStoreKey: async function ({ key, value }: { key: string; value: any }) { 15 | return strapiStore.set({ 16 | key, 17 | value, 18 | }); 19 | }, 20 | 21 | deleteStore: async function ({ key }: { key: string }) { 22 | return strapiStore.delete({ 23 | key, 24 | }); 25 | }, 26 | }; 27 | }; 28 | 29 | export type Store = ReturnType; 30 | 31 | export default createStoreConnector; 32 | -------------------------------------------------------------------------------- /server/src/services/store/index.ts: -------------------------------------------------------------------------------- 1 | import createStoreConnector from './store'; 2 | import createCredentialStore from './credential'; 3 | import createIndexedContentTypesStore from './indexed-content-types'; 4 | import createListenedContentTypesStore from './listened-content-types'; 5 | import createContentTypeIndexesStore from './content-type-indexes'; 6 | import type { Core } from '@strapi/strapi'; 7 | 8 | const storeService = ({ strapi }: { strapi: Core.Strapi }) => { 9 | const store = createStoreConnector({ strapi }); 10 | return { 11 | ...createCredentialStore({ store }), 12 | ...createListenedContentTypesStore({ store }), 13 | ...createIndexedContentTypesStore({ store }), 14 | ...createContentTypeIndexesStore({ store }), 15 | ...createStoreConnector({ strapi }), 16 | }; 17 | }; 18 | 19 | export type StoreService = ReturnType; 20 | 21 | export default storeService; 22 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Upstash 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 | -------------------------------------------------------------------------------- /admin/src/utils/serverRestartWatcher.ts: -------------------------------------------------------------------------------- 1 | const SERVER_HAS_NOT_BEEN_KILLED_MESSAGE = 'did-not-kill-server'; 2 | const SERVER_HAS_BEEN_KILLED_MESSAGE = 'server is down'; 3 | 4 | export function serverRestartWatcher(response: any, didShutDownServer?: boolean) { 5 | return new Promise((resolve) => { 6 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 7 | // @ts-ignore 8 | fetch(`${window.strapi.backendURL}/_health`, { 9 | method: 'HEAD', 10 | mode: 'no-cors', 11 | headers: { 12 | 'Content-Type': 'application/json', 13 | 'Keep-Alive': 'false', 14 | }, 15 | }) 16 | .then((res) => { 17 | if (res.status >= 400) { 18 | throw new Error(SERVER_HAS_BEEN_KILLED_MESSAGE); 19 | } 20 | 21 | if (!didShutDownServer) { 22 | throw new Error(SERVER_HAS_NOT_BEEN_KILLED_MESSAGE); 23 | } 24 | 25 | resolve(response); 26 | }) 27 | .catch((err) => { 28 | setTimeout(() => { 29 | return serverRestartWatcher( 30 | response, 31 | err.message !== SERVER_HAS_NOT_BEEN_KILLED_MESSAGE 32 | ).then(resolve); 33 | }, 100); 34 | }); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /server/src/controllers/credential.ts: -------------------------------------------------------------------------------- 1 | import { StoreService } from 'src/services/store'; 2 | import type { Core } from '@strapi/strapi'; 3 | 4 | export default ({ strapi }: { strapi: Core.Strapi }) => { 5 | const store = strapi.plugin('upstash-search').service('store') as StoreService; 6 | return { 7 | async getCredentials(ctx) { 8 | await store 9 | .getCredentials() 10 | .then((credentials) => { 11 | ctx.body = { data: credentials }; 12 | }) 13 | .catch((e) => { 14 | const message = e.message; 15 | ctx.body = { 16 | error: { 17 | message: message, 18 | }, 19 | }; 20 | }); 21 | }, 22 | 23 | async addCredentials(ctx) { 24 | const { host, apiKey } = ctx.request.body; 25 | await store 26 | .addCredentials({ host, apiKey }) 27 | .then((credentials) => { 28 | ctx.body = { data: credentials }; 29 | }) 30 | .catch((e) => { 31 | const message = e.message; 32 | ctx.body = { 33 | error: { 34 | message: message, 35 | }, 36 | }; 37 | }); 38 | }, 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /admin/src/index.ts: -------------------------------------------------------------------------------- 1 | import { PLUGIN_ID } from './pluginId'; 2 | import { PERMISSIONS } from './constants'; 3 | import { Initializer } from './components/Initializer'; 4 | import { PluginIcon } from './components/PluginIcon'; 5 | 6 | export default { 7 | register(app: any) { 8 | app.addMenuLink({ 9 | to: `plugins/${PLUGIN_ID}`, 10 | icon: PluginIcon, 11 | intlLabel: { 12 | id: `${PLUGIN_ID}.plugin.name`, 13 | defaultMessage: PLUGIN_ID, 14 | }, 15 | Component: async () => { 16 | const { App } = await import('./pages/App'); 17 | 18 | return App; 19 | }, 20 | permissions: PERMISSIONS.main, 21 | }); 22 | 23 | app.registerPlugin({ 24 | id: PLUGIN_ID, 25 | initializer: Initializer, 26 | isReady: false, 27 | name: PLUGIN_ID, 28 | }); 29 | }, 30 | 31 | async registerTrads({ locales }: { locales: string[] }) { 32 | return Promise.all( 33 | locales.map(async (locale) => { 34 | try { 35 | const { default: data } = await import(`./translations/${locale}.json`); 36 | 37 | return { data, locale }; 38 | } catch { 39 | return { data: {}, locale }; 40 | } 41 | }) 42 | ); 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /server/src/services/upstash-search/adapter.ts: -------------------------------------------------------------------------------- 1 | import type { Core } from '@strapi/strapi'; 2 | import type { ContentTypeService } from '../content-types/content-types'; 3 | 4 | const adapterService = ({ strapi }: { strapi: Core.Strapi }) => { 5 | const contentTypeService = strapi 6 | .plugin('upstash-search') 7 | .service('contentType') as ContentTypeService; 8 | return { 9 | addCollectionNamePrefixToId: function ({ 10 | contentType, 11 | entryId, 12 | }: { 13 | contentType: string; 14 | entryId: number; 15 | }) { 16 | const collectionName = contentTypeService.getCollectionName({ 17 | contentType, 18 | }); 19 | 20 | return `${collectionName}-${entryId}`; 21 | }, 22 | 23 | addCollectionNamePrefix: function ({ 24 | contentType, 25 | entries, 26 | }: { 27 | contentType: string; 28 | entries: Record[]; 29 | }) { 30 | return entries.map((entry) => ({ 31 | ...entry, 32 | _upstash_search_id: this.addCollectionNamePrefixToId({ 33 | entryId: entry.id, 34 | contentType, 35 | }), 36 | })); 37 | }, 38 | }; 39 | }; 40 | 41 | export type AdapterService = ReturnType; 42 | 43 | export default adapterService; 44 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout Repo 14 | uses: actions/checkout@v3 15 | 16 | - name: Set env 17 | run: echo "VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 18 | 19 | - name: Setup Node 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: 18 23 | 24 | - name: Set package version 25 | run: echo $(jq --arg v "${{ env.VERSION }}" '(.version) = $v' package.json) > package.json 26 | 27 | - name: Setup Bun 28 | uses: oven-sh/setup-bun@v1 29 | with: 30 | bun-version: latest 31 | 32 | - name: Install dependencies 33 | run: bun install 34 | 35 | - name: Build 36 | run: bun run build 37 | 38 | - name: Add npm token 39 | run: echo "//registry.npmjs.org/:_authToken=${{secrets.NPM_TOKEN}}" > .npmrc 40 | 41 | - name: Publish release candidate 42 | if: 'github.event.release.prerelease' 43 | run: npm publish --access public --tag=canary 44 | 45 | - name: Publish 46 | if: '!github.event.release.prerelease' 47 | run: npm publish --access public 48 | -------------------------------------------------------------------------------- /admin/src/hooks/useAlert.tsx: -------------------------------------------------------------------------------- 1 | import { useNotification } from '@strapi/strapi/admin'; 2 | 3 | export function useAlert() { 4 | const { toggleNotification } = useNotification(); 5 | 6 | function handleNotification({ 7 | type = 'info', 8 | message = 'Something occurred in Upstash Search', 9 | link, 10 | blockTransition = true, 11 | title, 12 | }: { 13 | type: 'info' | 'warning' | 'success'; 14 | message: string; 15 | link?: string; 16 | blockTransition?: boolean; 17 | title?: string; 18 | }) { 19 | toggleNotification({ 20 | title, 21 | type, 22 | message, 23 | link: link ? { url: link, label: link } : undefined, 24 | blockTransition, 25 | onClose: () => localStorage.setItem('STRAPI_UPDATE_NOTIF', 'true'), 26 | }); 27 | } 28 | 29 | const checkForbiddenError = ({ response }: { response: any }) => { 30 | const status = response?.data?.error?.status; 31 | if (status && status === 403) { 32 | handleNotification({ 33 | title: 'Forbidden', 34 | type: 'warning', 35 | message: 'You do not have permission to do this action', 36 | blockTransition: false, 37 | }); 38 | } 39 | }; 40 | 41 | return { 42 | handleNotification, 43 | checkForbiddenError, 44 | }; 45 | } 46 | 47 | export default useAlert; 48 | -------------------------------------------------------------------------------- /server/src/services/store/credential.ts: -------------------------------------------------------------------------------- 1 | import type { Store } from './store'; 2 | 3 | type Credentials = { 4 | host: string; 5 | apiKey: string; 6 | }; 7 | 8 | const createCredentialStore = ({ store }: { store: Store }) => ({ 9 | getApiKey: async function () { 10 | return store.getStoreKey({ key: 'upstash_search_api_key' }) as Promise; 11 | }, 12 | 13 | setApiKey: async function (apiKey: string) { 14 | return store.setStoreKey({ 15 | key: 'upstash_search_api_key', 16 | value: apiKey || '', 17 | }); 18 | }, 19 | 20 | getHost: async function () { 21 | return store.getStoreKey({ key: 'upstash_search_host' }) as Promise; 22 | }, 23 | 24 | setHost: async function (value: string) { 25 | return store.setStoreKey({ key: 'upstash_search_host', value: value || '' }); 26 | }, 27 | 28 | addCredentials: async function ({ host, apiKey }: { host: string; apiKey: string }) { 29 | await this.setApiKey(apiKey || ''); 30 | 31 | await this.setHost(host || ''); 32 | 33 | return this.getCredentials(); 34 | }, 35 | 36 | getCredentials: async function (): Promise { 37 | const apiKey = await this.getApiKey(); 38 | const host = await this.getHost(); 39 | return { apiKey, host }; 40 | }, 41 | }); 42 | 43 | export type CredentialService = ReturnType; 44 | 45 | export default createCredentialStore; 46 | -------------------------------------------------------------------------------- /server/src/services/error/error.ts: -------------------------------------------------------------------------------- 1 | import type { Core } from '@strapi/strapi'; 2 | 3 | const error = ({ strapi }: { strapi: Core.Strapi }) => { 4 | const store = strapi.plugin('upstash-search').service('store'); 5 | return { 6 | async createError(e) { 7 | strapi.log.error(`upstash-search: ${e.message}`); 8 | const prefix = e.stack.split(':')[0]; 9 | if (prefix === 'UpstashSearchApiError') { 10 | return { 11 | error: { 12 | message: e.message, 13 | link: { 14 | url: e.link || 'https://upstash.com/docs/search/overall/getstarted', 15 | label: { 16 | id: 'notification.upstash-search', 17 | defaultMessage: 'See more', 18 | }, 19 | }, 20 | }, 21 | }; 22 | } else if (e.type === 'UpstashSearchCommunicationError') { 23 | const { host } = await store.getCredentials(); 24 | return { 25 | error: { 26 | message: `Could not connect with Upstash Search, please check your host: ${host}`, 27 | }, 28 | }; 29 | } else { 30 | const message = e.message; 31 | return { 32 | error: { 33 | message: message, 34 | }, 35 | }; 36 | } 37 | }, 38 | }; 39 | }; 40 | 41 | export type ErrorService = ReturnType; 42 | 43 | export default error; 44 | -------------------------------------------------------------------------------- /admin/src/components/collections/CollectionTableHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import { Th, Thead, Tr, Typography, VisuallyHidden } from '@strapi/design-system'; 3 | import { useRBAC } from '@strapi/strapi/admin'; 4 | import { PERMISSIONS } from '../../constants'; 5 | 6 | const CollectionTableHeader = () => { 7 | const { 8 | allowedActions: { canCreate, canUpdate, canDelete }, 9 | } = useRBAC(PERMISSIONS.collections); 10 | 11 | return ( 12 | 13 | 14 | {(canCreate || canDelete) && ( 15 | 16 | INDEX 17 | 18 | )} 19 | 20 | NAME 21 | 22 | 23 | IN UPSTASH SEARCH 24 | 25 | 26 | INDEXING 27 | 28 | 29 | INDEX NAME 30 | 31 | 32 | DOCUMENTS 33 | 34 | 35 | HOOKS 36 | 37 | {canUpdate && ( 38 | 39 | Actions 40 | 41 | )} 42 | 43 | 44 | ); 45 | }; 46 | 47 | export default memo(CollectionTableHeader); 48 | -------------------------------------------------------------------------------- /admin/src/components/tabs.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Tabs } from '@strapi/design-system'; 2 | import { useRBAC } from '@strapi/strapi/admin'; 3 | 4 | import { PERMISSIONS } from '../constants'; 5 | import { CollectionTable } from './collections'; 6 | import { Settings } from './settings'; 7 | 8 | const PluginTabs = () => { 9 | const { allowedActions: allowedActionsCollection } = useRBAC(PERMISSIONS.collections); 10 | const { allowedActions: allowedActionsSettings } = useRBAC(PERMISSIONS.settings); 11 | 12 | const canSeeCollections = Object.values(allowedActionsCollection).some((value) => !!value); 13 | const canSeeSettings = Object.values(allowedActionsSettings).some((value) => !!value); 14 | 15 | return ( 16 | 17 | 18 | {'Collections'} 19 | {'Credentials'} 20 | 21 | 22 | {canSeeCollections && ( 23 | 24 | 25 | 26 | )} 27 | 28 | 29 | {canSeeSettings && ( 30 | 31 | 32 | 33 | )} 34 | 35 | 36 | ); 37 | }; 38 | 39 | export default PluginTabs; 40 | -------------------------------------------------------------------------------- /admin/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const PERMISSIONS = { 2 | main: [ 3 | { action: 'plugin::upstash-search.read', subject: null }, 4 | { action: 'plugin::upstash-search.collections.create', subject: null }, 5 | { action: 'plugin::upstash-search.collections.update', subject: null }, 6 | { action: 'plugin::upstash-search.collections.delete', subject: null }, 7 | { action: 'plugin::upstash-search.settings.edit', subject: null }, 8 | ], 9 | collections: [ 10 | { action: 'plugin::upstash-search.read', subject: null }, 11 | { action: 'plugin::upstash-search.collections.create', subject: null }, 12 | { action: 'plugin::upstash-search.collections.update', subject: null }, 13 | { action: 'plugin::upstash-search.collections.delete', subject: null }, 14 | ], 15 | settings: [ 16 | { action: 'plugin::upstash-search.read', subject: null }, 17 | { action: 'plugin::upstash-search.settings.edit', subject: null }, 18 | ], 19 | read: [{ action: 'plugin::upstash-search.read', subject: null }], 20 | create: [{ action: 'plugin::upstash-search.collections.create', subject: null }], 21 | update: [{ action: 'plugin::upstash-search.collections.update', subject: null }], 22 | delete: [{ action: 'plugin::upstash-search.collections.delete', subject: null }], 23 | settingsEdit: [{ action: 'plugin::upstash-search.settings.edit', subject: null }], 24 | createAndDelete: [ 25 | { action: 'plugin::upstash-search.collections.create', subject: null }, 26 | { action: 'plugin::upstash-search.collections.delete', subject: null }, 27 | ], 28 | }; 29 | -------------------------------------------------------------------------------- /server/src/services/store/content-type-indexes.ts: -------------------------------------------------------------------------------- 1 | import type { ContentTypeService } from '../content-types/content-types'; 2 | import type { Store } from './store'; 3 | 4 | const createContentTypeIndexesStore = ({ store }: { store: Store }) => ({ 5 | getIndexNamesOfContentType: async function ({ contentType }: { contentType: string }) { 6 | const contentTypeService = strapi 7 | .plugin('upstash-search') 8 | .service('contentType') as ContentTypeService; 9 | 10 | const collection = contentTypeService.getCollectionName({ contentType }) as string; 11 | const key = `upstash_search_index_names_${contentType}`; 12 | const indexNames = (await store.getStoreKey({ key })) as string[]; 13 | return (indexNames || [collection]).filter((n) => typeof n === 'string' && n.trim().length > 0); 14 | }, 15 | 16 | setIndexNamesForContentType: async function ({ 17 | contentType, 18 | indexNames = [], 19 | }: { 20 | contentType: string; 21 | indexNames?: string[]; 22 | }) { 23 | const key = `upstash_search_index_names_${contentType}`; 24 | const sanitized = (indexNames || []).filter( 25 | (n) => typeof n === 'string' && n.trim().length > 0 26 | ); 27 | return store.setStoreKey({ key, value: sanitized }); 28 | }, 29 | 30 | clearIndexNamesForContentType: async function ({ contentType }: { contentType: string }) { 31 | return this.setIndexNamesForContentType({ contentType, indexNames: [] }); 32 | }, 33 | }); 34 | 35 | export type ContentTypeIndexesService = ReturnType; 36 | 37 | export default createContentTypeIndexesStore; 38 | -------------------------------------------------------------------------------- /server/src/services/store/listened-content-types.ts: -------------------------------------------------------------------------------- 1 | import type { Store } from './store'; 2 | 3 | const createListenedContentTypesStore = ({ store }: { store: Store }) => ({ 4 | getListenedContentTypes: async function () { 5 | const contentTypes = (await store.getStoreKey({ 6 | key: 'upstash_search_listened_content_types', 7 | })) as string[]; 8 | return contentTypes || []; 9 | }, 10 | 11 | setListenedContentTypes: async function ({ contentTypes = [] }: { contentTypes: string[] }) { 12 | return store.setStoreKey({ 13 | key: 'upstash_search_listened_content_types', 14 | value: contentTypes, 15 | }); 16 | }, 17 | 18 | addListenedContentType: async function ({ contentType }: { contentType: string }) { 19 | const listenedContentTypes = await this.getListenedContentTypes(); 20 | const newSet = new Set(listenedContentTypes); 21 | newSet.add(contentType); 22 | 23 | return this.setListenedContentTypes({ 24 | contentTypes: [...newSet], 25 | }); 26 | }, 27 | 28 | addListenedContentTypes: async function ({ contentTypes }: { contentTypes: string[] }) { 29 | for (const contentType of contentTypes) { 30 | await this.addListenedContentType({ contentType }); 31 | } 32 | return this.getListenedContentTypes(); 33 | }, 34 | 35 | emptyListenedContentTypes: async function () { 36 | await this.setListenedContentTypes({ contentTypes: [] }); 37 | return this.getListenedContentTypes(); 38 | }, 39 | }); 40 | 41 | export type ListenedContentTypesService = ReturnType; 42 | 43 | export default createListenedContentTypesStore; 44 | -------------------------------------------------------------------------------- /server/src/services/store/indexed-content-types.ts: -------------------------------------------------------------------------------- 1 | import type { Store } from './store'; 2 | 3 | type IndexedContentType = { 4 | contentType: string; 5 | searchableFields: string[]; 6 | }; 7 | 8 | const createIndexedContentTypesStore = ({ store }: { store: Store }) => ({ 9 | getIndexedContentTypes: async function () { 10 | const contentTypes = (await store.getStoreKey({ 11 | key: 'upstash_search_indexed_content_types', 12 | })) as IndexedContentType[]; 13 | return contentTypes || []; 14 | }, 15 | 16 | setIndexedContentTypes: async function ({ 17 | contentTypes, 18 | }: { 19 | contentTypes: IndexedContentType[]; 20 | }) { 21 | return store.setStoreKey({ 22 | key: 'upstash_search_indexed_content_types', 23 | value: contentTypes, 24 | }); 25 | }, 26 | 27 | addIndexedContentType: async function ({ 28 | contentType, 29 | searchableFields, 30 | }: { 31 | contentType: string; 32 | searchableFields?: string[]; 33 | }) { 34 | const indexedContentTypes = await this.getIndexedContentTypes(); 35 | const filtered = indexedContentTypes.filter((ct) => ct.contentType !== contentType); 36 | const updated = [...filtered, { contentType, searchableFields }]; 37 | return this.setIndexedContentTypes({ contentTypes: updated }); 38 | }, 39 | 40 | removeIndexedContentType: async function ({ contentType }: { contentType: string }) { 41 | const indexedContentTypes = await this.getIndexedContentTypes(); 42 | const filtered = indexedContentTypes.filter((ct) => ct.contentType !== contentType); 43 | return this.setIndexedContentTypes({ contentTypes: filtered }); 44 | }, 45 | }); 46 | 47 | export type IndexedContentTypesService = ReturnType; 48 | 49 | export default createIndexedContentTypesStore; 50 | -------------------------------------------------------------------------------- /admin/src/hooks/useCredential.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { useFetchClient } from '@strapi/strapi/admin'; 3 | import { PLUGIN_ID } from '../pluginId'; 4 | import useAlert from './useAlert'; 5 | 6 | export function useCredential() { 7 | const [credentials, setCredentials] = useState({ 8 | host: '', 9 | apiKey: '', 10 | }); 11 | const [refetchIndex, setRefetchIndex] = useState(true); 12 | const [host, setHost] = useState(''); 13 | const [apiKey, setApiKey] = useState(''); 14 | const { handleNotification } = useAlert(); 15 | const { get, post } = useFetchClient(); 16 | 17 | const refetchCredentials = () => setRefetchIndex((prevRefetchIndex) => !prevRefetchIndex); 18 | 19 | const updateCredentials = async () => { 20 | const { 21 | data: { error }, 22 | } = await post(`/${PLUGIN_ID}/credential`, { 23 | apiKey: apiKey, 24 | host: host, 25 | }); 26 | if (error) { 27 | handleNotification({ 28 | type: 'warning', 29 | message: error.message, 30 | link: error.link, 31 | }); 32 | } else { 33 | refetchCredentials(); 34 | handleNotification({ 35 | type: 'success', 36 | message: 'Credentials sucessfully updated!', 37 | blockTransition: false, 38 | }); 39 | } 40 | }; 41 | 42 | const fetchCredentials = async () => { 43 | const { 44 | data: { data, error }, 45 | } = await get(`/${PLUGIN_ID}/credential`); 46 | 47 | if (error) { 48 | handleNotification({ 49 | type: 'warning', 50 | message: error.message, 51 | link: error.link, 52 | }); 53 | } else { 54 | setCredentials(data); 55 | setHost(data.host); 56 | setApiKey(data.apiKey); 57 | } 58 | }; 59 | 60 | useEffect(() => { 61 | fetchCredentials(); 62 | }, [refetchIndex]); 63 | 64 | return { 65 | credentials, 66 | updateCredentials, 67 | setHost, 68 | setApiKey, 69 | host, 70 | apiKey, 71 | }; 72 | } 73 | export default useCredential; 74 | -------------------------------------------------------------------------------- /server/src/controllers/content-type.ts: -------------------------------------------------------------------------------- 1 | import type { Core } from '@strapi/strapi'; 2 | import { ErrorService } from 'src/services/error'; 3 | import { UpstashSearchService } from 'src/services/upstash-search'; 4 | 5 | export default ({ strapi }: { strapi: Core.Strapi }) => { 6 | const upstashSearch = strapi 7 | .plugin('upstash-search') 8 | .service('upstashSearch') as UpstashSearchService; 9 | const error = strapi.plugin('upstash-search').service('error') as ErrorService; 10 | 11 | return { 12 | async getContentTypes(ctx) { 13 | await upstashSearch 14 | .getContentTypesReport() 15 | .then((contentTypes) => { 16 | ctx.body = { data: contentTypes }; 17 | }) 18 | .catch(async (e) => { 19 | ctx.body = await error.createError(e); 20 | }); 21 | }, 22 | 23 | async addContentType(ctx) { 24 | const { contentType, searchableFields, indexUids } = ctx.request.body; 25 | 26 | await upstashSearch 27 | .addContentTypeInUpstashSearch({ 28 | contentType, 29 | searchableFields, 30 | indexUids, 31 | }) 32 | .then((taskUids) => { 33 | ctx.body = { data: taskUids }; 34 | }) 35 | .catch(async (e) => { 36 | ctx.body = await error.createError(e); 37 | }); 38 | }, 39 | 40 | async updateContentType(ctx) { 41 | const { contentType } = ctx.request.body; 42 | await upstashSearch 43 | .updateContentTypeInUpstashSearch({ 44 | contentType, 45 | }) 46 | .then((taskUids) => { 47 | ctx.body = { data: taskUids }; 48 | }) 49 | .catch(async (e) => { 50 | ctx.body = await error.createError(e); 51 | }); 52 | }, 53 | 54 | async removeContentType(ctx) { 55 | const { contentType } = ctx.request.params; 56 | 57 | await upstashSearch 58 | .emptyOrDeleteIndex({ 59 | contentType, 60 | }) 61 | .then(() => { 62 | ctx.body = { data: 'ok' }; 63 | }) 64 | .catch(async (e) => { 65 | ctx.body = await error.createError(e); 66 | }); 67 | }, 68 | }; 69 | }; 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | ############################ 4 | # OS X 5 | ############################ 6 | 7 | .DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | Icon 11 | .Spotlight-V100 12 | .Trashes 13 | ._* 14 | 15 | 16 | ############################ 17 | # Linux 18 | ############################ 19 | 20 | *~ 21 | 22 | 23 | ############################ 24 | # Windows 25 | ############################ 26 | 27 | Thumbs.db 28 | ehthumbs.db 29 | Desktop.ini 30 | $RECYCLE.BIN/ 31 | *.cab 32 | *.msi 33 | *.msm 34 | *.msp 35 | 36 | 37 | ############################ 38 | # Packages 39 | ############################ 40 | 41 | *.7z 42 | *.csv 43 | *.dat 44 | *.dmg 45 | *.gz 46 | *.iso 47 | *.jar 48 | *.rar 49 | *.tar 50 | *.zip 51 | *.com 52 | *.class 53 | *.dll 54 | *.exe 55 | *.o 56 | *.seed 57 | *.so 58 | *.swo 59 | *.swp 60 | *.swn 61 | *.swm 62 | *.out 63 | *.pid 64 | 65 | 66 | ############################ 67 | # Logs and databases 68 | ############################ 69 | 70 | .tmp 71 | *.log 72 | *.sql 73 | *.sqlite 74 | *.sqlite3 75 | 76 | 77 | ############################ 78 | # Misc. 79 | ############################ 80 | 81 | *# 82 | ssl 83 | .idea 84 | nbproject 85 | .tsbuildinfo 86 | .eslintcache 87 | .env 88 | 89 | 90 | ############################ 91 | # Strapi 92 | ############################ 93 | 94 | public/uploads/* 95 | !public/uploads/.gitkeep 96 | 97 | 98 | ############################ 99 | # Build 100 | ############################ 101 | 102 | dist 103 | build 104 | 105 | 106 | ############################ 107 | # Node.js 108 | ############################ 109 | 110 | lib-cov 111 | lcov.info 112 | pids 113 | logs 114 | results 115 | node_modules 116 | .node_history 117 | 118 | 119 | ############################ 120 | # Package managers 121 | ############################ 122 | 123 | .yarn/* 124 | !.yarn/cache 125 | !.yarn/unplugged 126 | !.yarn/patches 127 | !.yarn/releases 128 | !.yarn/sdks 129 | !.yarn/versions 130 | .pnp.* 131 | yarn-error.log 132 | 133 | 134 | ############################ 135 | # Tests 136 | ############################ 137 | 138 | coverage 139 | -------------------------------------------------------------------------------- /server/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { ACTIONS } from './constants'; 2 | 3 | export default [ 4 | { 5 | method: 'GET', 6 | path: '/credential', 7 | handler: 'credentialController.getCredentials', 8 | config: { 9 | policies: ['admin::isAuthenticatedAdmin'], 10 | }, 11 | }, 12 | { 13 | method: 'POST', 14 | path: '/credential', 15 | handler: 'credentialController.addCredentials', 16 | config: { 17 | policies: [ 18 | 'admin::isAuthenticatedAdmin', 19 | { 20 | name: 'admin::hasPermissions', 21 | config: { actions: [ACTIONS.settings] }, 22 | }, 23 | ], 24 | }, 25 | }, 26 | { 27 | method: 'GET', 28 | path: '/content-type', 29 | handler: 'contentTypeController.getContentTypes', 30 | config: { 31 | policies: ['admin::isAuthenticatedAdmin'], 32 | }, 33 | }, 34 | { 35 | method: 'POST', 36 | path: '/content-type', 37 | handler: 'contentTypeController.addContentType', 38 | config: { 39 | policies: [ 40 | 'admin::isAuthenticatedAdmin', 41 | { 42 | name: 'admin::hasPermissions', 43 | config: { actions: [ACTIONS.create] }, 44 | }, 45 | ], 46 | }, 47 | }, 48 | { 49 | method: 'PUT', 50 | path: '/content-type', 51 | handler: 'contentTypeController.updateContentType', 52 | config: { 53 | policies: [ 54 | 'admin::isAuthenticatedAdmin', 55 | { 56 | name: 'admin::hasPermissions', 57 | config: { 58 | actions: [ACTIONS.update], 59 | }, 60 | }, 61 | ], 62 | }, 63 | }, 64 | { 65 | method: 'DELETE', 66 | path: '/content-type/:contentType', 67 | handler: 'contentTypeController.removeContentType', 68 | config: { 69 | policies: [ 70 | 'admin::isAuthenticatedAdmin', 71 | { 72 | name: 'admin::hasPermissions', 73 | config: { 74 | actions: [ACTIONS.delete], 75 | }, 76 | }, 77 | ], 78 | }, 79 | }, 80 | { 81 | method: 'GET', 82 | path: '/reload', 83 | handler: 'reloadController.reload', 84 | config: { 85 | policies: ['admin::isAuthenticatedAdmin'], 86 | }, 87 | }, 88 | ]; 89 | -------------------------------------------------------------------------------- /admin/src/components/settings/Credentials.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Field, Typography, Link } from '@strapi/design-system'; 2 | import React, { memo } from 'react'; 3 | import { useCredential } from '../../hooks/useCredential'; 4 | import { PERMISSIONS } from '../../constants'; 5 | import { useRBAC } from '@strapi/strapi/admin'; 6 | 7 | const Credentials = () => { 8 | const { host, apiKey, setHost, setApiKey, updateCredentials } = useCredential(); 9 | const { 10 | allowedActions: { canEdit }, 11 | } = useRBAC(PERMISSIONS.settingsEdit); 12 | 13 | return ( 14 | 15 | 16 | 17 | URL 18 | setHost(e.target.value)} 24 | /> 25 | 26 | 27 | 28 | 29 | Token 30 | setApiKey(e.target.value)} 36 | /> 37 | 38 | 39 | 40 | 41 | You can find these credentials in Upstash console Search tab. 42 | 43 | 44 | {' '} 45 | Check out docs 46 | 47 | 48 | 49 | 50 | 51 | 52 | {canEdit && ( 53 | 56 | )} 57 | 58 | 59 | ); 60 | }; 61 | 62 | export default memo(Credentials); 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@upstash/strapi-plugin-upstash-search", 3 | "version": "0.0.1", 4 | "description": "Upstash Search plugin for Strapi - Add AI-powered search to your Strapi content with zero configs", 5 | "keywords": [ 6 | "strapi", 7 | "plugin", 8 | "upstash", 9 | "search", 10 | "ai-search", 11 | "semantic-search", 12 | "full-text-search", 13 | "vector-search", 14 | "strapi-plugin" 15 | ], 16 | "homepage": "https://github.com/upstash/strapi-plugin-upstash-search#readme", 17 | "bugs": { 18 | "url": "https://github.com/upstash/strapi-plugin-upstash-search/issues" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/upstash/strapi-plugin-upstash-search.git" 23 | }, 24 | "license": "MIT", 25 | "author": "Upstash ", 26 | "type": "commonjs", 27 | "exports": { 28 | "./package.json": "./package.json", 29 | "./strapi-admin": { 30 | "types": "./dist/admin/src/index.d.ts", 31 | "source": "./admin/src/index.ts", 32 | "import": "./dist/admin/index.mjs", 33 | "require": "./dist/admin/index.js", 34 | "default": "./dist/admin/index.js" 35 | }, 36 | "./strapi-server": { 37 | "types": "./dist/server/src/index.d.ts", 38 | "source": "./server/src/index.ts", 39 | "import": "./dist/server/index.mjs", 40 | "require": "./dist/server/index.js", 41 | "default": "./dist/server/index.js" 42 | } 43 | }, 44 | "files": [ 45 | "dist", 46 | "README.md", 47 | "LICENSE" 48 | ], 49 | "scripts": { 50 | "build": "strapi-plugin build", 51 | "watch": "strapi-plugin watch", 52 | "watch:link": "strapi-plugin watch:link", 53 | "verify": "strapi-plugin verify", 54 | "test:ts:front": "run -T tsc -p admin/tsconfig.json", 55 | "test:ts:back": "run -T tsc -p server/tsconfig.json" 56 | }, 57 | "dependencies": { 58 | "@strapi/design-system": "^2.0.0-rc.29", 59 | "@strapi/icons": "^2.0.0-rc.29", 60 | "@upstash/search": "^0.1.5", 61 | "react-intl": "^7.1.11" 62 | }, 63 | "devDependencies": { 64 | "@strapi/sdk-plugin": "^5.3.2", 65 | "@strapi/strapi": "^5.23.1", 66 | "@strapi/typescript-utils": "^5.23.1", 67 | "@types/react": "^19.1.12", 68 | "@types/react-dom": "^19.1.9", 69 | "prettier": "^3.6.2", 70 | "react": "^18.3.1", 71 | "react-dom": "^18.3.1", 72 | "react-router-dom": "^6.30.1", 73 | "styled-components": "^6.1.19", 74 | "typescript": "^5.9.2" 75 | }, 76 | "peerDependencies": { 77 | "@strapi/sdk-plugin": "^5.3.2", 78 | "@strapi/strapi": "^5.23.1", 79 | "react": "^18.3.1", 80 | "react-dom": "^18.3.1", 81 | "react-router-dom": "^6.30.1", 82 | "styled-components": "^6.1.19" 83 | }, 84 | "engines": { 85 | "node": ">=18.0.0", 86 | "npm": ">=8.0.0" 87 | }, 88 | "strapi": { 89 | "kind": "plugin", 90 | "name": "upstash-search", 91 | "displayName": "Upstash Search", 92 | "description": "Add AI-powered search to your Strapi content with Upstash Search" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /admin/src/components/collections/CardSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex } from '@strapi/design-system'; 2 | 3 | export const SkeletonCard = () => ( 4 | 17 | 18 | 27 | 28 | 29 | 30 | 39 | 48 | 49 | 50 | 51 | 52 | 60 | 61 | 62 | 63 | 64 | 73 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 97 | 98 | 99 | 107 | 115 | 116 | 117 | 118 | 127 | 128 | 129 | 130 | ); 131 | -------------------------------------------------------------------------------- /server/src/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import type { ContentTypeService } from './services/content-types'; 2 | import type { LifecycleService } from './services/lifecycle'; 3 | import type { StoreService } from './services/store'; 4 | import type { UpstashSearchService } from './services/upstash-search'; 5 | import type { Core } from '@strapi/strapi'; 6 | 7 | async function subscribeToLifecycles({ 8 | lifecycle, 9 | store, 10 | }: { 11 | lifecycle: LifecycleService; 12 | store: StoreService; 13 | }) { 14 | const indexedContentTypes = await store.getIndexedContentTypes(); 15 | await store.emptyListenedContentTypes(); 16 | let lifecycles; 17 | for (const ct of indexedContentTypes) { 18 | lifecycles = await lifecycle.subscribeContentType({ 19 | contentType: ct.contentType, 20 | searchableFields: ct.searchableFields, 21 | }); 22 | } 23 | 24 | return lifecycles; 25 | } 26 | 27 | async function syncIndexedCollections({ 28 | store, 29 | contentTypeService, 30 | upstashSearch, 31 | }: { 32 | store: StoreService; 33 | contentTypeService: ContentTypeService; 34 | upstashSearch: UpstashSearchService; 35 | }) { 36 | const indexUids = await upstashSearch.getIndexUids(); 37 | // All indexed contentTypes 38 | const indexedContentTypes = await store.getIndexedContentTypes(); 39 | const contentTypes = contentTypeService.getContentTypesUid(); 40 | 41 | for (const contentType of contentTypes) { 42 | const contentTypeIndexUids = await store.getIndexNamesOfContentType({ 43 | contentType, 44 | }); 45 | const indexesInUpstashSearch = contentTypeIndexUids.some((indexUid) => 46 | indexUids.includes(indexUid) 47 | ); 48 | const contentTypeInIndexStore = indexedContentTypes.find( 49 | (ct) => ct.contentType === contentType 50 | ); 51 | 52 | // Remove any collection that is not in Upstash Search anymore 53 | if (!indexesInUpstashSearch && contentTypeInIndexStore) { 54 | await store.removeIndexedContentType({ contentType }); 55 | } 56 | } 57 | } 58 | 59 | const registerPermissionActions = async () => { 60 | // Role Based Access Control 61 | const RBAC_ACTIONS = [ 62 | { 63 | section: 'plugins', 64 | displayName: 'Access the Upstash Search', 65 | uid: 'read', 66 | pluginName: 'upstash-search', 67 | }, 68 | { 69 | section: 'plugins', 70 | displayName: 'Create', 71 | uid: 'collections.create', 72 | subCategory: 'collections', 73 | pluginName: 'upstash-search', 74 | }, 75 | { 76 | section: 'plugins', 77 | displayName: 'Update', 78 | uid: 'collections.update', 79 | subCategory: 'collections', 80 | pluginName: 'upstash-search', 81 | }, 82 | { 83 | section: 'plugins', 84 | displayName: 'Delete', 85 | uid: 'collections.delete', 86 | subCategory: 'collections', 87 | pluginName: 'upstash-search', 88 | }, 89 | { 90 | section: 'plugins', 91 | displayName: 'Edit', 92 | uid: 'settings.edit', 93 | subCategory: 'settings', 94 | pluginName: 'upstash-search', 95 | }, 96 | ]; 97 | 98 | await strapi.admin.services.permission.actionProvider.registerMany(RBAC_ACTIONS); 99 | }; 100 | 101 | export default async ({ strapi }: { strapi: Core.Strapi }) => { 102 | const store = strapi.plugin('upstash-search').service('store') as StoreService; 103 | const lifecycle = strapi.plugin('upstash-search').service('lifecycle') as LifecycleService; 104 | const upstashSearch = strapi 105 | .plugin('upstash-search') 106 | .service('upstashSearch') as UpstashSearchService; 107 | const contentTypeService = strapi 108 | .plugin('upstash-search') 109 | .service('contentType') as ContentTypeService; 110 | 111 | await syncIndexedCollections({ 112 | store, 113 | contentTypeService, 114 | upstashSearch, 115 | }); 116 | await subscribeToLifecycles({ 117 | lifecycle, 118 | store, 119 | }); 120 | await registerPermissionActions(); 121 | }; 122 | -------------------------------------------------------------------------------- /server/src/services/content-types/content-types.ts: -------------------------------------------------------------------------------- 1 | import type { Core, Schema } from '@strapi/strapi'; 2 | 3 | const IGNORED_PLUGINS = ['admin', 'upload', 'i18n', 'review-workflows', 'content-releases']; 4 | const IGNORED_CONTENT_TYPES = [ 5 | 'plugin::users-permissions.permission', 6 | 'plugin::users-permissions.role', 7 | ]; 8 | 9 | const removeIgnoredAPIs = ({ contentTypes }: { contentTypes: Record }) => { 10 | const contentTypeUids = Object.keys(contentTypes); 11 | 12 | return contentTypeUids.reduce( 13 | (sanitized, contentType) => { 14 | if ( 15 | !( 16 | IGNORED_PLUGINS.includes(contentTypes[contentType].plugin) || 17 | IGNORED_CONTENT_TYPES.includes(contentType) 18 | ) 19 | ) { 20 | sanitized[contentType] = contentTypes[contentType]; 21 | } 22 | return sanitized; 23 | }, 24 | {} as Record 25 | ); 26 | }; 27 | 28 | const contentTypeService = ({ strapi }: { strapi: Core.Strapi }) => ({ 29 | /** 30 | * Get all content types name being plugins or API's existing in Strapi instance. 31 | * Content Types are formated like this: `type::apiName.contentType`. 32 | */ 33 | getContentTypesUid() { 34 | const contentTypes = removeIgnoredAPIs({ 35 | contentTypes: strapi.contentTypes, 36 | }); 37 | 38 | return Object.keys(contentTypes); 39 | }, 40 | 41 | /** 42 | * Get the content type uid in this format: "type::service.contentType". 43 | * 44 | * If it is already an uid it returns it. If not it searches for it 45 | */ 46 | getContentTypeUid({ contentType }: { contentType: string }) { 47 | const contentTypes = strapi.contentTypes; 48 | const contentTypeUids = Object.keys(contentTypes); 49 | if (contentTypeUids.includes(contentType)) return contentType; 50 | 51 | const contentTypeUid = contentTypeUids.find((uid) => { 52 | return contentTypes[uid].modelName === contentType; 53 | }); 54 | 55 | return contentTypeUid; 56 | }, 57 | 58 | /** 59 | * Get the content type uid in this format: "type::service.contentType". 60 | * 61 | * If it is already an uid it returns it. If not it searches for it 62 | */ 63 | getCollectionName({ contentType }: { contentType: string }) { 64 | const contentTypes = strapi.contentTypes; 65 | const contentTypeUids = Object.keys(contentTypes); 66 | if (contentTypeUids.includes(contentType)) return contentTypes[contentType].modelName; 67 | 68 | return contentType; 69 | }, 70 | 71 | numberOfEntries: async function ({ 72 | contentType, 73 | filters = {}, 74 | status = 'published', 75 | }: { 76 | contentType: string; 77 | filters?: Record; 78 | status?: 'published' | 'draft'; 79 | }) { 80 | const contentTypeUid = this.getContentTypeUid({ contentType }); 81 | if (contentTypeUid === undefined) return 0; 82 | 83 | try { 84 | const count = await strapi.documents(contentTypeUid as any).count({ 85 | filters, 86 | status, 87 | }); 88 | 89 | return count; 90 | } catch (e) { 91 | strapi.log.warn(e); 92 | return 0; 93 | } 94 | }, 95 | 96 | async getEntries({ 97 | contentType, 98 | fields = '*', 99 | start = 0, 100 | limit = 100, 101 | filters = {}, 102 | sort = 'id', 103 | populate = '*', 104 | status = 'published', 105 | locale, 106 | }: { 107 | contentType: string; 108 | fields?: string; 109 | start?: number; 110 | limit?: number; 111 | filters?: Record; 112 | sort?: string; 113 | populate?: string; 114 | status?: 'published' | 'draft'; 115 | locale?: string; 116 | }) { 117 | const contentTypeUid = this.getContentTypeUid({ contentType }); 118 | if (contentTypeUid === undefined) return []; 119 | 120 | const queryOptions = { 121 | fields: fields || '*', 122 | filters, 123 | sort, 124 | populate, 125 | status, 126 | pagination: { 127 | start, 128 | limit, 129 | }, 130 | ...(locale ? { locale } : {}), 131 | }; 132 | 133 | const entries = await strapi.documents(contentTypeUid as any).findMany(queryOptions); 134 | 135 | // Safe guard in case the content-type is a single type. 136 | // In which case it is wrapped in an array for consistency. 137 | if (entries && !Array.isArray(entries)) return [entries]; 138 | return entries || []; 139 | }, 140 | 141 | actionInBatches: async function ({ 142 | contentType, 143 | callback = () => void 0, 144 | }: { 145 | contentType: string; 146 | callback?: ({ entries, contentType }: { entries: T[]; contentType: string }) => T; 147 | }) { 148 | const batchSize = 100; 149 | // Need total number of entries in contentType 150 | const entries_count = await this.numberOfEntries({ 151 | contentType, 152 | }); 153 | const cbResponse: T[] = []; 154 | for (let index = 0; index < entries_count; index += batchSize) { 155 | const entries = 156 | ((await this.getEntries({ 157 | start: index, 158 | contentType, 159 | })) as T[]) || []; 160 | 161 | if (entries.length > 0) { 162 | const info = await callback({ entries, contentType }); 163 | if (Array.isArray(info)) cbResponse.push(...info); 164 | else if (info) cbResponse.push(info); 165 | } 166 | } 167 | return cbResponse; 168 | }, 169 | }); 170 | 171 | export type ContentTypeService = ReturnType; 172 | 173 | export default contentTypeService; 174 | -------------------------------------------------------------------------------- /admin/src/hooks/useCollection.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { useFetchClient } from '@strapi/strapi/admin'; 3 | 4 | import { PLUGIN_ID } from '../pluginId'; 5 | import useAlert from './useAlert'; 6 | 7 | export type Collection = { 8 | contentType: string; 9 | indexed: boolean; 10 | listened: boolean; 11 | reloadNeeded: string; 12 | isIndexing?: boolean; 13 | indexUid: string; 14 | numberOfDocuments: number; 15 | numberOfEntries: number; 16 | fields: string[]; 17 | collection: string; 18 | [key: string]: any; 19 | }; 20 | 21 | export function useCollection() { 22 | const [collections, setCollections] = useState([]); 23 | const [refetchIndex, setRefetchIndex] = useState(true); 24 | const [reloadNeeded, setReloadNeeded] = useState(false); 25 | const [realTimeReports, setRealTimeReports] = useState(false); 26 | 27 | const { handleNotification, checkForbiddenError } = useAlert(); 28 | const { get, del, post, put } = useFetchClient(); 29 | 30 | const refetchCollection = () => setRefetchIndex((prevRefetchIndex) => !prevRefetchIndex); 31 | 32 | const hookingTextRendering = ({ indexed, listened }: { indexed: boolean; listened: boolean }) => { 33 | if (indexed && listened) return 'Hooked'; 34 | 35 | if (!indexed && !listened) return '/'; 36 | 37 | return 'Reload needed'; 38 | }; 39 | 40 | const fetchCollections = async () => { 41 | try { 42 | const { 43 | data: { data, error }, 44 | } = await get(`/${PLUGIN_ID}/content-type/`); 45 | 46 | if (error) { 47 | handleNotification({ 48 | type: 'warning', 49 | message: error.message, 50 | link: error.link, 51 | }); 52 | } else { 53 | const collections = data.contentTypes.map((collection: Collection) => { 54 | collection['reloadNeeded'] = hookingTextRendering({ 55 | indexed: collection.indexed, 56 | listened: collection.listened, 57 | }); 58 | return collection; 59 | }); 60 | const reload = collections.find((col: Collection) => col.reloadNeeded === 'Reload needed'); 61 | 62 | const isIndexing = collections.find((col: Collection) => col.isIndexing === true); 63 | 64 | if (!isIndexing) setRealTimeReports(false); 65 | else setRealTimeReports(true); 66 | 67 | if (reload) { 68 | setReloadNeeded(true); 69 | } else setReloadNeeded(false); 70 | setCollections(collections); 71 | } 72 | } catch (error) { 73 | checkForbiddenError({ response: error }); 74 | } 75 | }; 76 | 77 | const deleteCollection = async ({ contentType }: { contentType: string }) => { 78 | try { 79 | const { 80 | data: { error }, 81 | } = await del(`/${PLUGIN_ID}/content-type/${contentType}`); 82 | if (error) { 83 | handleNotification({ 84 | type: 'warning', 85 | message: error.message, 86 | link: error.link, 87 | }); 88 | } else { 89 | refetchCollection(); 90 | handleNotification({ 91 | type: 'success', 92 | message: 'Request to delete content-type is successful', 93 | blockTransition: false, 94 | }); 95 | } 96 | } catch (error) { 97 | checkForbiddenError({ response: error }); 98 | } 99 | }; 100 | 101 | const addCollection = async ({ 102 | contentType, 103 | searchableFields, 104 | indexUids, 105 | }: { 106 | contentType: string; 107 | searchableFields?: string[]; 108 | indexUids?: string[]; 109 | }) => { 110 | try { 111 | const { 112 | data: { error }, 113 | } = await post(`/${PLUGIN_ID}/content-type`, { 114 | contentType, 115 | searchableFields, 116 | indexUids, 117 | }); 118 | 119 | if (error) { 120 | handleNotification({ 121 | type: 'warning', 122 | message: error.message, 123 | link: error.link, 124 | }); 125 | } else { 126 | refetchCollection(); 127 | handleNotification({ 128 | type: 'success', 129 | message: 'Request to add a content-type is successful', 130 | blockTransition: false, 131 | }); 132 | } 133 | } catch (error) { 134 | checkForbiddenError({ response: error }); 135 | } 136 | }; 137 | 138 | const updateCollection = async ({ contentType }: { contentType: string }) => { 139 | try { 140 | const { 141 | data: { error }, 142 | } = await put(`/${PLUGIN_ID}/content-type`, { 143 | contentType, 144 | }); 145 | 146 | if (error) { 147 | handleNotification({ 148 | type: 'warning', 149 | message: error.message, 150 | link: error.link, 151 | }); 152 | } else { 153 | refetchCollection(); 154 | handleNotification({ 155 | type: 'success', 156 | message: 'Request to update content-type is successful', 157 | blockTransition: false, 158 | }); 159 | } 160 | } catch (error) { 161 | checkForbiddenError({ response: error }); 162 | } 163 | }; 164 | 165 | useEffect(() => { 166 | fetchCollections(); 167 | }, [refetchIndex]); 168 | 169 | // Start refreshing the collections when a collection is being indexed 170 | useEffect(() => { 171 | let interval: NodeJS.Timeout | undefined; 172 | if (realTimeReports) { 173 | interval = setInterval(() => { 174 | refetchCollection(); 175 | }, 1000); 176 | } else { 177 | if (interval) clearInterval(interval); 178 | } 179 | return () => { 180 | if (interval) clearInterval(interval); 181 | }; 182 | }, [realTimeReports, refetchCollection]); 183 | 184 | return { 185 | collections, 186 | deleteCollection, 187 | addCollection, 188 | updateCollection, 189 | reloadNeeded, 190 | refetchCollection, 191 | handleNotification, 192 | }; 193 | } 194 | 195 | export default useCollection; 196 | -------------------------------------------------------------------------------- /server/src/services/lifecycle/lifecycle.ts: -------------------------------------------------------------------------------- 1 | import type { Core } from '@strapi/strapi'; 2 | import type { ContentTypeService } from '../content-types/content-types'; 3 | import type { StoreService } from '../store'; 4 | import type { UpstashSearchService } from '../upstash-search'; 5 | 6 | const lifecycleService = ({ strapi }: { strapi: Core.Strapi }) => { 7 | const contentTypeService = strapi 8 | .plugin('upstash-search') 9 | .service('contentType') as ContentTypeService; 10 | const store = strapi.plugin('upstash-search').service('store') as StoreService; 11 | return { 12 | /** 13 | * Subscribe the content type to all required lifecycles 14 | * 15 | * @param {object} options 16 | * @param {string} options.contentType 17 | * 18 | * @returns {Promise} 19 | */ 20 | async subscribeContentType({ 21 | contentType, 22 | searchableFields, 23 | }: { 24 | contentType: string; 25 | searchableFields: string[]; 26 | }) { 27 | const contentTypeUid = contentTypeService.getContentTypeUid({ 28 | contentType: contentType, 29 | }); 30 | await strapi.db.lifecycles.subscribe({ 31 | models: [contentTypeUid], 32 | async afterCreate(event) { 33 | const { result } = event; 34 | const upstashSearch = strapi 35 | .plugin('upstash-search') 36 | .service('upstashSearch') as UpstashSearchService; 37 | 38 | await upstashSearch 39 | .addEntriesToUpstashSearch({ 40 | contentType: contentType, 41 | entries: [result], 42 | searchableFields: searchableFields, 43 | }) 44 | .catch((e) => { 45 | strapi.log.error( 46 | `Upstash Search could not add entry with id: ${result.id}: ${e.message}` 47 | ); 48 | }); 49 | }, 50 | async afterCreateMany(event) { 51 | const { result } = event; 52 | const upstashSearch = strapi 53 | .plugin('upstash-search') 54 | .service('upstashSearch') as UpstashSearchService; 55 | 56 | const nbrEntries = result.count; 57 | const ids = result.ids; 58 | 59 | const entries = []; 60 | const BATCH_SIZE = 100; 61 | for (let pos = 0; pos < nbrEntries; pos += BATCH_SIZE) { 62 | const batch = await contentTypeService.getEntries({ 63 | contentType: contentTypeUid, 64 | start: pos, 65 | limit: BATCH_SIZE, 66 | filters: { 67 | id: { 68 | $in: ids, 69 | }, 70 | }, 71 | }); 72 | entries.push(...batch); 73 | } 74 | 75 | upstashSearch 76 | .updateEntriesInUpstashSearch({ 77 | contentType: contentTypeUid, 78 | entries: entries, 79 | searchableFields: searchableFields, 80 | }) 81 | .catch((e) => { 82 | strapi.log.error(`Upstash Search could not update the entries: ${e.message}`); 83 | }); 84 | }, 85 | async afterUpdate(event) { 86 | const { result } = event; 87 | const upstashSearch = strapi 88 | .plugin('upstash-search') 89 | .service('upstashSearch') as UpstashSearchService; 90 | 91 | await upstashSearch 92 | .updateEntriesInUpstashSearch({ 93 | contentType: contentTypeUid, 94 | entries: [result], 95 | searchableFields: searchableFields, 96 | }) 97 | .catch((e) => { 98 | strapi.log.error( 99 | `Upstash Search could not update entry with id: ${result.id}: ${e.message}` 100 | ); 101 | }); 102 | }, 103 | async afterUpdateMany(event) { 104 | const upstashSearch = strapi 105 | .plugin('upstash-search') 106 | .service('upstashSearch') as UpstashSearchService; 107 | 108 | const nbrEntries = await contentTypeService.numberOfEntries({ 109 | contentType: contentTypeUid, 110 | filters: event.params.where, 111 | }); 112 | 113 | const entries = []; 114 | const BATCH_SIZE = 100; 115 | 116 | for (let pos = 0; pos < nbrEntries; pos += BATCH_SIZE) { 117 | const batch = await contentTypeService.getEntries({ 118 | contentType: contentTypeUid, 119 | filters: event.params.where, 120 | start: pos, 121 | limit: BATCH_SIZE, 122 | }); 123 | entries.push(...batch); 124 | } 125 | 126 | upstashSearch 127 | .updateEntriesInUpstashSearch({ 128 | contentType: contentTypeUid, 129 | entries: entries, 130 | searchableFields: searchableFields, 131 | }) 132 | .catch((e) => { 133 | strapi.log.error(`Upstash Search could not update the entries: ${e.message}`); 134 | }); 135 | }, 136 | async afterDelete(event) { 137 | const { result, params } = event; 138 | const upstashSearch = strapi 139 | .plugin('upstash-search') 140 | .service('upstashSearch') as UpstashSearchService; 141 | 142 | let entriesId = []; 143 | // Different ways of accessing the id's depending on the number of entries being deleted 144 | // In case of multiple deletes: 145 | if (params?.where?.$and && params?.where?.$and[0] && params?.where?.$and[0].id?.$in) 146 | entriesId = params?.where?.$and[0].id.$in; 147 | // In case there is only one entry being deleted 148 | else entriesId = [result.id]; 149 | 150 | upstashSearch 151 | .deleteEntriesFromUpstashSearch({ 152 | contentType: contentTypeUid, 153 | entriesId: entriesId, 154 | }) 155 | .catch((e) => { 156 | strapi.log.error( 157 | `Upstash Search could not delete entry with id: ${result.id}: ${e.message}` 158 | ); 159 | }); 160 | }, 161 | async afterDeleteMany(event) { 162 | const { result, params } = event; 163 | const upstashSearch = strapi 164 | .plugin('upstash-search') 165 | .service('upstashSearch') as UpstashSearchService; 166 | 167 | let entriesId = []; 168 | // Different ways of accessing the id's depending on the number of entries being deleted 169 | // In case of multiple deletes: 170 | if (params?.where?.$and && params?.where?.$and[0] && params?.where?.$and[0].id?.$in) 171 | entriesId = params?.where?.$and[0].id.$in; 172 | // In case there is only one entry being deleted 173 | else entriesId = [result.id]; 174 | 175 | upstashSearch 176 | .deleteEntriesFromUpstashSearch({ 177 | contentType: contentTypeUid, 178 | entriesId: entriesId, 179 | }) 180 | .catch((e) => { 181 | strapi.log.error( 182 | `Upstash Search could not delete entry with id: ${result.id}: ${e.message}` 183 | ); 184 | }); 185 | }, 186 | }); 187 | 188 | return store.addListenedContentType({ 189 | contentType: contentTypeUid, 190 | }); 191 | }, 192 | }; 193 | }; 194 | 195 | export type LifecycleService = ReturnType; 196 | 197 | export default lifecycleService; 198 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Upstash Search Strapi Plugin 2 | 3 |

⚡ The Upstash Search plugin for Strapi

4 | 5 | Upstash Search is a **simple, lightweight, and scalable way to add AI-powered search to your app**.We combine full-text and semantic search for highly relevant results. Search works out of the box and scales to massive data sizes with zero infrastructure to manage. 6 | 7 | ## 📖 Documentation 8 | 9 | To understand Upstash Search and how it works, see the [Upstash Search documentation](https://upstash.com/docs/search/overall/whatisupstashsearch). 10 | 11 | To understand Strapi and how to create an app, see [Strapi's documentation](https://strapi.io/documentation/developer-docs/latest/getting-started/introduction.html). 12 | 13 | ## 🔧 Installation 14 | 15 | This package works with [Strapi v5](https://docs.strapi.io/dev-docs/intro). 16 | 17 | Inside your Strapi app, add the package: 18 | 19 | With `npm`: 20 | 21 | ```bash 22 | 23 | npm install @upstash/strapi-plugin-upstash-search 24 | 25 | ``` 26 | 27 | With `yarn`: 28 | 29 | ```bash 30 | 31 | yarn add @upstash/strapi-plugin-upstash-search 32 | 33 | ``` 34 | 35 | ### 🔧 Create Upstash Search Database 36 | 37 | Before using the plugin, you need to create an Upstash Search database: 38 | 39 | 1. Go to the [Upstash Console](https://console.upstash.com/) 40 | 41 | 2. Navigate to the **Search** section 42 | 43 | 3. Click **Create Database** 44 | 45 | 4. Choose your region and configuration 46 | 47 | 5. Once created, copy your **REST URL** and **REST Token** from the database details 48 | 49 | ## 🚀 Getting started 50 | 51 | Now that you have installed the plugin and have a running Strapi app, let's go to the plugin page on your admin dashboard. 52 | 53 | On the left-navbar, `Upstash Search` appears under the `PLUGINS` category. If it does not, ensure that you have installed the plugin and re-build Strapi. 54 | 55 | ### 🤫 Add Credentials 56 | 57 | First, you need to configure credentials via the plugin page. The credentials are composed of: 58 | 59 | - **URL**: Your Upstash Search REST URL (e.g., `https://xxx-xxx-search.upstash.io`) 60 | 61 | - **Token**: Your Upstash Search REST Token 62 | 63 | 1. In your Strapi admin panel, navigate to **Upstash Search** in the plugins section 64 | 65 | 2. Click on the **Credentials** tab 66 | 67 | 3. Enter your **URL** (found in your Upstash console under REST URL) 68 | 69 | 4. Enter your **Token** (found in your Upstash console under REST Token) 70 | 71 | 5. Click **Save Credentials** 72 | 73 | You can find these credentials in your Upstash console Search tab. The plugin interface includes a direct link to the [Upstash Search documentation](https://upstash.com/docs/search/overall/getstarted) for reference. 74 | 75 | ### 🚛 Add your content-types to Upstash Search 76 | 77 | If you don't have any content-types yet in your Strapi project, please follow [Strapi quickstart](https://strapi.io/documentation/developer-docs/latest/getting-started/quick-start.html). 78 | 79 | #### Collections Tab 80 | 81 | 1. Navigate to the **Collections** tab in the Upstash Search plugin 82 | 83 | 2. You'll see all your available content-types displayed as cards with: 84 | 85 | - Content-type name (e.g., `restaurant`, `category`, `user`) 86 | 87 | - Number of entries 88 | 89 | - Current indexing status 90 | 91 | - A toggle switch to enable/disable indexing 92 | 93 | #### Adding Content-Types to Search 94 | 95 | To index a content-type: 96 | 97 | 1. **Click the toggle switch** next to a content-type to enable indexing 98 | 99 | 2. A **configuration modal** will open where you can: 100 | 101 | - **Select searchable fields**: Choose which fields should be searchable (required) 102 | 103 | - **Set custom index name**: Specify a custom index name 104 | 105 | 3. **Click confirm** to start indexing 106 | 107 | The plugin will show a progress indicator while indexing is in progress. Once complete, your content will be searchable in Upstash Search. 108 | 109 | ### 🪝 Apply Hooks 110 | 111 | Hooks are listeners that update Upstash Search each time you add/update/delete an entry in your content-types. They are automatically activated as soon as you enable indexing for a content-type. 112 | 113 | #### Removing Content-Types 114 | 115 | To remove a content-type from Upstash Search: 116 | 117 | 1. **Toggle off** the switch next to the content-type 118 | 119 | 2. The plugin will remove the content-type from indexing 120 | 121 | The plugin interface will reload the server when needed. 122 | 123 | ## 💅 Customization 124 | 125 | The Upstash Search plugin provides a simple UI-based configuration approach. All customization is done through the admin interface without requiring configuration files. 126 | 127 | ### 🏷 Custom Index Names 128 | 129 | When configuring a content-type for indexing, you can specify a custom index name in the configuration modal: 130 | 131 | - **Default behavior**: The plugin uses the content-type name as the index name by default 132 | 133 | - **Custom naming**: Enter any name you prefer (e.g., `my_restaurants` instead of `restaurant`) 134 | 135 | - **Shared indexes**: Multiple content-types can use the same index name to group them together 136 | 137 | ### 🔍 Searchable Fields Selection 138 | 139 | The configuration modal allows you to choose exactly which fields should be searchable: 140 | 141 | - **Required step**: You must select at least one field to enable indexing 142 | 143 | - **Field types**: All field types from your content-type schema are available 144 | 145 | - **Searchable vs Metadata**: Selected fields become searchable, while unselected fields are stored as metadata 146 | 147 | ### 🔄 Real-time Updates 148 | 149 | The plugin automatically: 150 | 151 | - Adds new entries to Upstash Search when created 152 | 153 | - Updates existing entries when modified 154 | 155 | - Removes entries from Upstash Search when deleted 156 | 157 | ### 🏗 Index Management 158 | 159 | Through the admin interface, you can: 160 | 161 | - Create and delete indexes 162 | 163 | - Monitor indexing progress 164 | 165 | - View index statistics 166 | 167 | - Rebuild indexes when needed 168 | 169 | ## 🤖 Compatibility with Upstash Search and Strapi 170 | 171 | **Supported Strapi versions**: 172 | 173 | - Strapi `>=v5.x.x` 174 | 175 | **Supported Node.js versions**: 176 | 177 | - Node.js >= 18 178 | 179 | **We recommend always using the latest version of Strapi to start your new projects**. 180 | 181 | ## ⚙️ Development Workflow and Contributing 182 | 183 | Any new contribution is more than welcome in this project! 184 | 185 | If you want to know more about the development workflow or want to contribute, please visit our contributing guidelines for detailed instructions! 186 | 187 | ## 🌎 Community support 188 | 189 | - For general help using **Upstash Search**, please refer to [the official Upstash documentation](https://upstash.com/docs). 190 | 191 | - For general help using **Strapi**, please refer to [the official Strapi documentation](https://strapi.io/documentation/). 192 | 193 | - Contact [Upstash support](https://upstash.com/docs/common/help/support) for Upstash-specific issues 194 | 195 | - Join the [Strapi community Slack](https://slack.strapi.io/) for general Strapi help 196 | 197 | ## Features 198 | 199 | - ✅ **UI-based Configuration**: No configuration files required - set everything up through the admin interface 200 | 201 | - ✅ **Real-time Synchronization**: Automatic sync between Strapi and Upstash Search 202 | 203 | - ✅ **Custom Index Names**: Configure custom index names for better organization 204 | 205 | - ✅ **Searchable Field Selection**: Choose which fields to make searchable 206 | 207 | - ✅ **Draft/Published Handling**: Proper handling of content states 208 | 209 | - ✅ **Batch Operations**: Efficient batch processing for large datasets 210 | 211 | - ✅ **Index Management**: Create, delete, and monitor indexes through the UI 212 | 213 | - ✅ **Error Handling**: Comprehensive error handling and logging 214 | 215 | - ✅ **Multi-index Support**: Support for multiple indexes per content-type 216 | 217 | ## Getting Help 218 | 219 | If you encounter any issues or have questions: 220 | 221 | 1. Check the [Upstash documentation](https://upstash.com/docs/vector) 222 | 223 | 2. Review the [Strapi documentation](https://strapi.io/documentation/) 224 | 225 | 3. Search existing GitHub issues 226 | 227 | 4. Create a new issue with detailed information about your problem 228 | -------------------------------------------------------------------------------- /admin/src/components/collections/CollectionTable.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Flex, 5 | Grid, 6 | Typography, 7 | EmptyStateLayout, 8 | ProgressBar, 9 | } from '@strapi/design-system'; 10 | import { Plus, Database, Cross } from '@strapi/icons'; 11 | import { private_useAutoReloadOverlayBlocker, useFetchClient } from '@strapi/strapi/admin'; 12 | 13 | import { memo, useEffect, useState } from 'react'; 14 | 15 | import useCollection from '../../hooks/useCollection'; 16 | import { PLUGIN_ID } from '../../pluginId'; 17 | import CollectionColumn from './CollectionColumn'; 18 | import { serverRestartWatcher } from '../../utils/serverRestartWatcher'; 19 | import { SkeletonCard } from './CardSkeleton'; 20 | 21 | const Collection = () => { 22 | const { 23 | collections, 24 | deleteCollection, 25 | addCollection, 26 | updateCollection, 27 | reloadNeeded, 28 | refetchCollection, 29 | } = useCollection(); 30 | const { lockAppWithAutoreload, unlockAppWithAutoreload } = private_useAutoReloadOverlayBlocker(); 31 | const { get } = useFetchClient(); 32 | 33 | const [reload, setReload] = useState(false); 34 | const [isLoading, setIsLoading] = useState(true); 35 | const [hasInitialLoad, setHasInitialLoad] = useState(false); 36 | 37 | /** 38 | * Reload the servers and wait for the server to be reloaded. 39 | */ 40 | const reloadServer = async () => { 41 | try { 42 | lockAppWithAutoreload(); 43 | await get(`/${PLUGIN_ID}/reload`); 44 | await serverRestartWatcher(true); 45 | setReload(false); 46 | } catch (err) { 47 | console.error(err); 48 | } finally { 49 | unlockAppWithAutoreload(); 50 | refetchCollection(); 51 | } 52 | }; 53 | 54 | useEffect(() => { 55 | if (reload) reloadServer(); 56 | }, [reload]); 57 | 58 | // Automatically reload when needed 59 | useEffect(() => { 60 | if (reloadNeeded) { 61 | setReload(true); 62 | } 63 | }, [reloadNeeded]); 64 | 65 | useEffect(() => { 66 | if (collections !== undefined) { 67 | if (!hasInitialLoad) { 68 | setTimeout(() => { 69 | setIsLoading(false); 70 | setHasInitialLoad(true); 71 | }, 500); 72 | } else { 73 | setIsLoading(false); 74 | } 75 | } 76 | }, [collections, hasInitialLoad]); 77 | 78 | return ( 79 | 80 | {isLoading ? ( 81 | 89 | 90 | 91 | 92 | 93 | 94 | ) : collections.length > 0 ? ( 95 | 103 | {collections.map((collection) => ( 104 | ) => { 119 | e.currentTarget.style.transform = 'translateY(-2px)'; 120 | e.currentTarget.style.boxShadow = '0px 4px 8px rgba(33, 33, 52, 0.12)'; 121 | }} 122 | onMouseLeave={(e: React.MouseEvent) => { 123 | e.currentTarget.style.transform = 'translateY(0)'; 124 | e.currentTarget.style.boxShadow = '0px 1px 4px rgba(33, 33, 52, 0.1)'; 125 | }} 126 | role="article" 127 | aria-labelledby={`collection-${collection.contentType}-title`} 128 | > 129 | 130 | 140 | 147 | 148 | 149 | 150 | 158 | {collection.collection} 159 | 160 | 161 | {collection.indexUid && collection.indexUid.length > 0 162 | ? collection.indexUid 163 | : 'Default Index'} 164 | 165 | 166 | 167 | 168 | 169 | 178 | 183 | {collection.indexed ? 'Active' : 'Inactive'} 184 | 185 | 186 | 187 | {collection.indexed && collection.isIndexing && ( 188 | 197 | 198 | Indexing... 199 | 200 | 201 | )} 202 | 203 | 204 | 205 | 206 | 214 | Indexed Documents 215 | 216 | 225 | {collection.indexed 226 | ? `${collection.numberOfEntries} / ${collection.numberOfEntries}` 227 | : `0 / ${collection.numberOfEntries}`} 228 | 229 | 230 | 231 | 232 | 233 | 234 | 240 | 241 | 242 | ))} 243 | 244 | ) : ( 245 | 250 | ); 251 | }; 252 | 253 | export default memo(Collection); 254 | -------------------------------------------------------------------------------- /admin/src/components/collections/CollectionColumn.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useMemo, useState } from 'react'; 2 | import { 3 | Checkbox, 4 | Box, 5 | Button, 6 | Flex, 7 | Typography, 8 | Modal, 9 | Grid, 10 | TextInput, 11 | Switch, 12 | } from '@strapi/design-system'; 13 | import { CheckCircle, Cog } from '@strapi/icons'; 14 | import { useRBAC } from '@strapi/strapi/admin'; 15 | import { PERMISSIONS } from '../../constants'; 16 | import type { Collection } from '../../hooks/useCollection'; 17 | 18 | type CollectionColumnProps = { 19 | entry: Collection; 20 | deleteCollection: ({ contentType }: { contentType: string }) => void; 21 | addCollection: ({ 22 | contentType, 23 | searchableFields, 24 | indexUids, 25 | }: { 26 | contentType: string; 27 | searchableFields?: string[]; 28 | indexUids?: string[]; 29 | }) => void; 30 | updateCollection: ({ contentType }: { contentType: string }) => void; 31 | }; 32 | 33 | const CollectionColumn = ({ entry, deleteCollection, addCollection }: CollectionColumnProps) => { 34 | const { 35 | allowedActions: { canCreate, canDelete }, 36 | } = useRBAC(PERMISSIONS.collections); 37 | 38 | // Modal state 39 | const [open, setOpen] = useState(false); 40 | const [selected, setSelected] = useState([]); 41 | const [customIndexName, setCustomIndexName] = useState(entry.indexUid); 42 | const allFields = useMemo(() => entry.fields || [], [entry.fields]); 43 | 44 | const toggleField = (name: string) => { 45 | setSelected((prev) => (prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name])); 46 | }; 47 | 48 | const onConfirmAdd = async () => { 49 | if (selected.length === 0) return; 50 | 51 | // Use custom index name if provided, otherwise let system use default 52 | const indexUids = customIndexName.trim() ? [customIndexName.trim()] : undefined; 53 | 54 | await addCollection({ 55 | contentType: entry.contentType, 56 | searchableFields: selected, 57 | indexUids, 58 | }); 59 | setOpen(false); 60 | setSelected([]); 61 | setCustomIndexName(''); 62 | }; 63 | 64 | const handleModalClose = () => { 65 | setOpen(false); 66 | setSelected([]); 67 | setCustomIndexName(''); 68 | }; 69 | 70 | const handleSwitchChange = (checked: boolean) => { 71 | if (checked) { 72 | // If turning on, open configuration modal 73 | setOpen(true); 74 | } else { 75 | // If turning off, remove from index 76 | deleteCollection({ contentType: entry.contentType }); 77 | } 78 | }; 79 | 80 | // Disable switch if there are no entries to index 81 | const hasEntries = entry.numberOfEntries > 0; 82 | const switchDisabled = !hasEntries; 83 | 84 | return ( 85 | 86 | {/* Toggle Section with proper accessibility */} 87 | {(canCreate || canDelete) && ( 88 | 89 | 90 | 97 | {entry.indexed ? 'Disable Indexing' : 'Enable Indexing'} 98 | 99 | 100 | 101 | 109 | 119 | 120 | 121 | 122 | 129 | {switchDisabled 130 | ? 'No entries to index' 131 | : entry.indexed 132 | ? 'Collection is being indexed' 133 | : 'Toggle to start indexing'} 134 | 135 | 136 | 137 | )} 138 | 139 | {/* Enhanced Configuration Modal */} 140 | 141 | 145 | 146 | 147 | 148 | Configure {entry.collection} 149 | 150 | 151 | 152 | 153 | 154 | 155 | {/* Index Configuration Section */} 156 | 157 | 158 | 159 | Index Name 160 | 161 | 162 | ) => 166 | setCustomIndexName(e.target.value) 167 | } 168 | placeholder={`${entry.collection}`} 169 | size="M" 170 | /> 171 | 172 | {/* Index name required error - right below input */} 173 | {customIndexName.trim() === '' && ( 174 | 175 | 176 | Index name is required 177 | 178 | 179 | )} 180 | 181 | 182 | {/* Searchable Fields Section */} 183 | 184 | 185 | 186 | Searchable Fields 187 | 188 | 189 | 190 | 191 | {allFields.map((name) => ( 192 | 193 | 194 | toggleField(name)} 197 | size="S" 198 | /> 199 | toggleField(name)} 205 | > 206 | {name} 207 | 208 | 209 | 210 | ))} 211 | 212 | {allFields.length === 0 && ( 213 | 214 | 215 | No fields found 216 | 217 | 218 | )} 219 | 220 | 221 | {/* Simple error message */} 222 | {selected.length === 0 && allFields.length > 0 && ( 223 | 224 | 225 | Please select at least one field 226 | 227 | 228 | )} 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 245 | 246 | 247 | 248 | 249 | 250 | ); 251 | }; 252 | 253 | export default memo(CollectionColumn); 254 | -------------------------------------------------------------------------------- /server/src/services/upstash-search/connector.ts: -------------------------------------------------------------------------------- 1 | import UpstashSearch from './client'; 2 | import type { AdapterService } from './adapter'; 3 | import { StoreService } from '../store'; 4 | import { ContentTypeService } from '../content-types'; 5 | import { LifecycleService } from '../lifecycle'; 6 | import type { Core } from '@strapi/strapi'; 7 | 8 | const sanitizeEntries = async function ({ 9 | contentType, 10 | entries, 11 | adapter, 12 | }: { 13 | contentType: string; 14 | entries: Record[]; 15 | adapter: AdapterService; 16 | }) { 17 | if (!Array.isArray(entries)) entries = [entries]; 18 | 19 | // Filter out unpublished entries to prevent duplicates 20 | entries = entries.filter((entry) => { 21 | // Only index published entries 22 | if (entry.publishedAt === null || entry.publishedAt === undefined) { 23 | return false; 24 | } 25 | return true; 26 | }); 27 | 28 | // Add content-type prefix to id 29 | entries = await adapter.addCollectionNamePrefix({ 30 | contentType, 31 | entries, 32 | }); 33 | 34 | return entries; 35 | }; 36 | 37 | function buildUpstashPayload(document: Record, searchableFields?: string[]) { 38 | const { _upstash_search_id, ...rest } = document; 39 | 40 | if (!Array.isArray(searchableFields) || searchableFields.length === 0) { 41 | return { 42 | id: _upstash_search_id, 43 | content: rest, 44 | metadata: {}, 45 | }; 46 | } 47 | 48 | const content: Record = {}; 49 | const metadata: Record = {}; 50 | const pick = new Set(searchableFields); 51 | 52 | Object.keys(rest).forEach((key) => { 53 | if (pick.has(key)) { 54 | content[key] = rest[key]; 55 | } else { 56 | metadata[key] = rest[key]; 57 | } 58 | }); 59 | 60 | return { id: _upstash_search_id, content, metadata }; 61 | } 62 | 63 | const connectorService = ({ 64 | strapi, 65 | adapter, 66 | }: { 67 | strapi: Core.Strapi; 68 | adapter: AdapterService; 69 | }) => { 70 | const store = strapi.plugin('upstash-search').service('store') as StoreService; 71 | const contentTypeService = strapi 72 | .plugin('upstash-search') 73 | .service('contentType') as ContentTypeService; 74 | const lifecycle = strapi.plugin('upstash-search').service('lifecycle') as LifecycleService; 75 | 76 | return { 77 | getIndexUids: async function () { 78 | try { 79 | const { apiKey, host } = await store.getCredentials(); 80 | 81 | const client = UpstashSearch({ token: apiKey, url: host }); 82 | const indexes = await client.listIndexes(); 83 | return indexes; 84 | } catch (e) { 85 | strapi.log.error(`upstash-search: ${e.message}`); 86 | return []; 87 | } 88 | }, 89 | 90 | deleteEntriesFromUpstashSearch: async function ({ 91 | contentType, 92 | entriesId, 93 | }: { 94 | contentType: string; 95 | entriesId: number[]; 96 | }) { 97 | const { apiKey, host } = await store.getCredentials(); 98 | 99 | const client = UpstashSearch({ token: apiKey, url: host }); 100 | 101 | const indexUids = await store.getIndexNamesOfContentType({ contentType }); 102 | const documentsIds = entriesId.map((entryId) => 103 | adapter.addCollectionNamePrefixToId({ entryId, contentType }) 104 | ); 105 | 106 | const tasks = await Promise.all( 107 | indexUids.map(async (indexUid) => { 108 | const task = await client.index(indexUid).delete(documentsIds); 109 | 110 | strapi.log.info( 111 | `A task to delete ${documentsIds.length} documents of the index "${indexUid}" in Upstash Search has been enqueued.` 112 | ); 113 | 114 | return task; 115 | }) 116 | ); 117 | 118 | return tasks.flat(); 119 | }, 120 | 121 | updateEntriesInUpstashSearch: async function ({ 122 | contentType, 123 | entries, 124 | searchableFields, 125 | }: { 126 | contentType: string; 127 | entries: Record[]; 128 | searchableFields?: string[]; 129 | }) { 130 | const { apiKey, host } = await store.getCredentials(); 131 | 132 | const client = UpstashSearch({ token: apiKey, url: host }); 133 | 134 | if (!Array.isArray(entries)) entries = [entries]; 135 | 136 | const indexUids = await store.getIndexNamesOfContentType({ contentType }); 137 | 138 | const addDocuments = await sanitizeEntries({ 139 | contentType, 140 | entries, 141 | adapter, 142 | }); 143 | 144 | // Check which documents are not in sanitized documents and need to be deleted 145 | const deleteDocuments = entries.filter( 146 | (entry) => !addDocuments.map((document) => document.id).includes(entry.id) 147 | ); 148 | // Collect delete tasks 149 | const deleteTasks = await Promise.all( 150 | indexUids.map(async (indexUid) => { 151 | const tasks = await Promise.all( 152 | deleteDocuments.map(async (document) => { 153 | const task = await client.index(indexUid).delete( 154 | adapter.addCollectionNamePrefixToId({ 155 | contentType, 156 | entryId: document.id, 157 | }) 158 | ); 159 | 160 | strapi.log.info( 161 | `A task to delete one document from the Upstash Search index "${indexUid}" has been enqueued.` 162 | ); 163 | 164 | return task; 165 | }) 166 | ); 167 | return tasks; 168 | }) 169 | ); 170 | 171 | // Collect update tasks 172 | const updateTasks = await Promise.all( 173 | indexUids.map(async (indexUid) => { 174 | const task = client 175 | .index(indexUid) 176 | .upsert( 177 | addDocuments.map((document) => buildUpstashPayload(document, searchableFields)) 178 | ); 179 | 180 | strapi.log.info( 181 | `A task to update ${addDocuments.length} documents to the Upstash Search index "${indexUid}" has been enqueued.` 182 | ); 183 | 184 | return task; 185 | }) 186 | ); 187 | 188 | return [...deleteTasks.flat(), ...updateTasks]; 189 | }, 190 | 191 | getStats: async function ({ indexUid }: { indexUid: string }) { 192 | try { 193 | const { apiKey, host } = await store.getCredentials(); 194 | 195 | const client = UpstashSearch({ token: apiKey, url: host }); 196 | const stats = await client.index(indexUid).info(); 197 | 198 | return { 199 | numberOfDocuments: stats.documentCount || 0, 200 | isIndexing: stats.pendingDocumentCount > 0, 201 | }; 202 | } catch (e) { 203 | return { 204 | numberOfDocuments: 0, 205 | isIndexing: false, 206 | }; 207 | } 208 | }, 209 | 210 | getContentTypesReport: async function () { 211 | const indexUids = await this.getIndexUids(); 212 | 213 | // All listened contentTypes 214 | const listenedContentTypes = await store.getListenedContentTypes(); 215 | // All indexed contentTypes 216 | const indexedContentTypes = await store.getIndexedContentTypes(); 217 | 218 | const contentTypes = contentTypeService.getContentTypesUid(); 219 | 220 | const reports = await Promise.all( 221 | contentTypes.flatMap(async (contentType) => { 222 | const collectionName = contentTypeService.getCollectionName({ 223 | contentType, 224 | }); 225 | const indexUidsForContentType = await store.getIndexNamesOfContentType({ 226 | contentType, 227 | }); 228 | return Promise.all( 229 | indexUidsForContentType.map(async (indexUid) => { 230 | const indexInUpstashSearch = indexUids.includes(indexUid); 231 | const contentTypeInIndexStore = indexedContentTypes.find( 232 | (ct) => ct.contentType === contentType 233 | ); 234 | const indexed = indexInUpstashSearch && contentTypeInIndexStore; 235 | 236 | // safe guard in case index does not exist anymore in Upstash Search 237 | if (!indexInUpstashSearch && contentTypeInIndexStore) { 238 | await store.removeIndexedContentType({ contentType }); 239 | } 240 | 241 | const { numberOfDocuments, isIndexing } = indexed 242 | ? await this.getStats({ indexUid }) 243 | : { isIndexing: false, numberOfDocuments: 0 }; 244 | 245 | const attrs = strapi.contentTypes[contentType]?.attributes || {}; 246 | const fieldNames = Object.keys(attrs).sort(); 247 | 248 | const numberOfEntries = await contentTypeService.numberOfEntries({ 249 | contentType, 250 | }); 251 | return { 252 | collection: collectionName, 253 | contentType: contentType, 254 | indexUid, 255 | indexed, 256 | isIndexing, 257 | numberOfDocuments, 258 | numberOfEntries, 259 | listened: listenedContentTypes.includes(contentType), 260 | fields: fieldNames, 261 | }; 262 | }) 263 | ); 264 | }) 265 | ); 266 | return { contentTypes: reports.flat() }; 267 | }, 268 | 269 | addEntriesToUpstashSearch: async function ({ 270 | contentType, 271 | entries, 272 | searchableFields, 273 | }: { 274 | contentType: string; 275 | entries: Record[]; 276 | searchableFields?: string[]; 277 | }) { 278 | const { apiKey, host } = await store.getCredentials(); 279 | 280 | const client = UpstashSearch({ token: apiKey, url: host }); 281 | 282 | if (!Array.isArray(entries)) entries = [entries]; 283 | 284 | const indexUids = await store.getIndexNamesOfContentType({ contentType }); 285 | const documents = await sanitizeEntries({ 286 | contentType, 287 | entries, 288 | adapter, 289 | }); 290 | 291 | const tasks = await Promise.all( 292 | indexUids.map(async (indexUid) => { 293 | const task = await client 294 | .index(indexUid) 295 | .upsert(documents.map((document) => buildUpstashPayload(document, searchableFields))); 296 | 297 | strapi.log.info( 298 | `The task to add ${documents.length} documents to the Upstash Search index "${indexUid}" has been enqueued.` 299 | ); 300 | return task; 301 | }) 302 | ); 303 | 304 | // Register this content type as indexed 305 | await store.addIndexedContentType({ contentType, searchableFields }); 306 | 307 | return tasks.flat(); 308 | }, 309 | 310 | addContentTypeInUpstashSearch: async function ({ 311 | contentType, 312 | searchableFields, 313 | indexUids, 314 | }: { 315 | contentType: string; 316 | searchableFields?: string[]; 317 | indexUids?: string[]; 318 | }) { 319 | const { apiKey, host } = await store.getCredentials(); 320 | 321 | const client = UpstashSearch({ token: apiKey, url: host }); 322 | await store.setIndexNamesForContentType({ contentType, indexNames: indexUids }); 323 | const indexNames = indexUids || (await store.getIndexNamesOfContentType({ contentType })); 324 | 325 | // Callback function for batching action 326 | const addDocuments = async ({ entries, contentType }) => { 327 | // Sanitize entries 328 | const documents = await sanitizeEntries({ 329 | contentType, 330 | entries, 331 | adapter, 332 | }); 333 | 334 | // Add documents in Upstash Search 335 | const taskUids = await Promise.all( 336 | indexNames.map(async (indexUid) => { 337 | const taskUid = await client 338 | .index(indexUid) 339 | .upsert(documents.map((document) => buildUpstashPayload(document, searchableFields))); 340 | 341 | strapi.log.info( 342 | `A task to add ${documents.length} documents to the Upstash Search index "${indexUid}" has been enqueued.` 343 | ); 344 | 345 | return taskUid; 346 | }) 347 | ); 348 | 349 | return taskUids.flat(); 350 | }; 351 | 352 | const tasksUids = await contentTypeService.actionInBatches({ 353 | contentType, 354 | callback: addDocuments, 355 | }); 356 | 357 | await store.addIndexedContentType({ contentType, searchableFields }); 358 | await lifecycle.subscribeContentType({ contentType, searchableFields }); 359 | 360 | return tasksUids; 361 | }, 362 | 363 | getContentTypesWithSameIndex: async function ({ contentType }: { contentType: string }) { 364 | const indexUids = await store.getIndexNamesOfContentType({ contentType }); 365 | 366 | // Initialize an empty array to hold contentTypes with the same index names 367 | let contentTypesWithSameIndex = []; 368 | 369 | // Iterate over each indexUid to fetch and accumulate contentTypes that have the same indexName 370 | for (const indexUid of indexUids) { 371 | const contentTypesForCurrentIndex = await this.listContentTypesWithCustomIndexName({ 372 | indexName: indexUid, 373 | }); 374 | 375 | contentTypesWithSameIndex = [...contentTypesWithSameIndex, ...contentTypesForCurrentIndex]; 376 | } 377 | 378 | // Remove duplicates 379 | contentTypesWithSameIndex = [...new Set(contentTypesWithSameIndex)]; 380 | 381 | // Get all contentTypes (not indexes) indexed in Upstash Search. 382 | const indexedContentTypes = await store.getIndexedContentTypes(); 383 | 384 | // Take intersection of both arrays 385 | const indexedContentTypesWithSameIndex = indexedContentTypes.filter((ct) => 386 | contentTypesWithSameIndex.includes(ct.contentType) 387 | ); 388 | 389 | return indexedContentTypesWithSameIndex; 390 | }, 391 | 392 | listContentTypesWithCustomIndexName: async function ({ indexName }: { indexName: string }) { 393 | const contentTypes = (contentTypeService.getContentTypesUid() as string[]) || []; 394 | 395 | const matchingContentTypes = []; 396 | for (const contentTypeUid of contentTypes) { 397 | const indexNames = await store.getIndexNamesOfContentType({ 398 | contentType: contentTypeUid, 399 | }); 400 | if (indexNames.includes(indexName)) { 401 | matchingContentTypes.push(contentTypeUid); 402 | } 403 | } 404 | 405 | return matchingContentTypes; 406 | }, 407 | 408 | emptyOrDeleteIndex: async function ({ contentType }: { contentType: string }) { 409 | const indexedContentTypesWithSameIndex = await this.getContentTypesWithSameIndex({ 410 | contentType, 411 | }); 412 | if (indexedContentTypesWithSameIndex.length > 1) { 413 | const deleteEntries = async ({ entries, contentType }) => { 414 | await this.deleteEntriesFromUpstashSearch({ 415 | contentType, 416 | entriesId: entries.map((entry) => entry.id), 417 | }); 418 | }; 419 | await contentTypeService.actionInBatches({ 420 | contentType, 421 | callback: deleteEntries, 422 | }); 423 | } else { 424 | const { apiKey, host } = await store.getCredentials(); 425 | 426 | const client = UpstashSearch({ token: apiKey, url: host }); 427 | 428 | const indexUids = await store.getIndexNamesOfContentType({ contentType }); 429 | await Promise.all( 430 | indexUids.map(async (indexUid) => { 431 | const response = await client.index(indexUid).deleteIndex(); 432 | strapi.log.info( 433 | `A task to delete the Upstash Search index "${indexUid}" has been added to the queue.` 434 | ); 435 | return response; 436 | }) 437 | ); 438 | } 439 | 440 | await store.removeIndexedContentType({ contentType }); 441 | }, 442 | 443 | updateContentTypeInUpstashSearch: async function ({ contentType }: { contentType: string }) { 444 | const indexedContentTypes = await store.getIndexedContentTypes(); 445 | if (indexedContentTypes.find((ct) => ct.contentType === contentType)) { 446 | await this.emptyOrDeleteIndex({ contentType }); 447 | } 448 | const searchableFields = indexedContentTypes.find( 449 | (ct) => ct.contentType === contentType 450 | )?.searchableFields; 451 | return this.addContentTypeInUpstashSearch({ contentType, searchableFields }); 452 | }, 453 | }; 454 | }; 455 | 456 | export type ConnectorService = ReturnType; 457 | 458 | export default connectorService; 459 | --------------------------------------------------------------------------------