索引字段、过滤、快照指纹与删除
策略的具体实现如下。
ts
export class TextSearch<Document extends { id: string }> {
private readonly options;
private readonly stored: readonly string[];
private readonly inputFields: readonly string[];
private readonly configurationFingerprint: string;
private index: MiniSearch<Document>;
/**
* Compile the index configuration and validate it before any document is added.
*
* @param registration - Format revision, indexed and stored fields, optional tokenizer,
* boosts and combination mode; handlers are bound to this object.
* @throws TypeError For a missing revision, empty/duplicate/blank field declarations, stored
* fields that collide with search metadata, an invalid tokenizer or combination, or a
* boost for an undeclared field or with a non-positive value.
*/
constructor(registration: TextSearchOptions<Document>) {
const definition = {
indexRevision: registration.indexRevision,
fields: registration.fields,
storedFields: registration.storedFields,
tokenize: registration.tokenize,
boost: registration.boost,
combineWith: registration.combineWith,
};
const reserved = new Set(['id', 'score', 'terms', 'queryTerms', 'match']);
breakWhile(
[
{
occur: () =>
!definition ||
typeof definition.indexRevision !== 'string' ||
!definition.indexRevision.trim(),
error: 'Text search requires an application format revision',
},
{
occur: () =>
!Array.isArray(definition.fields) ||
!definition.fields.length ||
!Array.isArray(definition.storedFields),
error: 'Text search requires field declarations',
},
{
occur: () =>
[...definition.fields, ...definition.storedFields].some(
(f) => typeof f !== 'string' || !f.trim(),
),
error: 'Invalid text search field',
},
{
occur: () =>
new Set(definition.fields).size !== definition.fields.length ||
new Set(definition.storedFields).size !== definition.storedFields.length,
error: 'Duplicate text search field',
},
{
occur: () => definition.storedFields.some((f) => reserved.has(f)),
error: 'Stored fields conflict with search metadata',
},
{
occur: () =>
definition.tokenize !== undefined && typeof definition.tokenize !== 'function',
error: 'Invalid text search tokenizer',
},
{
occur: () =>
definition.combineWith !== undefined && !['AND', 'OR'].includes(definition.combineWith),
error: 'Invalid text search combination',
},
{
occur: () =>
Object.entries(definition.boost ?? {}).some(
([field, value]) =>
!definition.fields.includes(field as keyof Document & string) ||
typeof value !== 'number' ||
!Number.isFinite(value) ||
value <= 0,
),
error: 'Invalid text search field boost',
},
],
['kit', 'text-search', 'registration'],
);
this.stored = Object.freeze([...definition.storedFields]);
this.inputFields = Object.freeze([...new Set([...definition.fields, ...this.stored])]);
// Object entries have distinct keys, so there is no equal-key ordering case.
const boost = Object.fromEntries(
Object.entries(definition.boost ?? {}).sort(([a], [b]) => (a < b ? -1 : 1)),
) as Record<string, number>;
this.options = {
fields: [...definition.fields],
storeFields: [...definition.storedFields],
...(definition.tokenize ? { tokenize: definition.tokenize.bind(registration) } : {}),
searchOptions: { boost, combineWith: definition.combineWith ?? 'OR' },
};
this.configurationFingerprint = JSON.stringify({
indexRevision: definition.indexRevision,
fields: this.options.fields,
storedFields: this.options.storeFields,
boost: this.options.searchOptions.boost,
combineWith: this.options.searchOptions.combineWith,
});
this.index = new MiniSearch<Document>(this.options);
}
/**
* Insert or replace one document.
*
* @param document - Document carrying a non-blank string `id`; only declared fields are kept.
* @throws TypeError For a missing document, a blank/non-string id, or a non-string value in
* an indexed field.
*/
upsert(document: Document): void {
breakWhile(!document, 'Invalid text search document');
// Only declared fields belong to this index. Read inherited getters before
// cloning and do not retain caller-owned nested metadata or extra services.
const snapshot = structuredClone({
...Object.fromEntries(
this.inputFields.map((field) => [field, document[field as keyof Document]]),
),
id: document.id,
}) as Document;
breakWhile(
typeof snapshot.id !== 'string' ||
!snapshot.id.trim() ||
this.options.fields.some(
(field) => snapshot[field] !== undefined && typeof snapshot[field] !== 'string',
),
'Invalid text search document',
);
if (this.index.has(snapshot.id)) this.index.replace(snapshot);
else this.index.add(snapshot);
}
/**
* Search the index and return at most `limit` hits.
*
* @param options - Non-empty query text, limit (1..1000) and a synchronous filter that decides
* which stored documents may be returned.
* @returns Hits ordered by MiniSearch relevance, carrying the stored fields only.
* @throws TypeError For a blank query, an out-of-range limit, a missing filter, a filter
* result that is not synchronous boolean.
*/
search(options: TextSearchQuery<Document> & { query: string }): TextSearchHit<Document>[] {
const { query } = options;
breakWhile(
[
{
occur: () => typeof query !== 'string' || !query.trim(),
error: 'Text search requires a nonempty query',
},
{
occur: () =>
!options ||
!Number.isSafeInteger(options.limit) ||
options.limit < 1 ||
options.limit > 1000,
error: 'Text search limit must be between 1 and 1000',
},
{
occur: () => typeof options.filter !== 'function',
error: 'Text search requires an application filter',
},
],
['kit', 'text-search', 'query'],
);
return this.index
.search(query, {
filter: (hit) =>
booleanResult(
options.filter({ ...this.fields(hit), id: String(hit.id) }),
'Text search filters must be synchronous',
'Text search filters must return boolean',
),
})
.slice(0, options.limit)
.map((hit) => ({
id: String(hit.id),
score: hit.score,
terms: [...hit.terms],
document: this.fields(hit),
}));
}
private fields(hit: SearchResult): Partial<Document> {
return structuredClone(
Object.fromEntries(
this.stored
.filter((field) => Object.hasOwn(hit, field))
.map((field) => [field, hit[field]]),
),
) as Partial<Document>;
}
/**
* Remove one document.
*
* Vacuum before returning so deleted terms do not survive the next serialized checkpoint.
*
* @param id - Document id; a non-blank string is required.
* @returns Whether a document was actually discarded.
* @throws TypeError For a blank or non-string id.
*/
async remove(id: string): Promise<boolean> {
breakWhile(typeof id !== 'string' || !id.trim(), 'Invalid text search document id');
if (!this.index.has(id)) return false;
this.index.discard(id);
await this.index.vacuum();
return true;
}
/** Drop every document while keeping the compiled configuration. */
clear(): void {
this.index.removeAll();
}
/**
* Serialize the index together with the configuration used to build it.
*
* @returns A versioned snapshot; the fingerprint lets `restore` reject a mismatched setup.
*/
snapshot(): TextSearchSnapshot {
return {
// The layout version and engine tag pin the serialized format to MiniSearch 7 semantics.
version: 2,
engine: 'minisearch-7',
configurationFingerprint: this.configurationFingerprint,
serializedIndex: JSON.stringify(this.index),
};
}
/**
* Rebuild an index from a snapshot.
*
* @param definition - The same configuration the snapshot was produced with.
* @param snapshot - Snapshot returned by `snapshot`.
* @returns A new populated `TextSearch`.
* @throws An error when the layout version, engine tag, configuration fingerprint or payload
* type does not match, because restoring an incompatible index would silently misanswer.
*/
static restore<Document extends { id: string }>(
definition: TextSearchOptions<Document>,
snapshot: TextSearchSnapshot,
): TextSearch<Document> {
const search = new TextSearch(definition);
breakWhile(
!snapshot ||
snapshot.version !== 2 ||
snapshot.engine !== 'minisearch-7' ||
snapshot.configurationFingerprint !== search.configurationFingerprint ||
typeof snapshot.serializedIndex !== 'string',
'Incompatible text search snapshot; rebuild from application records',
);
search.index = MiniSearch.loadJSON<Document>(snapshot.serializedIndex, search.options);
return search;
}
}