diff --git a/apps/cli/src/sync/routes.test.ts b/apps/cli/src/sync/routes.test.ts new file mode 100644 index 00000000..835e41ce --- /dev/null +++ b/apps/cli/src/sync/routes.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import Fastify from 'fastify' +import { DataSourceManager } from '@openchatlab/sync' +import { registerAutomationRoutes } from './routes' + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-sync-routes-')) +} + +test('DELETE import session deletes imported local session when deleteData=true', async () => { + const settingsDir = makeTempDir() + const dsManager = new DataSourceManager(settingsDir) + const ds = dsManager.add({ + baseUrl: 'http://example.com', + token: 'token', + intervalMinutes: 60, + }) + const [session] = dsManager.addSessions(ds.id, [{ name: 'First chat', remoteSessionId: 'remote-1' }]) + dsManager.updateSession(ds.id, session.id, { targetSessionId: 'local-1' }) + + const deletedSessionIds: string[] = [] + const app = Fastify() + registerAutomationRoutes(app, { + dsManager, + pullEngine: { + triggerPull: async () => ({ success: true, newMessageCount: 0 }), + triggerPullAll: async () => ({ success: true, newMessageCount: 0 }), + getProgress: () => [], + } as any, + dbManager: { + deleteSessionDatabaseFiles: (sessionId: string) => { + deletedSessionIds.push(sessionId) + }, + } as any, + serverInfo: { port: 5200, host: '127.0.0.1', token: 'api-token' }, + }) + await app.ready() + + const resp = await app.inject({ + method: 'DELETE', + url: `/_web/automation/data-sources/${ds.id}/sessions/${session.id}?deleteData=true`, + }) + + await app.close() + + assert.equal(resp.statusCode, 200) + assert.deepEqual(deletedSessionIds, ['local-1']) + assert.equal(dsManager.get(ds.id)?.sessions.length, 0) +}) diff --git a/apps/cli/src/sync/routes.ts b/apps/cli/src/sync/routes.ts index a1909050..8d146341 100644 --- a/apps/cli/src/sync/routes.ts +++ b/apps/cli/src/sync/routes.ts @@ -91,10 +91,7 @@ export function registerAutomationRoutes(server: FastifyInstance, ctx: SyncRoute if (!removed) return reply.code(404).send({ error: 'Session not found' }) reloadTimer(request.params.id) if (request.query.deleteData === 'true' && removed.targetSessionId) { - dbManager.close(removed.targetSessionId) - const dbPath = dbManager.getDbPath(removed.targetSessionId) - const fs = await import('node:fs') - if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath) + dbManager.deleteSessionDatabaseFiles(removed.targetSessionId) } return { success: true } }) diff --git a/packages/node-runtime/src/database-manager.ts b/packages/node-runtime/src/database-manager.ts index 7c04022e..07321bb9 100644 --- a/packages/node-runtime/src/database-manager.ts +++ b/packages/node-runtime/src/database-manager.ts @@ -18,6 +18,7 @@ import { getSchemaVersion, } from '@openchatlab/core' import { openBetterSqliteDatabase } from './better-sqlite3-adapter' +import { deleteSessionCache } from './cache/session-cache' import { assertDataDirCompatible, type RuntimeIdentity } from './data-dir-compat' import { CHAT_DB_COMPATIBILITY_RAISES, @@ -225,10 +226,15 @@ export class DatabaseManager { return adapter } - deleteSessionDatabaseFiles(sessionId: string): void { + /** + * 删除一个会话在本地留下的完整文件集合。 + * SQLite WAL 模式会产生 sidecar 文件,前端查询还会写 JSON 缓存;只删主库会让列表和后续同步读到脏状态。 + */ + deleteSessionDatabaseFiles(sessionId: string): boolean { this.close(sessionId) const dbPath = this.getDbPath(sessionId) + const existed = ['', '-wal', '-shm'].some((suffix) => fs.existsSync(dbPath + suffix)) for (const suffix of ['', '-wal', '-shm']) { try { const filePath = dbPath + suffix @@ -237,6 +243,10 @@ export class DatabaseManager { /* ignore cleanup failures */ } } + const cacheDir = this.pathProvider.getCacheDir() + deleteSessionCache(sessionId, cacheDir) + deleteSessionCache(sessionId, path.join(cacheDir, 'query')) + return existed } raiseCurrentChatDbCompatibilityGate(): void { diff --git a/packages/node-runtime/src/services/adapters/database-manager-adapter.test.ts b/packages/node-runtime/src/services/adapters/database-manager-adapter.test.ts new file mode 100644 index 00000000..bfbddb93 --- /dev/null +++ b/packages/node-runtime/src/services/adapters/database-manager-adapter.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import type { PathProvider } from '@openchatlab/core' +import { DatabaseManager } from '../../database-manager' +import { createDatabaseManagerAdapter } from './database-manager-adapter' + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-db-adapter-')) +} + +function createPathProvider(root: string): PathProvider { + return { + getSystemDir: () => root, + getUserDataDir: () => path.join(root, 'data'), + getDatabaseDir: () => path.join(root, 'data', 'databases'), + getAiDataDir: () => path.join(root, 'ai'), + getSettingsDir: () => path.join(root, 'settings'), + getCacheDir: () => path.join(root, 'cache'), + getTempDir: () => path.join(root, 'temp'), + getLogsDir: () => path.join(root, 'logs'), + getDownloadsDir: () => path.join(root, 'downloads'), + } +} + +test('DatabaseManager adapter deletes session database sidecar files and caches', () => { + const root = makeTempDir() + const pathProvider = createPathProvider(root) + const manager = new DatabaseManager(pathProvider, { allowMissingRuntimeForTests: true }) + const adapter = createDatabaseManagerAdapter(manager) + + const sessionId = 'synced-session' + const dbPath = manager.getDbPath(sessionId) + fs.mkdirSync(path.dirname(dbPath), { recursive: true }) + for (const suffix of ['', '-wal', '-shm']) { + fs.writeFileSync(dbPath + suffix, 'stale-data', 'utf-8') + } + + const cachePath = path.join(pathProvider.getCacheDir(), `${sessionId}.cache.json`) + const queryCachePath = path.join(pathProvider.getCacheDir(), 'query', `${sessionId}.cache.json`) + fs.mkdirSync(path.dirname(cachePath), { recursive: true }) + fs.mkdirSync(path.dirname(queryCachePath), { recursive: true }) + fs.writeFileSync(cachePath, '{}', 'utf-8') + fs.writeFileSync(queryCachePath, '{}', 'utf-8') + + assert.equal(adapter.deleteSessionFile(sessionId), true) + assert.equal(fs.existsSync(dbPath), false) + assert.equal(fs.existsSync(dbPath + '-wal'), false) + assert.equal(fs.existsSync(dbPath + '-shm'), false) + assert.equal(fs.existsSync(cachePath), false) + assert.equal(fs.existsSync(queryCachePath), false) +}) diff --git a/packages/node-runtime/src/services/adapters/database-manager-adapter.ts b/packages/node-runtime/src/services/adapters/database-manager-adapter.ts index 61422c66..d1316db6 100644 --- a/packages/node-runtime/src/services/adapters/database-manager-adapter.ts +++ b/packages/node-runtime/src/services/adapters/database-manager-adapter.ts @@ -5,7 +5,6 @@ * interface expected by session/member services. */ -import * as fs from 'fs' import type { DatabaseAdapter } from '@openchatlab/core' import type { DatabaseManager } from '../../database-manager' import type { SessionRuntimeAdapter } from './types' @@ -19,11 +18,7 @@ export function createDatabaseManagerAdapter(dbManager: DatabaseManager): Sessio getDbPath: (sessionId) => dbManager.getDbPath(sessionId), deleteSessionFile(sessionId: string): boolean { - dbManager.close(sessionId) - const dbPath = dbManager.getDbPath(sessionId) - if (!fs.existsSync(dbPath)) return false - fs.unlinkSync(dbPath) - return true + return dbManager.deleteSessionDatabaseFiles(sessionId) }, ensureReadonly(sessionId: string): DatabaseAdapter { diff --git a/packages/node-runtime/src/services/adapters/types.ts b/packages/node-runtime/src/services/adapters/types.ts index 48a75fa9..ae3e84be 100644 --- a/packages/node-runtime/src/services/adapters/types.ts +++ b/packages/node-runtime/src/services/adapters/types.ts @@ -14,7 +14,7 @@ export interface SessionRuntimeAdapter { closeSession(sessionId: string): void getDbPath(sessionId: string): string - /** Delete the session database file. Returns false if not found. */ + /** Delete the session database files and cache. Returns false if not found. */ deleteSessionFile(sessionId: string): boolean /** Open readonly, throw 404 if not found. */ diff --git a/packages/node-runtime/src/services/session-service.ts b/packages/node-runtime/src/services/session-service.ts index 52bc01d1..2647237e 100644 --- a/packages/node-runtime/src/services/session-service.ts +++ b/packages/node-runtime/src/services/session-service.ts @@ -126,7 +126,7 @@ export function updateSessionOwnerId(adapter: SessionRuntimeAdapter, sessionId: } /** - * Delete a session (close DB + remove file). + * Delete a session (close DB + remove files/cache). * Returns true if deleted, false if file not found. */ export function deleteSession(adapter: SessionRuntimeAdapter, sessionId: string): boolean {