Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 135 additions & 15 deletions packages/backend/src/core/SearchService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { CacheService } from '@/core/CacheService.js';
import { QueryService } from '@/core/QueryService.js';
import { IdService } from '@/core/IdService.js';
import { LoggerService } from '@/core/LoggerService.js';
import Logger from '@/logger.js';
import type { Index, MeiliSearch } from 'meilisearch';

type K = string;
Expand Down Expand Up @@ -46,6 +47,17 @@ export type SearchPagination = {
limit: number;
};

export type ReIndexNotesResult = {
/** 再インデックスしたノートの数 */
fetchedCount: number;
/** 最後にインデックスしたノートのID */
lastNoteId?: MiNote['id'];
/** 最後にインデックスしたノートの投稿日時 */
lastNoteDate?: Date;
/** 再インデックスに失敗したノートのID */
errorNoteIds: MiNote['id'][];
};

function compileValue(value: V): string {
if (typeof value === 'string') {
return `'${value}'`; // TODO: escape
Expand Down Expand Up @@ -76,9 +88,13 @@ function compileQuery(q: Q): string {

@Injectable()
export class SearchService {
public static MeilisearchNotActiveError = class extends Error {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IdentifiableError にするかこういうふうにするかをそろそろ定めたほうが良さそう...

(IdentifiableErrorのuuidを static 変数にすればいいかなと思ってる)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

エラーを定義する方針を決めるIssue、立てますか。

Comment thread
samunohito marked this conversation as resolved.
};

private readonly meilisearchIndexScope: 'local' | 'global' | string[] = 'local';
private readonly meilisearchNoteIndex: Index | null = null;
private readonly provider: FulltextSearchProvider;
private readonly logger: Logger;

constructor(
@Inject(DI.config)
Expand Down Expand Up @@ -126,7 +142,24 @@ export class SearchService {
}

this.provider = config.fulltextSearch?.provider ?? 'sqlLike';
this.loggerService.getLogger('SearchService').info(`-- Provider: ${this.provider}`);
this.logger = this.loggerService.getLogger('SearchService');
this.logger.info(`-- Provider: ${this.provider}`);
}

@bindThis
private async addDocuments(notes: Pick<MiNote, 'id' | 'userId' | 'userHost' | 'channelId' | 'cw' | 'text' | 'tags'>[]) {
return this.meilisearchNoteIndex?.addDocuments(notes.map(note => ({
id: note.id,
createdAt: this.idService.parse(note.id).date.getTime(),
userId: note.userId,
userHost: note.userHost,
channelId: note.channelId,
cw: note.cw,
text: note.text,
tags: note.tags,
})), {
primaryKey: 'id',
});
}

@bindThis
Expand All @@ -150,18 +183,7 @@ export class SearchService {
}
}

await this.meilisearchNoteIndex?.addDocuments([{
id: note.id,
createdAt: this.idService.parse(note.id).date.getTime(),
userId: note.userId,
userHost: note.userHost,
channelId: note.channelId,
cw: note.cw,
text: note.text,
tags: note.tags,
}], {
primaryKey: 'id',
});
await this.addDocuments([note]);
}

@bindThis
Expand All @@ -172,6 +194,105 @@ export class SearchService {
await this.meilisearchNoteIndex?.deleteDocument(note.id);
}

@bindThis
public async unindexNoteAll() {
await this.meilisearchNoteIndex?.deleteAllDocuments();
}

/**
* 一定期間の間に投稿されたノートをmeilisearchに再インデックスする.
*/
@bindThis
public async reIndexNotes(props: {
sinceDate?: number | null;
untilDate?: number | null;
}): Promise<ReIndexNotesResult> {
if (this.provider !== 'meilisearch' || !this.meilisearch || !this.meilisearchNoteIndex) {
throw new SearchService.MeilisearchNotActiveError();
}

const fetchNote = (sinceId?: MiNote['id'], untilId?: MiNote['id'], offset?: number, limit?: number) => {
const query = this.notesRepository.createQueryBuilder('note')
// 速い条件だけ先に
.andWhere('note.visibility IN (:...visibilities)', { visibilities: ['home', 'public'] });

if (sinceId) {
query.andWhere('note.id > :sinceId', { sinceId });
}

if (untilId) {
query.andWhere('note.id < :untilId', { untilId });
}

query.andWhere('note.text IS NOT NULL OR note.cw IS NOT NULL');

switch (this.meilisearchIndexScope) {
case 'global': {
break;
}
case 'local': {
query.andWhere('note.userHost IS NULL');
break;
}
default: {
query.andWhere('note.userHost IN (:...userHosts)', { userHosts: this.meilisearchIndexScope });
break;
}
}

return query
.select([
'note.id',
'note.userId',
'note.userHost',
'note.channelId',
'note.cw',
'note.text',
'note.tags',
])
.orderBy('note.id', 'DESC')
.skip(offset)
.take(limit)
.getMany();
};

this.logger.info('-- Start Re-indexing notes...');

const sinceId = props.sinceDate ? this.idService.gen(props.sinceDate) : undefined;
const untilId = props.untilDate ? this.idService.gen(props.untilDate) : undefined;
const errorNoteIds: MiNote['id'][] = [];
let lastNoteId: MiNote['id'] | undefined = undefined;
let fetchedCount = 0;

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
const notes = await fetchNote(sinceId, untilId, fetchedCount, 100);
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pagination logic is incorrect. fetchedCount is passed as the take parameter but it's meant to track the total count of processed notes, not to paginate through results. The loop will always fetch the same notes because there's no cursor-based pagination (no lastNoteId is used in the next iteration). Change the logic to use lastNoteId as the untilId parameter for subsequent iterations: await fetchNote(sinceId, lastNoteId ?? untilId, undefined, 100).

Suggested change
const notes = await fetchNote(sinceId, untilId, fetchedCount, 100);
const notes = await fetchNote(sinceId, lastNoteId ?? untilId, 100, 100);

Copilot uses AI. Check for mistakes.
if (notes.length === 0) {
break;
}

try {
await this.addDocuments(notes);
} catch (err) {
this.logger.error('-- Failed to index note', err as Error);
errorNoteIds.push(...notes.map(n => n.id));
break;
}

lastNoteId = notes[notes.length - 1].id;
fetchedCount += notes.length;
}

this.logger.info(`-- Re-indexing finished. Total: ${fetchedCount}`);

return {
fetchedCount: fetchedCount,
lastNoteId: lastNoteId,
lastNoteDate: lastNoteId ? this.idService.parse(lastNoteId).date : undefined,
errorNoteIds: errorNoteIds,
};
}

@bindThis
public async searchNote(
q: string,
Expand All @@ -190,7 +311,6 @@ export class SearchService {
return this.searchNoteByMeiliSearch(q, me, opts, pagination);
}
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const typeCheck: never = this.provider;
return [];
}
Expand Down Expand Up @@ -222,7 +342,7 @@ export class SearchService {
if (this.config.fulltextSearch?.provider === 'sqlPgroonga') {
query.andWhere('note.text &@~ :q', { q });
} else {
query.andWhere('LOWER(note.text) LIKE :q', { q: `%${ sqlLikeEscape(q.toLowerCase()) }%` });
query.andWhere('LOWER(note.text) LIKE :q', { q: `%${sqlLikeEscape(q.toLowerCase())}%` });
}

if (opts.host) {
Expand Down
Loading