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.
This commit is contained in:
digua committed 2026-07-14 11:32:22 +08:00
1 parent 53406f4c6d
commit 82710e2d5a
3 files changed
+83 -2

No files matched your search

+56
View File
@@ -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 }))
+4 -2
View File
@@ -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,
@@ -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()
}