Database search is okay, at best. To build awesome search experiences you need a dedicated search product.
Earlier this month we released Tigris Search in BETA and covered how it supports automatic synchronization infrastructure between Tigris Database and Tigris Search, which improves developer productivity by enabling the team to focus on shipping features and not setting up and managing search and synchronization infrastructure.
However, you may be looking to quickly enhance your application experience by adding search capabilities using existing data without waiting to migrate your database to Tigris. Or, your data may not be in a database, and it's in some other type of data store, such as S3.
Whatever the reason, the Tigris Standalone Search feature allows you to quickly add search to your existing data.
Tigris Standalone Search was released with Tigris Search BETAโ
As part of our release of Tigris Search BETA we also made it possible for you to take advantage of Tigris Search as a standalone feature.
Tigris Standalone Search can be used with any existing data store via integrating with our APIs and SDKs. This gives you full control to determine which indexes you create and maintain.
Here are some examples in TypeScript that demonstrate how to use Tigris Standalone Search. For a more in-depth example and a demo, checkout the search demo below.
Define your Search data model:
export const CATALOG_INDEX_NAME = "catalog";
@TigrisSearchIndex(CATALOG_INDEX_NAME)
export class Catalog {
@SearchField({ sort: true })
name: string;
@SearchField({ sort: true })
price: number;
@SearchField({ facet: true })
brand: string;
@SearchField({ elements: TigrisDataTypes.STRING, facet: true })
@Field({ elements: TigrisDataTypes.STRING })
tags: Array<string>;
}
Create your search index for the data model:
const client = new Tigris();
const search = client.getSearch();
const catalog = await search.createOrUpdateIndex<Catalog>(CATALOG_INDEX_NAME);
Insert a document in the search index:
const result = await catalog.createOne({
name: "fiona handbag",
price: 99.9,
brand: "michael kors",
tags: "purses",
});
That's it!
For more information, including indexing multiple documents, updating indexed documents, and deleted indexed documents, see the TypeScript Standalone Search docs.
Awesome email search demo with Resend & Tigris Searchโ
To demonstrate Tigris Search, we've created a demo application with Resend - an API platform that enables developers to build, test, and deliver transactional email at scale - that simulates a series of onboarding emails to be sent during the onboarding journey.
- Signed up = welcome
- After trigger X or Y amount of time = join the community
- After trigger X or Y amount of time = request feedback
After sending the emails, they are stored in a Tigris Search index. As the status of the emails change (delivered, bounced, complained, opened, clicked), Resend triggers a webhook which updates the Tigris search index with the updated email status. You can search across all the attributes of an email document within the Tigris Search index using:
- Full-text real-time search with fuzzy matching across all fields (status, to, from, subject, created [date], and email body)
- Filtering on email status
- Sorting by date
- Browsing via pagination
It's a simple but powerful use case of Tigris standalone search.
You can:
- ๐ซข Try out the Awesome Email Search demo
- ๐ป Grab the Awesome Email Search code on GitHub,
Or read through the code walk-through, below.
How to send transactional email with Resendโ
The demo application is built using Next.js and makes use of the Next.js 13 App Router.
When the Send email form is submitted, a route handles the POST
request.
export async function POST(request: Request) {
let emailIndex;
let statusCode = 400;
try {
const formData = await request.formData();
const stage = formData.get('stage');
if (!stage) {
throw new Error('"stage" is a required search parameter');
}
...
In the code above, we define a emailIndex
variable representing the Tigris
Search index we're going to create and also statusCode
variable to hold the
status code to be used in the POST
response. We grab the form data from the
POST
request and extract the stage
value. stage
maps to the email we'll
send: "Welcome", "Join the community", or "Request feedback". The code also
checks that a required stage
value was passed at all.
Next, we lookup the Resend email template and create the email:
if (!stage) {
throw new Error('"stage" is a required search parameter');
}
const emailTemplate = EmailTemplates[stage as string];
if (!emailTemplate) {
throw new Error(`Could not find email template for stage "${stage}"`);
}
const body = emailTemplate.template({
name: formData.get('name')?.valueOf() as string,
link: formData.get('link')?.valueOf() as string,
})!;
log('created email');
...
Here, we perform a lookup based on the stage
value and get a reference to the
email template details. The template
property is a react component - yep,
Resend allows you to
create emails using React -
and we create the body of the email, passing in name
and link
values that
have been submitted via the POST
request.
Now that we have the email contents, we can send the email:
log('created email');
const testEmailStatus = formData.get('testEmailStatus')
? TestEmailStatus[
formData.get('testEmailStatus') as unknown as TestEmailStatus
]
: '';
log(
'testEmailStatus',
testEmailStatus,
`[${formData.get('testEmailStatus')}]`
);
const sendEmailRequest: SendEmailData = {
from: process.env.DEFAULT_EMAIL as string,
to: [getToEmail(testEmailStatus)],
subject: emailTemplate.emailSubject,
react: body,
};
const resend = new Resend(process.env.RESEND_API_KEY);
const sendResponse = await resend.sendEmail(sendEmailRequest);
log('sent email');
statusCode = 201;
...
Resend provides several
test email addresses
(e.g. bounced@resend.dev
) you can use to test sending emails, so we map the
testEmailStatus
form data to a to
email address via the TestEmailStatus
lookup. We then create a SendEmailData
payload including from
, to
,
subject
, and body
information, and make a request to send the email via
await resend.sendEmail
. Finally, we set a statusCode
variable value of 201
to indicate the resource has been created.
How to index emails with Tigris Searchโ
Before we continue with the POST
handling code, let's look at how we define
our
Search data model
and create the search index.
First, we define an Email
class:
export const EMAIL_INDEX_NAME = "emails";
@TigrisSearchIndex(EMAIL_INDEX_NAME)
export class Email implements EmailResult {
@SearchField({ id: true })
id?: string;
@SearchField({ sort: true })
firstTo!: string;
@SearchField({ elements: TigrisDataTypes.STRING })
to!: string[];
@SearchField({ sort: true })
from!: string;
@SearchField({ sort: true })
subject!: string;
@SearchField()
body!: string;
@SearchField({ sort: true, facet: true })
status!: EmailStatus;
@SearchField({ sort: true })
createdAt!: Date;
}
The Email
class defines the Search document model to be created in an index
called emails
. Fields to be indexed are marked with a @SearchField
decorator, if they are to be sortable, sort
is set in the decorator, if they
are a potential facet
, they are also marked as such. Where possible, data
types are inferred. Since to
is an Array,
{ elements: TigrisDataTypes.STRING }
must be passed to the decorator.
With the Search data model defined, we need to create the search index:
import { Email } from "../src/db/models/email";
import { Tigris } from "@tigrisdata/core";
const main = async () => {
const client = new Tigris();
const search = client.getSearch();
await search.createOrUpdateIndex<Email>(Email);
};
main();
This simple script should be run whenever the application starts, data model
code is changed, or the application is built. It informs Tigris of the Search
Model structure and instructs Tigris to create a search index via the async call
to search.createOrUpdateIndex<Email>(Email)
.
Now that we've created the search index, we can head back to the POST
handling
function.
With the email sent, we can create the email document in the search index.
const sendResponse = await resend.sendEmail(sendEmailRequest);
log('sent email');
statusCode = 201;
const bodyString = reactElementToJSXString(body);
log('created body string', bodyString);
emailIndex: Email = {
to: sendEmailRequest.to as string[],
firstTo: sendEmailRequest.to[0] as string,
from: sendEmailRequest.from,
status: EmailStatus.Pending,
subject: emailTemplate.emailSubject,
body: bodyString,
createdAt: new Date(),
id: (sendResponse as EmailResponse).id,
};
log('creating index', emailIndex);
// uses environment variables for credentials
const tigris = new Tigris();
const search = tigris.getSearch();
const emails = await search.getIndex<Email>(EMAIL_INDEX_NAME);
const createResult = await emails.createOne(emailIndex);
if (createResult.error) {
console.error('Error occurred saving search index', createResult.error);
} else {
log('Index created', createResult);
}
...
The emailIndex
variable is assigned a structure matching the Email
class
data model we've defined. We then get the emails
index using
await search.getIndex<Email>(EMAIL_INDEX_NAME)
and then create the email
document within the index with await emails.createOne(emailIndex)
. Finally, we
check the result and log success or failure
A 201
even if the index creation fails?
I made the decision to still return a 201
if the email is sent but the search
index insertion fails. This is an interesting point for discussion.
With the email sent and the document inserted into the search index, we return the email index to the client if everything has gone as expected. Otherwise, we return a status code of 400 along with the error message.
if (createResult.error) {
console.error('Error occurred saving search index', createResult.error);
} else {
log('Index created', createResult);
}
} catch (ex: any) {
console.error(ex);
return NextResponse.json({ error: ex.toString() }, { status: statusCode });
}
return NextResponse.json(emailIndex, { status: statusCode });
}
Here's what the sending experience looks like which makes use of the POST
endpoint:
How to search with Tigris Searchโ
The whole point of adding documents to the search index is to enable us to build a search experience.
Let's walk through the GET
search handler:
export async function GET(request: Request) {
const response: SearchResponse = {
results: [],
meta: undefined,
error: undefined,
};
const { searchParams } = new URL(request.url);
log('searchParams', searchParams);
try {
const query = searchParams.get('search') || undefined;
const statuses = searchParams.get('statuses') || undefined;
const sortOrder = toSortOrder(searchParams.get('sortdir'));
// Number(null) = 0
const page = Number(searchParams.get('page'));
if (page <= 0) {
throw new Error(
'"page" is a required search parameter and must be a number greater than 0'
);
}
...
At the top of the GET
function, we create a response
to hold the query
result: a search result or an error. We also extract the searchParam
from the
request
and the following parameters:
query
: the textual querystatuses
: one or more of the valid email status valuessortdir
: this is sent asasc
ordesc
. We convert the latter into a sort order with the structure{ field: 'createdAt', order: '$desc' | '$asc' }
to be used with the Tigris SDK.page
: used for search pagination. Required by this endpoint.
Next, we perform the search using the Tigris SDK:
const tigris = new Tigris();
const search = tigris.getSearch();
const emails = await search.getIndex<Email>(EMAIL_INDEX_NAME);
const searchQuery: SearchQuery<Email> = {
q: query,
sort: sortOrder,
hitsPerPage: PAGE_SIZE,
};
if (statuses) {
const orFilter: Filter<Email>[] = statuses.split(',').map((status) => {
const enumAsStr = status as unknown as EmailStatus;
const asEnum = EmailStatus[enumAsStr];
log('map', status, enumAsStr, asEnum);
return {
status: asEnum,
} as Filter<Email>;
});
if (orFilter.length > 1) {
searchQuery.filter = {
$or: orFilter,
};
} else {
searchQuery.filter = orFilter[0];
}
}
log('searchQuery', JSON.stringify(searchQuery, null, 2));
const tigris = new Tigris();
const search = tigris.getSearch();
const queryResult = await emails.search(searchQuery, page);
log('queryResult', queryResult);
...
Above, we get the search index with
await search.getIndex<Email>(EMAIL_INDEX_NAME)
, and build a
SearchQuery<Email>
using query
, the sortOrder
(created via the sortdir
search parameter), and set a paging size (PAGE_SIZE
is defined elsewhere but
can be any numerical value. In this app we use 10
.).
If statuses
is populated it will be a comma-separated list of values (e.g.
Delivered,Bounced
) so we split the string by ,
, and map the string values to
emums representing the EmailStatus
values, and create a
search filter
on the status
search document property. If there's only one statuses
filter,
this is used standalone. If multiple statuses
have been sent, we need to split
the query into multiple $or
filters.
Finally, we execute the search with await emails.search(searchQuery, page)
,
passing in the SearchQuery<Email>
and page
representing the desired page
used in search pagination.
We're now ready to build the JSON response:
response.results = queryResult.hits.map((hit) => hit.document);
response.meta = queryResult.meta;
} catch (ex) {
console.error(ex);
response.error = ex as string;
}
return NextResponse.json(response);
}
Once we have the search result, we map
over the hits and create an Array of
hit.document
, which are of type Email
from our data model. We also pass the
queryResult.meta
information to the client via result.meta
. This contains
information such as the total number of search results, the current page, and
the page size.
With that, search functionality is in place.
How Resend webhooks trigger Tigris Search index updatesโ
The final step is to handle inbound webhooks from Resend that indicate when the
status of an email has changed. For example, when it's Delivered
, Opened
, or
when a link in the email is Clicked
.
export async function POST(request: Request) {
let statusCode = 500;
try {
const body = (await request.json()) as ResendWebhook;
log('WebHook', JSON.stringify(body, null, 2));
const newStatus: EmailStatus = WebHookToEmailStatusLookup[body.type];
log(`Updating status to`, newStatus);
...
The POST
function starts by assuming the worst and setting the HTTP response
statusCode
to 500
. It then gets the json
payload sent from Resend and
asserts it as a type defined by ResendWebhook
. We then perform a lookup to
determine the event type from the payload (Delivered
, Bounced
etc.).
Once we know the event type of Resend webhook, we want to retrieve the document from the search index that relates to the email the webhook is for:
log(`Updating status to`, newStatus);
log('getting index and document');
const emailsIndex = await search.getIndex<Email>(EMAIL_INDEX_NAME);
const toUpdate = await emailsIndex.getOne(body.data.email_id);
log('found document', JSON.stringify(toUpdate, null, 2));
if (toUpdate === undefined) {
statusCode = 404;
throw new Error(
`Could not find document in index for Email with id ${body.data.email_id}`
);
}
...
Here, we get the index and assign it to an emailsIndex
variable. We then use
the body.data.email_id
from the webhook to find the document to update with
emailsIndex.getOne(body.data.email_id)
. If the document can't be found, we set
the statusCode
to 404 and throw an
Error`.
If we've made it this far, we've found the document we want to update.
if (toUpdate === undefined) {
errorStatusCode = 404;
throw new Error(
`Could not find document in index for Email with id ${body.data.email_id}`
);
}
toUpdate.document.status = newStatus;
const updateStatus = await emailsIndex.updateOne(toUpdate.document);
log('Update result', JSON.stringify(updateStatus, null, 2));
if (updateStatus.error) {
const msg = 'Error updating indexed document:' + updateStatus.error;
console.error(msg);
throw new Error(msg);
}
statusCode = 200;
} catch (ex) {
console.error(ex);
}
return NextResponse.json({}, { status: statusCode });
}
So, we update the status
of the document with the new status from the webhook
and perform an update with emailsIndex.updateOne(toUpdate.document)
. We then
check the status of the update, and if it was successful set the statusCode
to
200
. Finally, we send a response using
NextResponse.json({}, { status: statusCode })
which make use of the
statusCode
we've been setting throughout the POST
function. A 200
tells
Resend that the webhook was successfully handled. Error response codes are
handled differently and may result in retries.
Try the demo to get started with Tigris Search and Resendโ
Ok, now you're definitely ready to:
- ๐ซข Try out the Awesome Email Search demo
- ๐ป Grab the Awesome Email Search code on GitHub,
Please also consider joining the Tigris Discord, following @TigrisData on Twitter, and giving Tigris a Star on GitHub.
Have questions? Get in touch.