logancyang_obsidian-copilot/src/search/dbOperations.ts
Zero Liu 8658282aa9
chore(lint): enable 4 type-aware quick-win rules and fix violations (#2424)
Turns on no-unsafe-enum-comparison, no-base-to-string,
no-redundant-type-constituents, and restrict-template-expressions.
Fixes all 24 violations across 13 source files.
2026-05-13 21:57:33 -07:00

798 lines
25 KiB
TypeScript

// DEPRECATED: Orama DB operations are superseded by v3 MemoryIndexManager JSONL index. Keep only for
// backward compatibility where needed; new features should not depend on this module.
import EmbeddingsManager from "@/LLMProviders/embeddingManager";
import { CustomError } from "@/error";
import { logError, logInfo } from "@/logger";
import { getSettings, subscribeToSettingsChange } from "@/settings/model";
import { areEmbeddingModelsSame } from "@/utils";
import { Embeddings } from "@langchain/core/embeddings";
import { create, insert, Orama, remove, removeMultiple, search } from "@orama/orama";
import { Mutex } from "async-mutex";
import { MD5 } from "crypto-js";
import { App, Notice, Platform } from "obsidian";
import { ChunkedStorage } from "./chunkedStorage";
import { getMatchingPatterns, getVectorLength, shouldIndexFile } from "./searchUtils";
const INTEGRITY_CHECK_YIELD_INTERVAL = 25;
export interface OramaDocument {
id: string;
title: string;
content: string;
embedding: number[];
path: string;
embeddingModel: string;
created_at: number;
ctime: number;
mtime: number;
tags: string[];
extension: string;
nchars: number;
metadata: Record<string, any>;
}
export class DBOperations {
private oramaDb: Orama<any> | undefined;
private chunkedStorage: ChunkedStorage | undefined;
private isInitialized = false;
private dbPath: string;
private initializationPromise: Promise<void>;
private isIndexLoaded = false;
private hasUnsavedChanges = false;
private filesWithoutEmbeddings: Set<string> = new Set();
private upsertMutex = new Mutex();
constructor(private app: App) {
// Subscribe to settings changes
subscribeToSettingsChange(() => {
void (async () => {
try {
const settings = getSettings();
// Handle mobile index loading setting change
if (Platform.isMobile && settings.disableIndexOnMobile) {
this.isIndexLoaded = false;
this.oramaDb = undefined;
} else if (Platform.isMobile && !settings.disableIndexOnMobile && !this.oramaDb) {
// Re-initialize DB if mobile setting is enabled
await this.initializeDB(await EmbeddingsManager.getInstance().getEmbeddingsAPI());
}
// Handle index sync setting change
const newPath = await this.getDbPath();
if (this.dbPath && newPath !== this.dbPath) {
logInfo("Path change detected, reinitializing database...");
this.dbPath = newPath;
await this.initializeChunkedStorage();
await this.initializeDB(await EmbeddingsManager.getInstance().getEmbeddingsAPI());
logInfo("Database reinitialized with new path:", newPath);
}
} catch (error) {
logError("DBOperations settings change handler failed", error);
}
})();
});
}
private async initializeChunkedStorage() {
if (!this.app.vault.adapter) {
throw new CustomError("Vault adapter not available. Please try again later.");
}
const baseDir = await this.getDbPath();
this.chunkedStorage = new ChunkedStorage(this.app, baseDir, this.getVaultIdentifier());
this.isInitialized = true;
}
async initializeDB(embeddingInstance: Embeddings | undefined): Promise<Orama<any> | undefined> {
try {
if (!this.isInitialized) {
this.dbPath = await this.getDbPath();
await this.initializeChunkedStorage();
}
if (Platform.isMobile && getSettings().disableIndexOnMobile) {
this.isIndexLoaded = false;
this.oramaDb = undefined;
return;
}
if (!this.chunkedStorage) {
throw new CustomError("Storage not initialized properly");
}
try {
if (await this.chunkedStorage.exists()) {
this.oramaDb = await this.chunkedStorage.loadDatabase();
logInfo("Loaded existing chunked semantic index database from disk.");
return this.oramaDb;
}
} catch (error) {
// If loading fails, we'll create a new database
logError("Failed to load existing semantic index database, creating new one:", error);
}
// Create new database if none exists or loading failed
const newDb = await this.createNewDb(embeddingInstance);
this.oramaDb = newDb;
return newDb;
} catch (error) {
logError(`Error initializing semantic index database:`, error);
new Notice("Failed to initialize Copilot database. Some features may be limited.");
return undefined;
}
}
async saveDB() {
if (Platform.isMobile && getSettings().disableIndexOnMobile) {
return;
}
if (!this.oramaDb || !this.chunkedStorage) {
// Instead of throwing immediately, try to initialize.
// Crucial for new user onboarding.
try {
await this.initializeDB(await EmbeddingsManager.getInstance().getEmbeddingsAPI());
// If still not initialized after attempt, then throw
if (!this.oramaDb || !this.chunkedStorage) {
throw new CustomError("Semantic index database not found.");
}
} catch (error) {
logError("Failed to initialize database during save:", error);
throw new CustomError("Failed to initialize and save database.");
}
}
try {
await this.chunkedStorage.saveDatabase(this.oramaDb);
this.hasUnsavedChanges = false;
if (getSettings().debug) {
logInfo("Semantic index database saved successfully at:", this.dbPath);
}
} catch (error) {
logError(`Error saving semantic index database:`, error);
throw error;
}
}
async clearIndex(embeddingInstance: Embeddings | undefined): Promise<void> {
try {
// Ensure database is initialized first
if (!this.oramaDb) {
await this.initializeDB(embeddingInstance);
}
// Clear existing storage first
await this.chunkedStorage?.clearStorage();
// Wait a moment to ensure file system operations complete
await new Promise((resolve) => window.setTimeout(resolve, 100));
// Create new database instance
this.oramaDb = await this.createNewDb(embeddingInstance);
// Save the empty database
await this.saveDB();
new Notice("Local Copilot index cleared successfully.");
logInfo("Local Copilot index cleared successfully, new instance created.");
} catch (err) {
logError("Error clearing the local Copilot index:", err);
new Notice("An error occurred while clearing the local Copilot index.");
throw err;
}
}
public async removeDocs(filePath: string) {
if (!this.oramaDb) {
throw new CustomError("Semantic index database not found.");
}
try {
const searchResult = await search(this.oramaDb, {
term: filePath,
properties: ["path"],
});
if (searchResult.hits.length > 0) {
await removeMultiple(
this.oramaDb,
searchResult.hits.map((hit) => hit.id),
500
);
if (getSettings().debug) {
logInfo(`Deleted document from local Copilot index: ${filePath}`);
}
}
this.markUnsavedChanges();
} catch (err) {
logError("Error deleting document from local Copilotindex:", err);
}
}
public getDb(): Orama<any> | undefined {
if (!this.oramaDb) {
console.warn("Database not initialized. Some features may be limited.");
}
return this.oramaDb;
}
public async getIsIndexLoaded(): Promise<boolean> {
return this.isIndexLoaded;
}
public async waitForInitialization() {
await this.initializationPromise;
}
public onunload() {
if (this.hasUnsavedChanges) {
void this.saveDB().catch((err) => logError("saveDB on unload failed", err));
}
}
public getCurrentDbPath(): string {
// This is the old path before any setting changes, used for comparison
return this.dbPath;
}
// This is the path according to the setting's enableIndexSync
public async getDbPath(): Promise<string> {
const vaultRoot = this.app.vault.getRoot().path;
let baseDir: string;
if (getSettings().enableIndexSync) {
baseDir = this.app.vault.configDir;
} else {
// If vaultRoot is just "/", treat it as empty
const effectiveRoot = vaultRoot === "/" ? "" : vaultRoot;
const prefix = effectiveRoot === "" || effectiveRoot.startsWith("/") ? "" : "/";
baseDir = `${prefix}${effectiveRoot}/.copilot-index`;
// Ensure the directory exists
if (!(await this.app.vault.adapter.exists(baseDir))) {
await this.app.vault.adapter.mkdir(baseDir);
logInfo("Created directory:", baseDir);
}
}
return baseDir;
}
private getVaultIdentifier(): string {
const vaultName = this.app.vault.getName();
return MD5(vaultName).toString();
}
public markUnsavedChanges() {
this.hasUnsavedChanges = true;
}
private async createNewDb(embeddingInstance: Embeddings | undefined): Promise<Orama<any>> {
if (!embeddingInstance) {
throw new CustomError("Embedding instance not found.");
}
const vectorLength = await getVectorLength(embeddingInstance);
if (!vectorLength || vectorLength === 0) {
throw new CustomError(
"Invalid vector length detected. Please check if your embedding model is working."
);
}
const schema = this.createDynamicSchema(vectorLength);
const db = create({
schema,
components: {
tokenizer: {
stemmer: undefined,
stopWords: undefined,
},
},
});
logInfo(
`Created new semantic index database for ${this.dbPath}. ` +
`Embedding model: ${EmbeddingsManager.getModelName(embeddingInstance)} with vector length ${vectorLength}.`
);
this.isIndexLoaded = true;
return db;
}
public static async getDocsByPath(db: Orama<any>, path: string) {
if (!db) throw new Error("DB not initialized");
if (!path) return;
// Use getAllDocuments + JS filter for reliable path matching (handles Unicode/Chinese correctly)
const allDocs = await DBOperations.getAllDocuments(db);
const filtered = allDocs.filter((doc: any) => doc.path === path);
// Return in same format as before (hits with document and score)
return filtered.map((doc: any) => ({ document: doc, score: 1 }));
}
public static async getDocsByEmbedding(
db: Orama<any>,
embedding: number[],
options: {
limit: number;
similarity: number;
}
) {
const result = await search(db, {
mode: "vector",
vector: { value: embedding, property: "embedding" },
limit: options.limit,
similarity: options.similarity,
includeVectors: true,
});
return result.hits;
}
public static async getLatestFileMtime(db: Orama<any> | undefined): Promise<number> {
if (!db) throw new Error("DB not initialized");
try {
const result = await search(db, {
term: "",
limit: 1,
sortBy: {
property: "mtime",
order: "DESC",
},
});
if (result.hits.length > 0) {
const latestDoc = result.hits[0].document as any;
return latestDoc.mtime;
}
return 0; // Return 0 if no documents found
} catch (err) {
logError("Error getting latest file mtime from VectorDB:", err);
return 0;
}
}
createDynamicSchema(vectorLength: number) {
return {
id: "string",
title: "string", // basename of the TFile
path: "string", // path of the TFile
content: "string",
embedding: `vector[${vectorLength}]`,
embeddingModel: "string",
created_at: "number",
ctime: "number",
mtime: "number",
tags: "string[]",
extension: "string",
nchars: "number",
};
}
async upsert(docToSave: any): Promise<any> {
if (!this.oramaDb) throw new Error("DB not initialized");
const db = this.oramaDb;
// Use mutex to make the operation atomic
return await this.upsertMutex.runExclusive(async () => {
try {
// Calculate partition first
const partition = this.chunkedStorage?.assignDocumentToPartition(
String(docToSave.id),
getSettings().numPartitions
);
// Check if document exists
const existingDoc = await search(db, {
term: String(docToSave.id),
properties: ["id"],
limit: 1,
});
if (existingDoc.hits.length > 0) {
await remove(db, existingDoc.hits[0].id);
}
// Insert into the assigned partition
try {
await insert(db, docToSave as Parameters<typeof insert>[1]);
logInfo(
`${existingDoc.hits.length > 0 ? "Updated" : "Inserted"} document ${docToSave.id} in partition ${partition}`
);
this.markUnsavedChanges();
return docToSave;
} catch (insertErr) {
logError(
`Failed to ${existingDoc.hits.length > 0 ? "update" : "insert"} document ${docToSave.id}:`,
insertErr
);
// If we removed an existing document but failed to insert the new one,
// we should try to restore the old document
if (existingDoc.hits.length > 0) {
try {
await insert(db, existingDoc.hits[0].document);
} catch (restoreErr) {
logError("Failed to restore previous document version:", restoreErr);
}
}
return undefined;
}
} catch (err) {
logError(`Error upserting document ${docToSave.id}:`, err);
return undefined;
}
});
}
async getLatestFileMtime(): Promise<number> {
if (!this.oramaDb) throw new Error("DB not initialized");
try {
const result = await search(this.oramaDb, {
term: "",
limit: 1,
sortBy: {
property: "mtime",
order: "DESC",
},
});
if (result.hits.length > 0) {
const latestDoc = result.hits[0].document as any;
return latestDoc.mtime;
}
return 0; // Return 0 if no documents found
} catch (err) {
logError("Error getting latest file mtime from VectorDB:", err);
return 0;
}
}
/**
* Checks if the embedding model has changed and handles the transition.
*
* @param embeddingInstance - The current embedding instance to check against
* @returns {boolean} - Returns true if model changed (triggers full rebuild with overwrite=true),
* false if model unchanged or no index exists (allows normal indexing flow)
*
* Behavior:
* - true = Model changed → Forces full rebuild (clears existing index and reindexes all files)
* - false = Model unchanged OR no index exists → Proceeds with normal indexing:
* - If index exists: incremental update (only new/modified files)
* - If no index: first-time build (indexes all files without "rebuilding")
*
* Note: When no index exists, returning false is correct because:
* - It's not a "rebuild" (nothing to rebuild), it's a first-time "build"
* - The indexing flow will still create the index, just without the "overwrite" flag
* - This prevents unnecessary "rebuilding" when toggling semantic search on/off
*/
async checkAndHandleEmbeddingModelChange(embeddingInstance: Embeddings): Promise<boolean> {
// Load DB from disk if not in memory
if (!this.oramaDb) {
logInfo("Semantic index database not loaded in memory. Checking for existing index...");
try {
await this.initializeDB(embeddingInstance);
} catch (error) {
logError("Failed to initialize database:", error);
throw new CustomError(
"Failed to initialize semantic index database. Please check your embedding model settings."
);
}
// If still no DB after init, no existing index to compare against
if (!this.oramaDb) {
logInfo("No existing index found. Will create new index.");
return false; // Not a rebuild, just a first-time build
}
}
// Check if the embedding model in the existing index matches the current model
const singleDoc = await search(this.oramaDb, {
term: "",
limit: 1,
});
let prevEmbeddingModel: string | undefined;
if (singleDoc.hits.length > 0) {
const oramaDocSample = singleDoc.hits[0];
if (
typeof oramaDocSample === "object" &&
oramaDocSample !== null &&
"document" in oramaDocSample
) {
const doc = oramaDocSample.document as { embeddingModel?: string };
prevEmbeddingModel = doc.embeddingModel;
}
}
if (prevEmbeddingModel) {
const currEmbeddingModel = EmbeddingsManager.getModelName(embeddingInstance);
if (!areEmbeddingModelsSame(prevEmbeddingModel, currEmbeddingModel)) {
// Model has changed, notify user and rebuild DB
new Notice("New embedding model detected. Rebuilding Copilot index from scratch.");
logInfo(
`Detected change in embedding model from "${prevEmbeddingModel}" to "${currEmbeddingModel}". Rebuilding Copilot index from scratch.`
);
// Create new DB with new model
this.oramaDb = await this.createNewDb(embeddingInstance);
await this.saveDB();
return true;
}
} else {
logInfo("No previous embedding model found in the database.");
}
return false;
}
public static async getAllDocuments(db: Orama<any>): Promise<any[]> {
const result = await search(db, {
term: "",
limit: 100000,
includeVectors: true,
});
return result.hits.map((hit) => hit.document);
}
public async garbageCollect(): Promise<number> {
if (!this.oramaDb) {
logInfo(
"Semantic index database not found during garbage collection. Attempting to initialize..."
);
try {
const embeddingInstance = await EmbeddingsManager.getInstance().getEmbeddingsAPI();
if (!embeddingInstance) {
throw new CustomError("No embedding model available.");
}
await this.initializeDB(embeddingInstance);
if (!this.oramaDb) {
throw new CustomError("Failed to initialize database after attempt.");
}
} catch (error) {
logError("Failed to initialize database during garbage collection:", error);
throw new CustomError(
"Failed to initialize database. Please check your embedding model settings."
);
}
}
try {
const files = this.app.vault.getMarkdownFiles();
const filePaths = new Set(files.map((file) => file.path));
// Determine which files are currently eligible for indexing based on settings
const { inclusions, exclusions } = getMatchingPatterns();
const allowedPaths = new Set(
files
.filter((file) => shouldIndexFile(file, inclusions, exclusions))
.map((file) => file.path)
);
// Get all documents in the database
const docs = await DBOperations.getAllDocuments(this.oramaDb);
// Identify docs to remove:
// 1) Files that no longer exist
// 2) Files that exist but are excluded by current settings (or internal exclusions)
const docsToRemove = docs.filter(
(doc: { path: string }) => !filePaths.has(doc.path) || !allowedPaths.has(doc.path)
) as { path: string; id: string }[];
if (docsToRemove.length === 0) {
return 0;
}
logInfo(
"Copilot index: Docs to remove during garbage collection:",
Array.from(new Set(docsToRemove.map((doc) => doc.path))).join(", ")
);
if (docsToRemove.length === 1) {
await remove(this.oramaDb, docsToRemove[0].id);
} else {
await removeMultiple(
this.oramaDb,
docsToRemove.map((hit) => hit.id),
500
);
}
await this.saveDB();
return docsToRemove.length;
} catch (err) {
logError("Error garbage collecting the Copilot index:", err);
throw new CustomError("Failed to garbage collect the Copilot index.");
}
}
public async getIndexedFiles(): Promise<string[]> {
if (!this.oramaDb) {
throw new CustomError("Semantic index database not found.");
}
try {
// Search all documents and get unique file paths
const docs = await DBOperations.getAllDocuments(this.oramaDb);
// Use a Set to get unique file paths since multiple chunks can belong to the same file
const uniquePaths = new Set<string>();
docs.forEach((doc: { path: string }) => {
uniquePaths.add(doc.path);
});
// Convert Set to sorted array
return Array.from(uniquePaths).sort();
} catch (err) {
logError("Error getting indexed files:", err);
throw new CustomError("Failed to retrieve indexed files.");
}
}
public async isIndexEmpty(): Promise<boolean> {
if (!this.oramaDb) {
return true;
}
try {
const result = await search(this.oramaDb, {
term: "",
limit: 1,
});
return result.hits.length === 0;
} catch (err) {
logError("Error checking if database is empty:", err);
throw new CustomError("Failed to check if database is empty.");
}
}
public async hasIndex(notePath: string): Promise<boolean> {
if (!this.oramaDb) {
return false;
}
const docs = await DBOperations.getDocsByPath(this.oramaDb, notePath);
return docs !== undefined && docs.length > 0;
}
async hasEmbeddings(notePath: string): Promise<boolean> {
if (!this.oramaDb) {
return false;
}
const docs = await DBOperations.getDocsByPath(this.oramaDb, notePath);
if (!docs || docs.length === 0) {
return false;
}
// Check if ALL documents for this path have embeddings
return docs.every((doc) => {
return (
doc?.document?.embedding &&
Array.isArray(doc.document.embedding) &&
doc.document.embedding.length > 0
);
});
}
async getDocsJsonByPaths(paths: string[]): Promise<Record<string, any[]>> {
if (!this.oramaDb) {
throw new CustomError("Semantic index database not found.");
}
const result: Record<string, any[]> = {};
for (const path of paths) {
const docs = await DBOperations.getDocsByPath(this.oramaDb, path);
if (docs && docs.length > 0) {
result[path] = docs.map((hit) => ({
id: hit.document.id,
title: hit.document.title,
path: hit.document.path,
content: hit.document.content,
metadata: hit.document.metadata,
embedding: hit.document.embedding,
embeddingModel: hit.document.embeddingModel,
tags: hit.document.tags,
extension: hit.document.extension,
nchars: hit.document.nchars,
}));
}
}
return result;
}
/**
* Mark a file as missing embeddings
*/
public markFileMissingEmbeddings(filePath: string): void {
this.filesWithoutEmbeddings.add(filePath);
}
/**
* Clear the list of files missing embeddings
*/
public clearFilesMissingEmbeddings(): void {
this.filesWithoutEmbeddings.clear();
}
/**
* Get the list of files missing embeddings
*/
public getFilesMissingEmbeddings(): string[] {
return Array.from(this.filesWithoutEmbeddings);
}
/**
* Check if a file is missing embeddings
*/
public isFileMissingEmbeddings(filePath: string): boolean {
return this.filesWithoutEmbeddings.has(filePath);
}
/**
* Check the integrity of the index by verifying all documents have proper embeddings.
* Files found missing embeddings are marked for retry so the next indexing run picks them up.
* (The phantom re-indexing issue is prevented by clearing the set at the start of each
* indexing run in indexOperations.ts, not by skipping the mark here.)
*/
public async checkIndexIntegrity(): Promise<void> {
if (!this.oramaDb) {
throw new CustomError("Orama database not found.");
}
try {
const indexedFiles = await this.getIndexedFiles();
const filesMissing: string[] = [];
for (let index = 0; index < indexedFiles.length; index += 1) {
const filePath = indexedFiles[index];
const hasEmb = await this.hasEmbeddings(filePath);
if (!hasEmb) {
filesMissing.push(filePath);
this.filesWithoutEmbeddings.add(filePath);
}
if ((index + 1) % INTEGRITY_CHECK_YIELD_INTERVAL === 0) {
await this.yieldToEventLoop();
}
}
if (indexedFiles.length % INTEGRITY_CHECK_YIELD_INTERVAL !== 0) {
await this.yieldToEventLoop();
}
if (filesMissing.length > 0) {
logInfo(
`Integrity check: ${filesMissing.length} file(s) missing embeddings, marked for re-indexing:`,
filesMissing.join(", ")
);
} else {
logInfo("Index integrity check completed. All documents have embeddings.");
}
} catch (err) {
logError("Error checking index integrity:", err);
throw new CustomError("Failed to check index integrity.");
}
}
/**
* Yield control to the browser event loop so long-running integrity checks do not freeze the UI.
*/
private async yieldToEventLoop(): Promise<void> {
await new Promise<void>((resolve) => {
if (typeof window !== "undefined") {
const idleWindow = window as Window & {
requestIdleCallback?: (callback: IdleRequestCallback) => number;
};
if (idleWindow.requestIdleCallback) {
idleWindow.requestIdleCallback(() => resolve());
return;
}
}
window.setTimeout(resolve, 0);
});
}
}