Skip to content
中文

Index fields, filtering, snapshot fingerprint, and removal ​

The policy implementation follows.

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;
  }
}

API reference, usage examples and developer guides.