mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(node-runtime): extract session cache to shared package
- Extract JSON cache CRUD + overview/members compute to node-runtime/cache - Functions now accept DatabaseAdapter instead of better-sqlite3 Database - Electron sessionCache.ts becomes pure re-export - Updated all Electron callers to wrap DB with BetterSqliteAdapter
This commit is contained in:
1 parent
064f3ce144
commit
9ee3060377
7 files changed
+213
-186
No files matched your search
@@ -1,187 +1,20 @@
|
||||
/**
|
||||
* 会话级通用 JSON 缓存模块
|
||||
* Session cache — Electron re-export layer.
|
||||
*
|
||||
* 每个 session 对应一个 {sessionId}.cache.json,内部按 key 分区存储。
|
||||
* 与数据库 schema 完全解耦,读取失败自动重建,无需版本管理。
|
||||
*
|
||||
* 文件路径: {cacheDir}/{sessionId}.cache.json
|
||||
* All logic has been extracted to @openchatlab/node-runtime/cache.
|
||||
* This file provides backward-compatible imports for existing Electron code.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import type Database from 'better-sqlite3'
|
||||
export {
|
||||
getCachePath,
|
||||
getCache,
|
||||
setCache,
|
||||
invalidateCache,
|
||||
deleteSessionCache,
|
||||
computeAndSetOverviewCache,
|
||||
computeAndSetMembersCache,
|
||||
CACHE_KEY_OVERVIEW,
|
||||
CACHE_KEY_MEMBERS,
|
||||
} from '@openchatlab/node-runtime'
|
||||
|
||||
// ==================== 通用缓存基础设施 ====================
|
||||
|
||||
interface CacheEntry<T = unknown> {
|
||||
data: T
|
||||
ts: number
|
||||
}
|
||||
|
||||
type CacheFile = Record<string, CacheEntry>
|
||||
|
||||
export function getCachePath(sessionId: string, cacheDir: string): string {
|
||||
return path.join(cacheDir, `${sessionId}.cache.json`)
|
||||
}
|
||||
|
||||
function readCacheFile(cachePath: string): CacheFile | null {
|
||||
try {
|
||||
if (!fs.existsSync(cachePath)) return null
|
||||
return JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as CacheFile
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeCacheFile(cachePath: string, content: CacheFile): void {
|
||||
try {
|
||||
const dir = path.dirname(cachePath)
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
fs.writeFileSync(cachePath, JSON.stringify(content), 'utf-8')
|
||||
} catch {
|
||||
// 写入失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取缓存分区
|
||||
*/
|
||||
export function getCache<T>(sessionId: string, key: string, cacheDir: string): T | null {
|
||||
const cachePath = getCachePath(sessionId, cacheDir)
|
||||
const file = readCacheFile(cachePath)
|
||||
if (!file || !file[key]) return null
|
||||
return file[key].data as T
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入缓存分区
|
||||
*/
|
||||
export function setCache<T>(sessionId: string, key: string, data: T, cacheDir: string): void {
|
||||
const cachePath = getCachePath(sessionId, cacheDir)
|
||||
const file = readCacheFile(cachePath) ?? {}
|
||||
file[key] = { data, ts: Math.floor(Date.now() / 1000) }
|
||||
writeCacheFile(cachePath, file)
|
||||
}
|
||||
|
||||
/**
|
||||
* 使缓存失效(不传 key 则清空整个文件)
|
||||
*/
|
||||
export function invalidateCache(sessionId: string, cacheDir: string, key?: string): void {
|
||||
const cachePath = getCachePath(sessionId, cacheDir)
|
||||
try {
|
||||
if (!key) {
|
||||
if (fs.existsSync(cachePath)) fs.unlinkSync(cachePath)
|
||||
} else {
|
||||
const file = readCacheFile(cachePath)
|
||||
if (file && file[key]) {
|
||||
delete file[key]
|
||||
writeCacheFile(cachePath, file)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略失败
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 session 的所有缓存
|
||||
*/
|
||||
export function deleteSessionCache(sessionId: string, cacheDir: string): void {
|
||||
const cachePath = getCachePath(sessionId, cacheDir)
|
||||
try {
|
||||
if (fs.existsSync(cachePath)) fs.unlinkSync(cachePath)
|
||||
} catch {
|
||||
// 忽略删除失败
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Overview 缓存(聚合统计) ====================
|
||||
|
||||
export const CACHE_KEY_OVERVIEW = 'overview'
|
||||
|
||||
export interface OverviewCache {
|
||||
totalMessages: number
|
||||
totalMembers: number
|
||||
firstMessageTs: number | null
|
||||
lastMessageTs: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库计算概览统计并写入缓存
|
||||
*/
|
||||
export function computeAndSetOverviewCache(db: Database.Database, sessionId: string, cacheDir: string): OverviewCache {
|
||||
const msgStats = db.prepare('SELECT MIN(ts) as first_ts, MAX(ts) as last_ts FROM message').get() as {
|
||||
first_ts: number | null
|
||||
last_ts: number | null
|
||||
}
|
||||
|
||||
const totalMessages = (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as count FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
WHERE COALESCE(m.account_name, '') != '系统消息'`
|
||||
)
|
||||
.get() as { count: number }
|
||||
).count
|
||||
|
||||
const totalMembers = (
|
||||
db.prepare(`SELECT COUNT(*) as count FROM member WHERE COALESCE(account_name, '') != '系统消息'`).get() as {
|
||||
count: number
|
||||
}
|
||||
).count
|
||||
|
||||
const data: OverviewCache = {
|
||||
totalMessages,
|
||||
totalMembers,
|
||||
firstMessageTs: msgStats.first_ts,
|
||||
lastMessageTs: msgStats.last_ts,
|
||||
}
|
||||
|
||||
setCache(sessionId, CACHE_KEY_OVERVIEW, data, cacheDir)
|
||||
|
||||
// 同步生成成员缓存
|
||||
computeAndSetMembersCache(db, sessionId, cacheDir)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// ==================== Members 缓存(成员维度) ====================
|
||||
|
||||
export const CACHE_KEY_MEMBERS = 'members'
|
||||
|
||||
export interface MemberStat {
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface MembersCache {
|
||||
/** member.id -> { name, count } */
|
||||
members: Record<number, MemberStat>
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库计算成员统计并写入缓存
|
||||
*/
|
||||
export function computeAndSetMembersCache(db: Database.Database, sessionId: string, cacheDir: string): MembersCache {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT msg.sender_id, COUNT(*) as count,
|
||||
COALESCE(m.group_nickname, m.account_name, m.platform_id) as name
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
GROUP BY msg.sender_id`
|
||||
)
|
||||
.all() as Array<{ sender_id: number; count: number; name: string }>
|
||||
|
||||
const members: Record<number, MemberStat> = {}
|
||||
for (const row of rows) {
|
||||
members[row.sender_id] = { name: row.name, count: row.count }
|
||||
}
|
||||
|
||||
const data: MembersCache = { members }
|
||||
setCache(sessionId, CACHE_KEY_MEMBERS, data, cacheDir)
|
||||
return data
|
||||
}
|
||||
export type { OverviewCache, MembersCache, MemberStat } from '@openchatlab/node-runtime'
|
||||
@@ -44,7 +44,7 @@ function buildDeps(requestId: string): IncrementalImportDeps {
|
||||
const { computeAndSetOverviewCache } = await import('../../database/sessionCache')
|
||||
const dbPath = getDbPath(sessionId)
|
||||
const rawDb = new Database(dbPath)
|
||||
computeAndSetOverviewCache(rawDb, sessionId, getCacheDir())
|
||||
computeAndSetOverviewCache(new BetterSqliteAdapter(rawDb), sessionId, getCacheDir())
|
||||
rawDb.close()
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
|
||||
@@ -72,7 +72,7 @@ function buildStreamImportDeps(requestId: string): StreamImportDeps {
|
||||
const { computeAndSetOverviewCache } = await import('../../database/sessionCache')
|
||||
const dbPath = getDbPath(sessionId)
|
||||
const rawDb = new Database(dbPath)
|
||||
computeAndSetOverviewCache(rawDb, sessionId, getCacheDir())
|
||||
computeAndSetOverviewCache(new BetterSqliteAdapter(rawDb), sessionId, getCacheDir())
|
||||
rawDb.close()
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3'
|
||||
import { BetterSqliteAdapter } from '@openchatlab/node-runtime'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { openDatabase, getDbDir, getDbPath, getCacheDir } from '../core'
|
||||
@@ -89,7 +90,7 @@ function resolveOverview(db: Database.Database, sessionId: string, cacheDir: str
|
||||
let cached = cacheDir ? getCache<OverviewCache>(sessionId, CACHE_KEY_OVERVIEW, cacheDir) : null
|
||||
if (!cached && cacheDir) {
|
||||
try {
|
||||
cached = computeAndSetOverviewCache(db, sessionId, cacheDir)
|
||||
cached = computeAndSetOverviewCache(new BetterSqliteAdapter(db), sessionId, cacheDir)
|
||||
} catch {
|
||||
// cache compute failure — fall through to live query
|
||||
}
|
||||
@@ -197,7 +198,7 @@ export function getChatOverview(sessionId: string, topN: number = 10) {
|
||||
let membersCache = getCache<MembersCache>(sessionId, CACHE_KEY_MEMBERS, cacheDir)
|
||||
if (!membersCache) {
|
||||
try {
|
||||
membersCache = computeAndSetMembersCache(db, sessionId, cacheDir)
|
||||
membersCache = computeAndSetMembersCache(new BetterSqliteAdapter(db), sessionId, cacheDir)
|
||||
} catch {
|
||||
// fallback: no member data
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
getCachePath,
|
||||
getCache,
|
||||
setCache,
|
||||
invalidateCache,
|
||||
deleteSessionCache,
|
||||
computeAndSetOverviewCache,
|
||||
computeAndSetMembersCache,
|
||||
CACHE_KEY_OVERVIEW,
|
||||
CACHE_KEY_MEMBERS,
|
||||
} from './session-cache'
|
||||
export type { OverviewCache, MembersCache, MemberStat } from './session-cache'
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Session-level JSON cache module (platform-agnostic).
|
||||
*
|
||||
* Extracted from electron/main/database/sessionCache.ts.
|
||||
* Each session has a {sessionId}.cache.json file partitioned by key.
|
||||
* Decoupled from DB schema — reads auto-rebuild on failure, no versioning needed.
|
||||
*
|
||||
* File path: {cacheDir}/{sessionId}.cache.json
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import type { DatabaseAdapter } from '@openchatlab/core'
|
||||
|
||||
// ==================== Generic cache infrastructure ====================
|
||||
|
||||
interface CacheEntry<T = unknown> {
|
||||
data: T
|
||||
ts: number
|
||||
}
|
||||
|
||||
type CacheFile = Record<string, CacheEntry>
|
||||
|
||||
export function getCachePath(sessionId: string, cacheDir: string): string {
|
||||
return path.join(cacheDir, `${sessionId}.cache.json`)
|
||||
}
|
||||
|
||||
function readCacheFile(cachePath: string): CacheFile | null {
|
||||
try {
|
||||
if (!fs.existsSync(cachePath)) return null
|
||||
return JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as CacheFile
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeCacheFile(cachePath: string, content: CacheFile): void {
|
||||
try {
|
||||
const dir = path.dirname(cachePath)
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
fs.writeFileSync(cachePath, JSON.stringify(content), 'utf-8')
|
||||
} catch {
|
||||
// Write failure is non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
export function getCache<T>(sessionId: string, key: string, cacheDir: string): T | null {
|
||||
const cachePath = getCachePath(sessionId, cacheDir)
|
||||
const file = readCacheFile(cachePath)
|
||||
if (!file || !file[key]) return null
|
||||
return file[key].data as T
|
||||
}
|
||||
|
||||
export function setCache<T>(sessionId: string, key: string, data: T, cacheDir: string): void {
|
||||
const cachePath = getCachePath(sessionId, cacheDir)
|
||||
const file = readCacheFile(cachePath) ?? {}
|
||||
file[key] = { data, ts: Math.floor(Date.now() / 1000) }
|
||||
writeCacheFile(cachePath, file)
|
||||
}
|
||||
|
||||
export function invalidateCache(sessionId: string, cacheDir: string, key?: string): void {
|
||||
const cachePath = getCachePath(sessionId, cacheDir)
|
||||
try {
|
||||
if (!key) {
|
||||
if (fs.existsSync(cachePath)) fs.unlinkSync(cachePath)
|
||||
} else {
|
||||
const file = readCacheFile(cachePath)
|
||||
if (file && file[key]) {
|
||||
delete file[key]
|
||||
writeCacheFile(cachePath, file)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteSessionCache(sessionId: string, cacheDir: string): void {
|
||||
const cachePath = getCachePath(sessionId, cacheDir)
|
||||
try {
|
||||
if (fs.existsSync(cachePath)) fs.unlinkSync(cachePath)
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Overview cache (aggregate stats) ====================
|
||||
|
||||
export const CACHE_KEY_OVERVIEW = 'overview'
|
||||
|
||||
export interface OverviewCache {
|
||||
totalMessages: number
|
||||
totalMembers: number
|
||||
firstMessageTs: number | null
|
||||
lastMessageTs: number | null
|
||||
}
|
||||
|
||||
export function computeAndSetOverviewCache(db: DatabaseAdapter, sessionId: string, cacheDir: string): OverviewCache {
|
||||
const msgStats = db.prepare('SELECT MIN(ts) as first_ts, MAX(ts) as last_ts FROM message').get() as {
|
||||
first_ts: number | null
|
||||
last_ts: number | null
|
||||
}
|
||||
|
||||
const totalMessages = (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as count FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
WHERE COALESCE(m.account_name, '') != '系统消息'`
|
||||
)
|
||||
.get() as { count: number }
|
||||
).count
|
||||
|
||||
const totalMembers = (
|
||||
db.prepare(`SELECT COUNT(*) as count FROM member WHERE COALESCE(account_name, '') != '系统消息'`).get() as {
|
||||
count: number
|
||||
}
|
||||
).count
|
||||
|
||||
const data: OverviewCache = {
|
||||
totalMessages,
|
||||
totalMembers,
|
||||
firstMessageTs: msgStats.first_ts,
|
||||
lastMessageTs: msgStats.last_ts,
|
||||
}
|
||||
|
||||
setCache(sessionId, CACHE_KEY_OVERVIEW, data, cacheDir)
|
||||
computeAndSetMembersCache(db, sessionId, cacheDir)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// ==================== Members cache (per-member stats) ====================
|
||||
|
||||
export const CACHE_KEY_MEMBERS = 'members'
|
||||
|
||||
export interface MemberStat {
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface MembersCache {
|
||||
members: Record<number, MemberStat>
|
||||
}
|
||||
|
||||
export function computeAndSetMembersCache(db: DatabaseAdapter, sessionId: string, cacheDir: string): MembersCache {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT msg.sender_id, COUNT(*) as count,
|
||||
COALESCE(m.group_nickname, m.account_name, m.platform_id) as name
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
GROUP BY msg.sender_id`
|
||||
)
|
||||
.all() as Array<{ sender_id: number; count: number; name: string }>
|
||||
|
||||
const members: Record<number, MemberStat> = {}
|
||||
for (const row of rows) {
|
||||
members[row.sender_id] = { name: row.name, count: row.count }
|
||||
}
|
||||
|
||||
const data: MembersCache = { members }
|
||||
setCache(sessionId, CACHE_KEY_MEMBERS, data, cacheDir)
|
||||
return data
|
||||
}
|
||||
@@ -188,6 +188,20 @@ export type { PiModelConfig, BuildPiModelOptions } from './ai'
|
||||
export { fetchRemoteModels, validateApiKey } from './ai'
|
||||
export type { RemoteModel, FetchRemoteModelsResult, RemoteApiOptions } from './ai'
|
||||
|
||||
// Session cache (overview + members JSON file cache)
|
||||
export {
|
||||
getCachePath,
|
||||
getCache,
|
||||
setCache,
|
||||
invalidateCache,
|
||||
deleteSessionCache,
|
||||
computeAndSetOverviewCache,
|
||||
computeAndSetMembersCache,
|
||||
CACHE_KEY_OVERVIEW,
|
||||
CACHE_KEY_MEMBERS,
|
||||
} from './cache'
|
||||
export type { OverviewCache, MembersCache, MemberStat } from './cache'
|
||||
|
||||
// Chat DB migrations
|
||||
export { getChatDbMigrations } from './migrations'
|
||||
export type { MigrationDeps } from './migrations'
|
||||
|
||||
Reference in new issue
Block a user