diff --git a/apps/desktop/main/worker/import/streamImport.ts b/apps/desktop/main/worker/import/streamImport.ts index f53a7166..bda7347c 100644 --- a/apps/desktop/main/worker/import/streamImport.ts +++ b/apps/desktop/main/worker/import/streamImport.ts @@ -20,6 +20,7 @@ import { computeAndSetOverviewCache, createImportPerfLogger, deleteSessionCache, + listDatabaseCandidateIds, } from '@openchatlab/node-runtime' import type { AutoImportBatchItemResult, @@ -131,13 +132,7 @@ export async function autoImport( return sharedAutoImportFile( filePath, { - listSessionIds: () => - fs.existsSync(getDbDir()) - ? fs - .readdirSync(getDbDir()) - .filter((name) => name.endsWith('.db')) - .map((name) => name.slice(0, -3)) - : [], + listSessionIds: () => listDatabaseCandidateIds(getDbDir()), openReadonly: (sessionId) => new BetterSqliteAdapter(openRawDatabase(getDbPath(sessionId), { readonly: true })), onProgress: (progress) => sendProgress(requestId, progress), sessionExists: (sessionId) => fs.existsSync(getDbPath(sessionId)), @@ -183,13 +178,7 @@ export async function autoImportBatch( }, })), { - listSessionIds: () => - fs.existsSync(getDbDir()) - ? fs - .readdirSync(getDbDir()) - .filter((name) => name.endsWith('.db')) - .map((name) => name.slice(0, -3)) - : [], + listSessionIds: () => listDatabaseCandidateIds(getDbDir()), openReadonly: (sessionId) => new BetterSqliteAdapter(openRawDatabase(getDbPath(sessionId), { readonly: true })), sessionExists: (sessionId) => fs.existsSync(getDbPath(sessionId)), createSession: (sourcePath, sourceFormatOptions, sessionId, itemProgress) => diff --git a/packages/node-runtime/src/database-manager.test.ts b/packages/node-runtime/src/database-manager.test.ts index fc32bf75..ec7fd56f 100644 --- a/packages/node-runtime/src/database-manager.test.ts +++ b/packages/node-runtime/src/database-manager.test.ts @@ -7,7 +7,7 @@ import Database from 'better-sqlite3' import type { PathProvider } from '@openchatlab/core' import { CHAT_DB_SCHEMA, CURRENT_SCHEMA_VERSION, getSessionInfo } from '@openchatlab/core' import { DataDirCompatibilityError, readDataDirCompatibilityMeta } from './data-dir-compat' -import { DatabaseManager } from './database-manager' +import { DatabaseManager, listDatabaseCandidateIds } from './database-manager' const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') @@ -1031,3 +1031,20 @@ test('listSessionIds ignores non-ChatLab sqlite databases without migrating them assert.deepEqual(manager.listSessionIds(), []) manager.closeAll() }) + +test('database candidate enumeration ignores AppleDouble sidecars but keeps damaged databases visible', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + fs.writeFileSync(path.join(dbDir, '._chat_existing.db'), Buffer.from([0x00, 0x05, 0x16, 0x07])) + + const manager = new DatabaseManager(createPathProvider(root), { nativeBinding, allowMissingRuntimeForTests: true }) + assert.deepEqual(listDatabaseCandidateIds(dbDir), []) + assert.deepEqual(manager.listSessionIds(), []) + assert.deepEqual(manager.listSessionIdsReadonly(), []) + + fs.writeFileSync(path.join(dbDir, 'damaged.db'), 'not a sqlite database') + assert.deepEqual(listDatabaseCandidateIds(dbDir), ['damaged']) + assert.throws(() => manager.listSessionIds(), /file is not a database/) + assert.throws(() => manager.listSessionIdsReadonly(), /file is not a database/) +}) diff --git a/packages/node-runtime/src/database-manager.ts b/packages/node-runtime/src/database-manager.ts index 3a36c731..67b78019 100644 --- a/packages/node-runtime/src/database-manager.ts +++ b/packages/node-runtime/src/database-manager.ts @@ -52,6 +52,19 @@ interface OpenRawSessionDatabaseOptions { initializeChatTables?: boolean } +/** + * List database candidates that require further inspection. + * macOS AppleDouble `._*` files contain metadata rather than SQLite data. + */ +export function listDatabaseCandidateIds(databaseDir: string): string[] { + if (!fs.existsSync(databaseDir)) return [] + + return fs + .readdirSync(databaseDir) + .filter((fileName) => fileName.endsWith('.db') && !fileName.startsWith('._')) + .map((fileName) => fileName.slice(0, -3)) +} + export class DatabaseManager { private cache = new Map() private nativeBinding?: string @@ -185,17 +198,11 @@ export class DatabaseManager { this.assertCompatible() const dbDir = this.pathProvider.getDatabaseDir() - if (!fs.existsSync(dbDir)) return [] - - return fs - .readdirSync(dbDir) - .filter((f) => f.endsWith('.db')) - .map((f) => f.replace('.db', '')) - .filter((id) => { - const db = this.open(id) - if (!db) return false - return isChatSessionDb(db) - }) + return listDatabaseCandidateIds(dbDir).filter((id) => { + const db = this.open(id) + if (!db) return false + return isChatSessionDb(db) + }) } /** @@ -205,20 +212,14 @@ export class DatabaseManager { this.assertCompatible() const dbDir = this.pathProvider.getDatabaseDir() - if (!fs.existsSync(dbDir)) return [] - - return fs - .readdirSync(dbDir) - .filter((fileName) => fileName.endsWith('.db')) - .map((fileName) => fileName.replace('.db', '')) - .filter((sessionId) => { - const db = this.openRawSessionDatabase(sessionId, { readonly: true }) - try { - return isChatSessionDb(db) - } finally { - db.close() - } - }) + return listDatabaseCandidateIds(dbDir).filter((sessionId) => { + const db = this.openRawSessionDatabase(sessionId, { readonly: true }) + try { + return isChatSessionDb(db) + } finally { + db.close() + } + }) } getUserDataDir(): string { diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index d85536a7..abe4e5cf 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -120,7 +120,7 @@ export { getSystemLogsDir, hasPendingElectronDataWarning, } from './node-path-provider' -export { DatabaseManager } from './database-manager' +export { DatabaseManager, listDatabaseCandidateIds } from './database-manager' export { createJiebaNlpProvider } from './jieba-nlp-provider' export { applyPendingNodeDataDirMigration,