From 82710e2d5af5309efbb9292250fa5f366d721cc7 Mon Sep 17 00:00:00 2001 From: digua Date: Tue, 14 Jul 2026 11:18:35 +0800 Subject: [PATCH] fix(import): keep dry-run database access read-only Use non-migrating session enumeration during import analysis so previews cannot mutate legacy schemas or raise data-directory compatibility gates. --- apps/cli/src/import/importer.test.ts | 56 +++++++++++++++++++ apps/cli/src/import/stream-import.ts | 6 +- packages/node-runtime/src/database-manager.ts | 23 ++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/import/importer.test.ts b/apps/cli/src/import/importer.test.ts index 04351363..1330a39b 100644 --- a/apps/cli/src/import/importer.test.ts +++ b/apps/cli/src/import/importer.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import test from 'node:test' +import Database from 'better-sqlite3' import type { PathProvider } from '@openchatlab/core' import { DatabaseManager, @@ -161,6 +162,61 @@ test('analyzeAutoImport previews a new session without writing a database', asyn assert.deepEqual(manager.listSessionIds(), []) }) +test('analyzeAutoImport does not migrate legacy session databases', async (t) => { + const root = makeTempDir() + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'legacy.db') + const legacyDb = new Database(dbPath, { nativeBinding }) + legacyDb.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT 4 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Legacy Chat', 'qq', 'group', 1000, 4); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + nickname TEXT + ); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + `) + legacyDb.close() + + const manager = new DatabaseManager(createPathProvider(root), { + nativeBinding, + runtime: { version: '0.25.1', kind: 'cli' }, + }) + const result = await analyzeAutoImport(manager, writeTempChatFile(root), { nativeBinding }) + + assert.equal(result.success, true) + const unchangedDb = new Database(dbPath, { readonly: true, nativeBinding }) + const schemaVersion = unchangedDb.prepare('SELECT schema_version FROM meta').pluck().get() + const memberColumns = unchangedDb.pragma('table_info(member)') as Array<{ name: string }> + unchangedDb.close() + + assert.equal(schemaVersion, 4) + assert.equal( + memberColumns.some((column) => column.name === 'account_name'), + false + ) + assert.equal(readDataDirCompatibilityMeta(path.join(root, 'data')), null) +}) + test('autoImport reports the same exact-message deduplication on create and incremental paths', async (t) => { const root = makeTempDir() t.after(() => fs.rmSync(root, { recursive: true, force: true })) diff --git a/apps/cli/src/import/stream-import.ts b/apps/cli/src/import/stream-import.ts index da0087bf..a7a6fce1 100644 --- a/apps/cli/src/import/stream-import.ts +++ b/apps/cli/src/import/stream-import.ts @@ -238,14 +238,16 @@ export async function analyzeAutoImport( if (formatId) formatOptions.formatId = formatId if (chatIndex !== undefined) formatOptions.chatIndex = chatIndex const progressAdapter = createProgressAdapter(onProgress) + let readonlySessionIds: string[] | undefined + const listSessionIdsReadonly = (): string[] => (readonlySessionIds ??= dbManager.listSessionIdsReadonly()) return sharedAnalyzeAutoImportFile( filePath, { - listSessionIds: () => dbManager.listSessionIds(), + listSessionIds: listSessionIdsReadonly, openReadonly: (candidateSessionId) => dbManager.openRawSessionDatabase(candidateSessionId, { readonly: true }), onProgress: progressAdapter, - sessionExists: (candidateSessionId) => dbManager.listSessionIds().includes(candidateSessionId), + sessionExists: (candidateSessionId) => listSessionIdsReadonly().includes(candidateSessionId), analyzeCreateSession: (sourcePath, sourceFormatOptions) => sharedAnalyzeNewImport(sourcePath, progressAdapter, { formatId: typeof sourceFormatOptions?.formatId === 'string' ? sourceFormatOptions.formatId : undefined, diff --git a/packages/node-runtime/src/database-manager.ts b/packages/node-runtime/src/database-manager.ts index f9c65419..3a36c731 100644 --- a/packages/node-runtime/src/database-manager.ts +++ b/packages/node-runtime/src/database-manager.ts @@ -198,6 +198,29 @@ export class DatabaseManager { }) } + /** + * 只读列举聊天会话 ID,不触发数据库迁移或兼容门禁提升。 + */ + listSessionIdsReadonly(): string[] { + 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() + } + }) + } + getUserDataDir(): string { return this.pathProvider.getUserDataDir() }