feat(search): add @tower/search package with Meilisearch client and helpers
Implements Task 2 of Plan 4 (Archive & Search): provides createMeiliClient, configureIndex, indexMessage, and deleteMessage for use by the worker and API. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
testMatch: ['**/*.test.ts'],
|
||||||
|
rootDir: 'src',
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@tower/search",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsc --watch",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"meilisearch": "^0.44.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jest": "^29.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"jest": "^29.0.0",
|
||||||
|
"ts-jest": "^29.0.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
MESSAGES_INDEX,
|
||||||
|
configureIndex,
|
||||||
|
indexMessage,
|
||||||
|
deleteMessage,
|
||||||
|
MeiliDocument,
|
||||||
|
} from './index';
|
||||||
|
|
||||||
|
function makeMockClient() {
|
||||||
|
const mockUpdateSettings = jest.fn().mockResolvedValue({});
|
||||||
|
const mockAddDocuments = jest.fn().mockResolvedValue({});
|
||||||
|
const mockDeleteDocument = jest.fn().mockResolvedValue({});
|
||||||
|
const mockIndex = jest.fn().mockReturnValue({
|
||||||
|
updateSettings: mockUpdateSettings,
|
||||||
|
addDocuments: mockAddDocuments,
|
||||||
|
deleteDocument: mockDeleteDocument,
|
||||||
|
});
|
||||||
|
return { client: { index: mockIndex } as any, mockIndex, mockUpdateSettings, mockAddDocuments, mockDeleteDocument };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MESSAGES_INDEX', () => {
|
||||||
|
it('is the string tower-messages', () => {
|
||||||
|
expect(MESSAGES_INDEX).toBe('tower-messages');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('configureIndex', () => {
|
||||||
|
it('sets searchable, filterable, and sortable attributes on the correct index', async () => {
|
||||||
|
const { client, mockIndex, mockUpdateSettings } = makeMockClient();
|
||||||
|
await configureIndex(client);
|
||||||
|
expect(mockIndex).toHaveBeenCalledWith('tower-messages');
|
||||||
|
expect(mockUpdateSettings).toHaveBeenCalledWith({
|
||||||
|
searchableAttributes: ['content', 'senderName', 'sourceGroupName'],
|
||||||
|
filterableAttributes: ['sourceGroupId', 'tags', 'platform'],
|
||||||
|
sortableAttributes: ['approvedAt'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('indexMessage', () => {
|
||||||
|
it('adds the document to the messages index', async () => {
|
||||||
|
const { client, mockIndex, mockAddDocuments } = makeMockClient();
|
||||||
|
const doc: MeiliDocument = {
|
||||||
|
id: 'msg-1',
|
||||||
|
content: 'Hello community',
|
||||||
|
senderName: 'Alice',
|
||||||
|
sourceGroupId: 'grp-1',
|
||||||
|
sourceGroupName: 'UP Parivar',
|
||||||
|
tags: ['#important'],
|
||||||
|
platform: 'whatsapp',
|
||||||
|
approvedAt: 1716825600000,
|
||||||
|
};
|
||||||
|
await indexMessage(client, doc);
|
||||||
|
expect(mockIndex).toHaveBeenCalledWith('tower-messages');
|
||||||
|
expect(mockAddDocuments).toHaveBeenCalledWith([doc]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteMessage', () => {
|
||||||
|
it('deletes document by id from the messages index', async () => {
|
||||||
|
const { client, mockIndex, mockDeleteDocument } = makeMockClient();
|
||||||
|
await deleteMessage(client, 'msg-1');
|
||||||
|
expect(mockIndex).toHaveBeenCalledWith('tower-messages');
|
||||||
|
expect(mockDeleteDocument).toHaveBeenCalledWith('msg-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { MeiliSearch } from 'meilisearch';
|
||||||
|
|
||||||
|
export { MeiliSearch } from 'meilisearch';
|
||||||
|
|
||||||
|
export interface MeiliDocument {
|
||||||
|
id: string; // DB Message.id
|
||||||
|
content: string;
|
||||||
|
senderName: string; // empty string when null
|
||||||
|
sourceGroupId: string;
|
||||||
|
sourceGroupName: string;
|
||||||
|
tags: string[];
|
||||||
|
platform: string;
|
||||||
|
approvedAt: number; // Unix ms — Meilisearch sorts numbers, not ISO strings
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MESSAGES_INDEX = 'tower-messages';
|
||||||
|
|
||||||
|
export function createMeiliClient(url: string, masterKey: string): MeiliSearch {
|
||||||
|
return new MeiliSearch({ host: url, apiKey: masterKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function configureIndex(client: MeiliSearch): Promise<void> {
|
||||||
|
await client.index(MESSAGES_INDEX).updateSettings({
|
||||||
|
searchableAttributes: ['content', 'senderName', 'sourceGroupName'],
|
||||||
|
filterableAttributes: ['sourceGroupId', 'tags', 'platform'],
|
||||||
|
sortableAttributes: ['approvedAt'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function indexMessage(client: MeiliSearch, doc: MeiliDocument): Promise<void> {
|
||||||
|
await client.index(MESSAGES_INDEX).addDocuments([doc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMessage(client: MeiliSearch, id: string): Promise<void> {
|
||||||
|
await client.index(MESSAGES_INDEX).deleteDocument(id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user