-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Add tokenizer mode support for message search #33048
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
6838eaa
f33d576
dce3d1b
940ae09
a27bf89
cbf3e39
eb6f973
b3df964
7d8ba24
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| /* | ||
| Copyright 2026 Hiroshi Shinaoka | ||
|
|
||
| SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial | ||
| Please see LICENSE files in the repository root for full details. | ||
| */ | ||
|
|
||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { createSeshatConfig, TokenizerMode } from "./seshat-config.js"; | ||
|
|
||
| describe("createSeshatConfig", () => { | ||
| it.each([ | ||
| [ | ||
| TokenizerMode.Ngram, | ||
| { | ||
| tokenizerMode: TokenizerMode.Ngram, | ||
| ngramMinSize: 2, | ||
| ngramMaxSize: 4, | ||
| }, | ||
| ], | ||
| [TokenizerMode.Language, { tokenizerMode: TokenizerMode.Language }], | ||
| [undefined, { tokenizerMode: TokenizerMode.Language }], | ||
| ])("returns the expected config for tokenizerMode %s", (tokenizerMode, expectedConfig) => { | ||
| expect(createSeshatConfig(tokenizerMode)).toEqual(expectedConfig); | ||
| }); | ||
|
|
||
| it("throws for unknown tokenizer modes", () => { | ||
| expect(() => createSeshatConfig("unknown")).toThrow("Unknown tokenizer mode: unknown"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| /* | ||
| Copyright 2025 Hiroshi Shinaoka | ||
|
|
||
| SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial | ||
| Please see LICENSE files in the repository root for full details. | ||
| */ | ||
|
|
||
| export enum TokenizerMode { | ||
| Ngram = "ngram", | ||
| Language = "language", | ||
| } | ||
|
|
||
| /** | ||
| * Create Seshat configuration based on tokenizer mode. | ||
| * | ||
| * @param tokenizerMode - The tokenizer mode: "ngram" for N-gram tokenization (CJK languages), | ||
| * or "language" for standard language-based tokenization. | ||
| * @returns Configuration object for Seshat initialization. | ||
| */ | ||
| export function createSeshatConfig(tokenizerMode?: string): { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this throw if an unknown
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated createSeshatConfig to default only when no tokenizerMode is provided, and to throw for unknown values. Added coverage for that case. |
||
| tokenizerMode: TokenizerMode; | ||
| ngramMinSize?: number; | ||
| ngramMaxSize?: number; | ||
| } { | ||
| if (tokenizerMode === TokenizerMode.Ngram) { | ||
| return { | ||
| tokenizerMode: TokenizerMode.Ngram, | ||
| ngramMinSize: 2, | ||
| ngramMaxSize: 4, | ||
| }; | ||
| } | ||
|
|
||
| if (tokenizerMode === undefined || tokenizerMode === TokenizerMode.Language) { | ||
| return { tokenizerMode: TokenizerMode.Language }; | ||
| } | ||
|
|
||
| throw new Error(`Unknown tokenizer mode: ${tokenizerMode}`); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| /* | ||
| Copyright 2026 Hiroshi Shinaoka | ||
|
|
||
| SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial | ||
| Please see LICENSE files in the repository root for full details. | ||
| */ | ||
|
|
||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import { TokenizerMode } from "./seshat-config.js"; | ||
| import { initEventIndex } from "./seshat-index.js"; | ||
|
|
||
| const eventStorePath = join(tmpdir(), "element-desktop-seshat-index-test"); | ||
| const passphrase = "fixture-value"; | ||
|
|
||
| class FakeReindexError extends Error {} | ||
|
|
||
| describe("initEventIndex", () => { | ||
| it("passes ngram config when opening a new index", async () => { | ||
| const mkdir = vi.fn().mockResolvedValue(undefined); | ||
| const deleteContents = vi.fn().mockResolvedValue(undefined); | ||
| const eventIndex = { kind: "index" }; | ||
| const Seshat = vi.fn().mockImplementation(() => eventIndex); | ||
| const SeshatRecovery = vi.fn(); | ||
|
|
||
| const result = await initEventIndex(eventStorePath, passphrase, TokenizerMode.Ngram, { | ||
| mkdir, | ||
| deleteContents, | ||
| createSeshat: Seshat, | ||
| createSeshatRecovery: SeshatRecovery, | ||
| isReindexError: (error) => error instanceof FakeReindexError, | ||
| }); | ||
|
|
||
| expect(mkdir).toHaveBeenCalledWith(eventStorePath, { recursive: true }); | ||
| expect(Seshat).toHaveBeenCalledWith(eventStorePath, { | ||
| passphrase, | ||
| tokenizerMode: TokenizerMode.Ngram, | ||
| ngramMinSize: 2, | ||
| ngramMaxSize: 4, | ||
| }); | ||
| expect(SeshatRecovery).not.toHaveBeenCalled(); | ||
| expect(result).toEqual({ eventIndex }); | ||
| }); | ||
|
|
||
| it("passes tokenizer config through the reindex recovery path", async () => { | ||
| const mkdir = vi.fn().mockResolvedValue(undefined); | ||
| const deleteContents = vi.fn().mockResolvedValue(undefined); | ||
| const reopenedIndex = { kind: "reopened-index" }; | ||
| const recoveryIndex = { | ||
| getUserVersion: vi.fn().mockResolvedValue(1), | ||
| shutdown: vi.fn().mockResolvedValue(undefined), | ||
| reindex: vi.fn().mockResolvedValue(undefined), | ||
| }; | ||
|
|
||
| const Seshat = vi | ||
| .fn() | ||
| .mockImplementationOnce(() => { | ||
| throw new FakeReindexError("schema changed"); | ||
| }) | ||
| .mockImplementationOnce(() => reopenedIndex); | ||
| const SeshatRecovery = vi.fn().mockImplementation(() => recoveryIndex); | ||
|
|
||
| const result = await initEventIndex(eventStorePath, passphrase, TokenizerMode.Language, { | ||
| mkdir, | ||
| deleteContents, | ||
| createSeshat: Seshat, | ||
| createSeshatRecovery: SeshatRecovery, | ||
| isReindexError: (error) => error instanceof FakeReindexError, | ||
| }); | ||
|
|
||
| expect(SeshatRecovery).toHaveBeenCalledWith(eventStorePath, { | ||
| passphrase, | ||
| tokenizerMode: TokenizerMode.Language, | ||
| }); | ||
| expect(recoveryIndex.reindex).toHaveBeenCalledOnce(); | ||
| expect(Seshat).toHaveBeenNthCalledWith(2, eventStorePath, { | ||
| passphrase, | ||
| tokenizerMode: TokenizerMode.Language, | ||
| }); | ||
| expect(deleteContents).not.toHaveBeenCalled(); | ||
| expect(result).toEqual({ eventIndex: reopenedIndex }); | ||
| }); | ||
|
|
||
| it("marks the index as recreated when recovery deletes a version 0 database", async () => { | ||
| const mkdir = vi.fn().mockResolvedValue(undefined); | ||
| const deleteContents = vi.fn().mockResolvedValue(undefined); | ||
| const recreatedIndex = { kind: "recreated-index" }; | ||
| const recoveryIndex = { | ||
| getUserVersion: vi.fn().mockResolvedValue(0), | ||
| shutdown: vi.fn().mockResolvedValue(undefined), | ||
| reindex: vi.fn().mockResolvedValue(undefined), | ||
| }; | ||
| const Seshat = vi | ||
| .fn() | ||
| .mockImplementationOnce(() => { | ||
| throw new FakeReindexError("schema changed"); | ||
| }) | ||
| .mockImplementationOnce(() => recreatedIndex); | ||
| const SeshatRecovery = vi.fn().mockImplementation(() => recoveryIndex); | ||
|
|
||
| const result = await initEventIndex(eventStorePath, passphrase, TokenizerMode.Language, { | ||
| mkdir, | ||
| deleteContents, | ||
| createSeshat: Seshat, | ||
| createSeshatRecovery: SeshatRecovery, | ||
| isReindexError: (error) => error instanceof FakeReindexError, | ||
| }); | ||
|
|
||
| expect(recoveryIndex.shutdown).toHaveBeenCalledOnce(); | ||
| expect(recoveryIndex.reindex).not.toHaveBeenCalled(); | ||
| expect(deleteContents).toHaveBeenCalledWith(eventStorePath); | ||
| expect(result).toEqual({ eventIndex: recreatedIndex, wasRecreated: true }); | ||
| }); | ||
|
|
||
| it("propagates non-reindex errors without deleting the database", async () => { | ||
| const mkdir = vi.fn().mockResolvedValue(undefined); | ||
| const deleteContents = vi.fn().mockResolvedValue(undefined); | ||
| const openError = new Error("filesystem lock"); | ||
|
|
||
| const Seshat = vi.fn().mockImplementationOnce(() => { | ||
| throw openError; | ||
| }); | ||
| const SeshatRecovery = vi.fn(); | ||
|
|
||
| await expect( | ||
| initEventIndex(eventStorePath, passphrase, TokenizerMode.Ngram, { | ||
| mkdir, | ||
| deleteContents, | ||
| createSeshat: Seshat, | ||
| createSeshatRecovery: SeshatRecovery, | ||
| isReindexError: (error) => error instanceof FakeReindexError, | ||
| }), | ||
| ).rejects.toThrow("filesystem lock"); | ||
|
|
||
| expect(deleteContents).not.toHaveBeenCalled(); | ||
| expect(SeshatRecovery).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| /* | ||
| Copyright 2026 Hiroshi Shinaoka | ||
|
|
||
| SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial | ||
| Please see LICENSE files in the repository root for full details. | ||
| */ | ||
|
|
||
| import type { Seshat as SeshatType, SeshatRecovery as SeshatRecoveryType } from "matrix-seshat"; | ||
| import { createSeshatConfig } from "./seshat-config.js"; | ||
|
|
||
| type SeshatConfig = NonNullable<ConstructorParameters<typeof SeshatType>[1]>; | ||
|
|
||
| export interface SeshatIndexDependencies { | ||
| mkdir(path: string, options: { recursive: true }): Promise<string | undefined>; | ||
| deleteContents(path: string): Promise<void>; | ||
| createSeshat(path: string, config: SeshatConfig): SeshatType; | ||
| createSeshatRecovery(path: string, config: SeshatConfig): SeshatRecoveryType; | ||
| isReindexError(error: unknown): boolean; | ||
| } | ||
|
|
||
| export interface InitEventIndexResult { | ||
| eventIndex: SeshatType; | ||
| wasRecreated?: boolean; | ||
| } | ||
|
|
||
| export async function initEventIndex( | ||
| eventStorePath: string, | ||
| passphrase: string, | ||
| tokenizerMode: string | undefined, | ||
| { mkdir, deleteContents, createSeshat, createSeshatRecovery, isReindexError }: SeshatIndexDependencies, | ||
| ): Promise<InitEventIndexResult> { | ||
| const seshatConfig = { passphrase, ...createSeshatConfig(tokenizerMode) }; | ||
|
|
||
| await mkdir(eventStorePath, { recursive: true }); | ||
|
|
||
| try { | ||
| return { eventIndex: createSeshat(eventStorePath, seshatConfig) }; | ||
| } catch (e) { | ||
| if (isReindexError(e)) { | ||
| // If this is a reindex error, the index schema changed. Try to open the | ||
| // database in recovery mode, reindex the database and finally try to | ||
| // open the database again. | ||
| const recoveryIndex = createSeshatRecovery(eventStorePath, seshatConfig); | ||
| const userVersion = await recoveryIndex.getUserVersion(); | ||
|
|
||
| // If our user version is 0 we'll delete the db anyways so reindexing it is a waste of time. | ||
| if (userVersion === 0) { | ||
| await recoveryIndex.shutdown(); | ||
| await deleteContents(eventStorePath); | ||
| return { eventIndex: createSeshat(eventStorePath, seshatConfig), wasRecreated: true }; | ||
| } else { | ||
| await recoveryIndex.reindex(); | ||
| } | ||
|
|
||
| return { eventIndex: createSeshat(eventStorePath, seshatConfig) }; | ||
| } | ||
|
|
||
| // For non-reindex errors (e.g. bad passphrase, filesystem lock), | ||
| // propagate to the caller so the user sees an error instead of | ||
| // silently losing their search index. | ||
| throw e; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Your docstring has jumped up a few lines!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moved the docstring back onto createSeshatConfig so it no longer documents the enum.