refactor(core): extract member write operations to @openchatlab/core

Move mergeMembers, deleteMember, updateMemberAliases, ensureAliasesColumn,
ensureAvatarColumn to packages/core/src/query/member-ops.ts.
All functions accept DatabaseAdapter. Electron basic.ts now wraps raw
Database with BetterSqliteAdapter and delegates to core.
Added 8 unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor committed 2026-05-17 23:25:56 +08:00
1 parent abcb1fc28f
commit d1942fb18f
5 files changed
+290 -171

No files matched your search

+22 -171
View File
@@ -21,8 +21,14 @@ import {
getMemberNameHistory as coreGetMemberNameHistory,
getMembersWithAliases as coreGetMembersWithAliases,
getMembersPaginated as coreGetMembersPaginated,
updateMemberAliases as coreUpdateMemberAliases,
mergeMembers as coreMergeMembers,
deleteMember as coreDeleteMember,
ensureAliasesColumn as coreEnsureAliasesColumn,
ensureAvatarColumn as coreEnsureAvatarColumn,
} from '@openchatlab/core'
import type { MembersPaginationParams, MembersPaginatedResult, MemberWithAliases } from '@openchatlab/core'
import { BetterSqliteAdapter } from '@openchatlab/node-runtime'
// ==================== 基础查询(委托给 core) ====================
@@ -108,77 +114,39 @@ export function getMemberNameHistory(sessionId: string, memberId: number): any[]
// ==================== 成员管理 ====================
// 用于标记已检查过 aliases 字段的会话
const aliasesCheckedSessions = new Set<string>()
// 用于标记已检查过 avatar 字段的会话
const avatarCheckedSessions = new Set<string>()
/**
* 确保 member 表有 aliases 字段(数据库迁移)
* 用于兼容旧数据库
*/
function ensureAliasesColumn(sessionId: string): void {
// 每个会话只检查一次
if (aliasesCheckedSessions.has(sessionId)) return
const dbPath = getDbPath(sessionId)
if (!fs.existsSync(dbPath)) return
// 先关闭可能缓存的只读连接
closeDatabase(sessionId)
// 使用写入模式打开数据库检查并添加字段
const db = new Database(dbPath)
db.pragma('journal_mode = WAL')
try {
// 检查 aliases 字段是否存在
const columns = db.prepare('PRAGMA table_info(member)').all() as Array<{ name: string }>
const hasAliases = columns.some((col) => col.name === 'aliases')
if (!hasAliases) {
// 添加 aliases 字段
db.exec("ALTER TABLE member ADD COLUMN aliases TEXT DEFAULT '[]'")
const adapter = new BetterSqliteAdapter(db)
if (coreEnsureAliasesColumn(adapter)) {
console.log(`[Worker] Added aliases column to member table in session ${sessionId}`)
}
// 标记为已检查
aliasesCheckedSessions.add(sessionId)
} finally {
db.close()
}
}
/**
* 确保 member 表有 avatar 字段(数据库迁移)
* 用于兼容旧数据库
*/
export function ensureAvatarColumn(sessionId: string): void {
// 每个会话只检查一次
if (avatarCheckedSessions.has(sessionId)) return
const dbPath = getDbPath(sessionId)
if (!fs.existsSync(dbPath)) return
// 先关闭可能缓存的只读连接
closeDatabase(sessionId)
// 使用写入模式打开数据库检查并添加字段
const db = new Database(dbPath)
db.pragma('journal_mode = WAL')
try {
// 检查 avatar 字段是否存在
const columns = db.prepare('PRAGMA table_info(member)').all() as Array<{ name: string }>
const hasAvatar = columns.some((col) => col.name === 'avatar')
if (!hasAvatar) {
// 添加 avatar 字段
db.exec('ALTER TABLE member ADD COLUMN avatar TEXT')
const adapter = new BetterSqliteAdapter(db)
if (coreEnsureAvatarColumn(adapter)) {
console.log(`[Worker] Added avatar column to member table in session ${sessionId}`)
}
// 标记为已检查
avatarCheckedSessions.add(sessionId)
} finally {
db.close()
@@ -217,165 +185,48 @@ export function getMembersPaginated(sessionId: string, params: MembersPagination
return coreGetMembersPaginated(db, params)
}
/**
* 更新成员别名
*/
export function updateMemberAliases(sessionId: string, memberId: number, aliases: string[]): boolean {
const dbPath = getDbPath(sessionId)
if (!fs.existsSync(dbPath)) {
return false
}
if (!fs.existsSync(dbPath)) return false
try {
const db = new Database(dbPath)
db.pragma('journal_mode = WAL')
const stmt = db.prepare('UPDATE member SET aliases = ? WHERE id = ?')
stmt.run(JSON.stringify(aliases), memberId)
const adapter = new BetterSqliteAdapter(db)
const result = coreUpdateMemberAliases(adapter, memberId, aliases)
db.close()
return true
return result
} catch (error) {
console.error('[Worker] Failed to update member aliases:', error)
return false
}
}
type MemberMergeRow = {
id: number
platformId: string
accountName: string | null
groupNickname: string | null
aliases: string | null
avatar: string | null
messageCount: number
}
function parseAliases(raw: string | null): string[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
} catch {
return []
}
}
/**
* 合并两个成员(保留消息数更多的一方)
*/
export function mergeMembers(sessionId: string, memberId1: number, memberId2: number): boolean {
const dbPath = getDbPath(sessionId)
if (!fs.existsSync(dbPath) || memberId1 === memberId2) {
return false
}
if (!fs.existsSync(dbPath)) return false
try {
const db = new Database(dbPath)
db.pragma('journal_mode = WAL')
const rows = db
.prepare(
`
SELECT
m.id,
m.platform_id as platformId,
m.account_name as accountName,
m.group_nickname as groupNickname,
m.aliases,
m.avatar,
COUNT(msg.id) as messageCount
FROM member m
LEFT JOIN message msg ON m.id = msg.sender_id
WHERE m.id IN (?, ?)
GROUP BY m.id
`
)
.all(memberId1, memberId2) as MemberMergeRow[]
if (rows.length !== 2) {
db.close()
return false
}
const [memberA, memberB] = rows
let primary = memberA
let secondary = memberB
if (
memberB.messageCount > memberA.messageCount ||
(memberB.messageCount === memberA.messageCount && memberB.id < memberA.id)
) {
primary = memberB
secondary = memberA
}
const mergedAliases = Array.from(new Set([...parseAliases(primary.aliases), ...parseAliases(secondary.aliases)]))
const mergedAccountName = primary.accountName || secondary.accountName
const mergedGroupNickname = primary.groupNickname || secondary.groupNickname
const mergedAvatar = primary.avatar || secondary.avatar
const mergeTransaction = db.transaction(() => {
// 1. 归并消息归属到主成员
db.prepare('UPDATE message SET sender_id = ? WHERE sender_id = ?').run(primary.id, secondary.id)
// 2. 归并昵称历史
db.prepare('UPDATE member_name_history SET member_id = ? WHERE member_id = ?').run(primary.id, secondary.id)
// 3. owner_id 若指向被合并成员,切换到主成员 platformId
db.prepare('UPDATE meta SET owner_id = ? WHERE owner_id = ?').run(primary.platformId, secondary.platformId)
// 4. 更新主成员资料(默认以消息更多一方为主,补齐缺失字段)
db.prepare(
`
UPDATE member
SET account_name = ?, group_nickname = ?, avatar = ?, aliases = ?
WHERE id = ?
`
).run(mergedAccountName, mergedGroupNickname, mergedAvatar, JSON.stringify(mergedAliases), primary.id)
// 5. 删除被合并成员
db.prepare('DELETE FROM member WHERE id = ?').run(secondary.id)
})
mergeTransaction()
const adapter = new BetterSqliteAdapter(db)
const result = coreMergeMembers(adapter, memberId1, memberId2)
db.close()
return true
return result
} catch (error) {
console.error('[Worker] Failed to merge members:', error)
return false
}
}
/**
* 删除成员及其所有消息
*/
export function deleteMember(sessionId: string, memberId: number): boolean {
const dbPath = getDbPath(sessionId)
if (!fs.existsSync(dbPath)) {
return false
}
if (!fs.existsSync(dbPath)) return false
try {
const db = new Database(dbPath)
db.pragma('journal_mode = WAL')
// 使用事务删除成员及其相关数据
const deleteTransaction = db.transaction(() => {
// 1. 删除该成员的消息
db.prepare('DELETE FROM message WHERE sender_id = ?').run(memberId)
// 2. 删除该成员的昵称历史
db.prepare('DELETE FROM member_name_history WHERE member_id = ?').run(memberId)
// 3. 删除成员记录
db.prepare('DELETE FROM member WHERE id = ?').run(memberId)
})
deleteTransaction()
const adapter = new BetterSqliteAdapter(db)
const result = coreDeleteMember(adapter, memberId)
db.close()
return true
return result
} catch (error) {
console.error('[Worker] Failed to delete member:', error)
return false
+5
View File
@@ -91,6 +91,11 @@ export {
fetchConversationBetween,
filterMessagesWithContext,
getMultipleSessionsMessages,
updateMemberAliases,
mergeMembers,
deleteMember,
ensureAliasesColumn,
ensureAvatarColumn,
} from './query'
// 查询类型
@@ -0,0 +1,110 @@
/**
* Tests for shared member write operations.
*
* Run: npx tsx --test packages/core/src/query/__tests__/member-ops.test.ts
*/
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../interfaces/database-adapter'
import { updateMemberAliases, mergeMembers, deleteMember, ensureAliasesColumn, ensureAvatarColumn } from '../member-ops'
function createInMemoryDb(): DatabaseAdapter & { rows: Map<string, Record<string, unknown>[]>; execLog: string[] } {
const rows = new Map<string, Record<string, unknown>[]>()
const execLog: string[] = []
return {
rows,
execLog,
exec(sql: string) {
execLog.push(sql)
},
prepare(sql: string): PreparedStatement {
return {
get(..._params: unknown[]): Record<string, unknown> | undefined {
return rows.get(sql)?.[0]
},
all(..._params: unknown[]): Record<string, unknown>[] {
return rows.get(sql) ?? []
},
run(..._params: unknown[]): RunResult {
execLog.push(`run:${sql.trim().substring(0, 50)}`)
return { changes: 1 }
},
}
},
transaction<T>(fn: () => T): T {
return fn()
},
pragma() {
return undefined
},
close() {
/* no-op */
},
}
}
describe('updateMemberAliases', () => {
it('returns true on success', () => {
const db = createInMemoryDb()
const result = updateMemberAliases(db, 1, ['nickname1', 'nickname2'])
assert.equal(result, true)
})
})
describe('mergeMembers', () => {
it('returns false for same id', () => {
const db = createInMemoryDb()
assert.equal(mergeMembers(db, 1, 1), false)
})
it('returns false when members not found', () => {
const db = createInMemoryDb()
assert.equal(mergeMembers(db, 1, 2), false)
})
})
describe('deleteMember', () => {
it('executes delete transaction', () => {
const db = createInMemoryDb()
const result = deleteMember(db, 42)
assert.equal(result, true)
assert.ok(db.execLog.some((s) => s.includes('DELETE FROM message')))
assert.ok(db.execLog.some((s) => s.includes('DELETE FROM member')))
})
})
describe('ensureAliasesColumn', () => {
it('adds column when missing', () => {
const db = createInMemoryDb()
db.rows.set('PRAGMA table_info(member)', [{ name: 'id' }, { name: 'platform_id' }])
const added = ensureAliasesColumn(db)
assert.equal(added, true)
assert.ok(db.execLog.some((s) => s.includes('ALTER TABLE member ADD COLUMN aliases')))
})
it('skips when column exists', () => {
const db = createInMemoryDb()
db.rows.set('PRAGMA table_info(member)', [{ name: 'id' }, { name: 'aliases' }])
const added = ensureAliasesColumn(db)
assert.equal(added, false)
})
})
describe('ensureAvatarColumn', () => {
it('adds column when missing', () => {
const db = createInMemoryDb()
db.rows.set('PRAGMA table_info(member)', [{ name: 'id' }])
const added = ensureAvatarColumn(db)
assert.equal(added, true)
assert.ok(db.execLog.some((s) => s.includes('ALTER TABLE member ADD COLUMN avatar')))
})
it('skips when column exists', () => {
const db = createInMemoryDb()
db.rows.set('PRAGMA table_info(member)', [{ name: 'id' }, { name: 'avatar' }])
const added = ensureAvatarColumn(db)
assert.equal(added, false)
})
})
+3
View File
@@ -121,6 +121,9 @@ export {
} from './message-query-functions'
export type { AsyncPaginatedMessages, AsyncMessagesWithTotal, AsyncConversationData } from './message-query-functions'
// Member write operations (merge, delete, update aliases, DDL migration)
export { updateMemberAliases, mergeMembers, deleteMember, ensureAliasesColumn, ensureAvatarColumn } from './member-ops'
// Filter with context (keyword/sender/time filter + pagination)
export { filterMessagesWithContext, getMultipleSessionsMessages } from './filter'
export type {
+150
View File
@@ -0,0 +1,150 @@
/**
* Member write operations (merge, delete, update aliases).
*
* All functions accept a DatabaseAdapter, keeping them platform-agnostic.
* The caller is responsible for opening/closing the DB connection.
*/
import type { DatabaseAdapter } from '../interfaces/database-adapter'
// ==================== Helpers ====================
function parseAliases(raw: string | null): string[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
} catch {
return []
}
}
// ==================== Public API ====================
export function updateMemberAliases(db: DatabaseAdapter, memberId: number, aliases: string[]): boolean {
try {
db.prepare('UPDATE member SET aliases = ? WHERE id = ?').run(JSON.stringify(aliases), memberId)
return true
} catch {
return false
}
}
interface MemberMergeRow {
id: number
platformId: string
accountName: string | null
groupNickname: string | null
aliases: string | null
avatar: string | null
messageCount: number
}
/**
* Merge two members — messages and name history are reassigned to the one
* with more messages (or the lower id on tie). The secondary member is deleted.
*/
export function mergeMembers(db: DatabaseAdapter, memberId1: number, memberId2: number): boolean {
if (memberId1 === memberId2) return false
try {
const rows = db
.prepare(
`
SELECT
m.id,
m.platform_id as platformId,
m.account_name as accountName,
m.group_nickname as groupNickname,
m.aliases,
m.avatar,
COUNT(msg.id) as messageCount
FROM member m
LEFT JOIN message msg ON m.id = msg.sender_id
WHERE m.id IN (?, ?)
GROUP BY m.id
`
)
.all(memberId1, memberId2) as unknown as MemberMergeRow[]
if (rows.length !== 2) return false
const [memberA, memberB] = rows
let primary = memberA
let secondary = memberB
if (
memberB.messageCount > memberA.messageCount ||
(memberB.messageCount === memberA.messageCount && memberB.id < memberA.id)
) {
primary = memberB
secondary = memberA
}
const mergedAliases = Array.from(new Set([...parseAliases(primary.aliases), ...parseAliases(secondary.aliases)]))
const mergedAccountName = primary.accountName || secondary.accountName
const mergedGroupNickname = primary.groupNickname || secondary.groupNickname
const mergedAvatar = primary.avatar || secondary.avatar
db.transaction(() => {
db.prepare('UPDATE message SET sender_id = ? WHERE sender_id = ?').run(primary.id, secondary.id)
db.prepare('UPDATE member_name_history SET member_id = ? WHERE member_id = ?').run(primary.id, secondary.id)
db.prepare('UPDATE meta SET owner_id = ? WHERE owner_id = ?').run(primary.platformId, secondary.platformId)
db.prepare(`UPDATE member SET account_name = ?, group_nickname = ?, avatar = ?, aliases = ? WHERE id = ?`).run(
mergedAccountName,
mergedGroupNickname,
mergedAvatar,
JSON.stringify(mergedAliases),
primary.id
)
db.prepare('DELETE FROM member WHERE id = ?').run(secondary.id)
})
return true
} catch {
return false
}
}
/**
* Delete a member and all their messages / name history.
*/
export function deleteMember(db: DatabaseAdapter, memberId: number): boolean {
try {
db.transaction(() => {
db.prepare('DELETE FROM message WHERE sender_id = ?').run(memberId)
db.prepare('DELETE FROM member_name_history WHERE member_id = ?').run(memberId)
db.prepare('DELETE FROM member WHERE id = ?').run(memberId)
})
return true
} catch {
return false
}
}
/**
* Ensure the `aliases` column exists on the `member` table (DDL migration).
*/
export function ensureAliasesColumn(db: DatabaseAdapter): boolean {
const columns = db.prepare('PRAGMA table_info(member)').all() as unknown as Array<{ name: string }>
const has = columns.some((col) => col.name === 'aliases')
if (!has) {
db.exec("ALTER TABLE member ADD COLUMN aliases TEXT DEFAULT '[]'")
return true
}
return false
}
/**
* Ensure the `avatar` column exists on the `member` table (DDL migration).
*/
export function ensureAvatarColumn(db: DatabaseAdapter): boolean {
const columns = db.prepare('PRAGMA table_info(member)').all() as unknown as Array<{ name: string }>
const has = columns.some((col) => col.name === 'avatar')
if (!has) {
db.exec('ALTER TABLE member ADD COLUMN avatar TEXT')
return true
}
return false
}