;
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 |
146 |
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 | }
247 | />
248 | )}
249 |
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 | }
242 | >
243 | {entry.indexed ? 'Update' : 'Enable'}
244 |
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 |
--------------------------------------------------------------------------------