refactor(database): migrate to new modular database layer and unify data access
- Remove legacy database.ts, LocalDataSource.ts, and MessageRepository.ts - Create new src/database/ module with messageRepository, userCacheRepository, conversationRepository, and groupCacheRepository - Update all consumers to import from @/database instead of services/database - Add web platform blur handling for modal components to fix focus issues - Flatten SystemMessageItem and NotificationsScreen styles for consistent design - Add draggable slider in ChatSettingsScreen and dynamic font size support - Introduce 9 new chat color themes - Add profile screens for about, terms, and privacy policy with navigation routes - Add policy links to login and registration screens - Fix post share URL format from /posts/ to /post/
This commit is contained in:
123
src/database/LocalDataSource.ts
Normal file
123
src/database/LocalDataSource.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import { ILocalDataSource, DataSourceError } from '@/data/datasources/interfaces';
|
||||
import { databaseManager } from './core/DatabaseManager';
|
||||
|
||||
let writeQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
export class LocalDataSource implements ILocalDataSource {
|
||||
async initialize(userId?: string): Promise<void> {
|
||||
await databaseManager.initialize(userId);
|
||||
}
|
||||
|
||||
private async ensureDb(): Promise<SQLite.SQLiteDatabase> {
|
||||
return databaseManager.getDb();
|
||||
}
|
||||
|
||||
async query<T>(sql: string, params?: any[]): Promise<T[]> {
|
||||
try {
|
||||
const db = await this.ensureDb();
|
||||
if (params && params.length > 0) {
|
||||
return await db.getAllAsync<T>(sql, params as any);
|
||||
}
|
||||
return await db.getAllAsync<T>(sql);
|
||||
} catch (error) {
|
||||
this.handleError(error, 'QUERY');
|
||||
}
|
||||
}
|
||||
|
||||
async getFirst<T>(sql: string, params?: any[]): Promise<T | null> {
|
||||
try {
|
||||
const db = await this.ensureDb();
|
||||
if (params && params.length > 0) {
|
||||
return await db.getFirstAsync<T>(sql, params as any);
|
||||
}
|
||||
return await db.getFirstAsync<T>(sql);
|
||||
} catch (error) {
|
||||
this.handleError(error, 'GET_FIRST');
|
||||
}
|
||||
}
|
||||
|
||||
async execute(sql: string, params?: any[]): Promise<void> {
|
||||
try {
|
||||
const db = await this.ensureDb();
|
||||
await db.execAsync(sql);
|
||||
} catch (error) {
|
||||
this.handleError(error, 'EXECUTE');
|
||||
}
|
||||
}
|
||||
|
||||
async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> {
|
||||
try {
|
||||
const db = await this.ensureDb();
|
||||
if (params && params.length > 0) {
|
||||
return await db.runAsync(sql, params as any);
|
||||
}
|
||||
return await db.runAsync(sql);
|
||||
} catch (error) {
|
||||
this.handleError(error, 'RUN');
|
||||
}
|
||||
}
|
||||
|
||||
async transaction(operations: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
const db = await this.ensureDb();
|
||||
await db.withTransactionAsync(async () => {
|
||||
await operations();
|
||||
});
|
||||
} catch (error) {
|
||||
this.handleError(error, 'TRANSACTION');
|
||||
}
|
||||
}
|
||||
|
||||
async batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await this.transaction(async () => {
|
||||
await operations(this);
|
||||
});
|
||||
} catch (error) {
|
||||
this.handleError(error, 'BATCH');
|
||||
}
|
||||
}
|
||||
|
||||
async enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const wrappedOperation = async () => {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (this.isRecoverableError(error)) {
|
||||
console.error('[LocalDataSource] 数据库写入异常,尝试重连后重试:', error);
|
||||
await databaseManager.initialize(databaseManager.getCurrentUserId());
|
||||
return operation();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const queued = writeQueue.then(wrappedOperation, wrappedOperation);
|
||||
writeQueue = queued.then(() => undefined, () => undefined);
|
||||
return queued;
|
||||
}
|
||||
|
||||
private isRecoverableError(error: unknown): boolean {
|
||||
const message = String(error);
|
||||
return (
|
||||
message.includes('NativeDatabase.prepareAsync') ||
|
||||
message.includes('NullPointerException') ||
|
||||
message.includes('database is locked') ||
|
||||
message.includes('finalizing statement') ||
|
||||
message.includes('cannot create file')
|
||||
);
|
||||
}
|
||||
|
||||
private handleError(error: unknown, operation: string): never {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new DataSourceError(
|
||||
`Database ${operation} failed: ${message}`,
|
||||
'DB_ERROR',
|
||||
'LocalDataSource',
|
||||
error instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const localDataSource = new LocalDataSource();
|
||||
14
src/database/core/DatabaseConfig.ts
Normal file
14
src/database/core/DatabaseConfig.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const DB_CONFIG = {
|
||||
MAX_RETRIES: 4,
|
||||
WEB_RETRY_DELAY_BASE: 500,
|
||||
NATIVE_RETRY_DELAY: 100,
|
||||
WEB_CLOSE_DELAY: 300,
|
||||
WEB_FINAL_CLOSE_DELAY: 200,
|
||||
LOCK_PREFIX: 'db-',
|
||||
DEFAULT_DB_NAME: 'carrot_bbs.db',
|
||||
DB_NAME_PREFIX: 'carrot_bbs_',
|
||||
} as const;
|
||||
|
||||
export const getDbName = (userId: string | null): string => {
|
||||
return userId ? `${DB_CONFIG.DB_NAME_PREFIX}${userId}.db` : DB_CONFIG.DEFAULT_DB_NAME;
|
||||
};
|
||||
196
src/database/core/DatabaseManager.ts
Normal file
196
src/database/core/DatabaseManager.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import { LockManager } from './LockManager';
|
||||
import { DB_CONFIG, getDbName } from './DatabaseConfig';
|
||||
import { runMigrations } from '../schema/MigrationManager';
|
||||
|
||||
type SQLiteDatabase = SQLite.SQLiteDatabase;
|
||||
|
||||
class DatabaseManager {
|
||||
private static instance: DatabaseManager;
|
||||
private db: SQLiteDatabase | null = null;
|
||||
private currentUserId: string | null = null;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private initTargetUserId: string | null = null;
|
||||
private isInitialized = false;
|
||||
|
||||
static getInstance(): DatabaseManager {
|
||||
if (!DatabaseManager.instance) {
|
||||
DatabaseManager.instance = new DatabaseManager();
|
||||
}
|
||||
return DatabaseManager.instance;
|
||||
}
|
||||
|
||||
async initialize(userId?: string | null): Promise<void> {
|
||||
const targetUserId = userId || null;
|
||||
const callId = Date.now();
|
||||
|
||||
console.log(`[DatabaseManager ${callId}] initialize 开始, userId=${targetUserId}, 当前db=${!!this.db}, 当前userId=${this.currentUserId}`);
|
||||
|
||||
if (this.db && this.currentUserId === targetUserId) {
|
||||
console.log(`[DatabaseManager ${callId}] 已是目标数据库,直接返回`);
|
||||
this.isInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.initPromise && this.initTargetUserId === targetUserId) {
|
||||
console.log(`[DatabaseManager ${callId}] 等待进行中的初始化`);
|
||||
await this.initPromise;
|
||||
console.log(`[DatabaseManager ${callId}] 初始化完成`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.initPromise) {
|
||||
console.log(`[DatabaseManager ${callId}] 等待其他初始化完成`);
|
||||
await this.initPromise.catch(() => {});
|
||||
}
|
||||
|
||||
if (this.db && this.currentUserId === targetUserId) {
|
||||
console.log(`[DatabaseManager ${callId}] 其他初始化已完成目标,直接返回`);
|
||||
this.isInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.initTargetUserId = targetUserId;
|
||||
console.log(`[DatabaseManager ${callId}] 开始实际初始化`);
|
||||
|
||||
this.initPromise = LockManager.withMutex(async () => {
|
||||
const mutexId = callId;
|
||||
console.log(`[DatabaseManager ${mutexId}] 获得互斥锁`);
|
||||
|
||||
if (this.db && this.currentUserId === targetUserId) {
|
||||
console.log(`[DatabaseManager ${mutexId}] 互斥锁内检查:已是目标数据库`);
|
||||
this.isInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const isWeb = typeof window !== 'undefined';
|
||||
|
||||
if (this.db) {
|
||||
console.log(`[DatabaseManager ${mutexId}] 关闭旧数据库连接...`);
|
||||
try {
|
||||
await this.db.closeAsync();
|
||||
console.log(`[DatabaseManager ${mutexId}] 旧连接已关闭`);
|
||||
} catch (e) {
|
||||
console.warn(`[DatabaseManager ${mutexId}] 关闭旧连接失败:`, e);
|
||||
}
|
||||
this.db = null;
|
||||
this.currentUserId = null;
|
||||
this.isInitialized = false;
|
||||
|
||||
if (isWeb) {
|
||||
await new Promise(r => setTimeout(r, DB_CONFIG.WEB_CLOSE_DELAY));
|
||||
}
|
||||
}
|
||||
|
||||
const dbName = getDbName(targetUserId);
|
||||
console.log(`[DatabaseManager ${mutexId}] 打开数据库: ${dbName}`);
|
||||
|
||||
const database = await this.openDatabaseWithRetry(dbName);
|
||||
console.log(`[DatabaseManager ${mutexId}] 数据库已打开,执行迁移`);
|
||||
|
||||
await runMigrations(database);
|
||||
console.log(`[DatabaseManager ${mutexId}] 迁移完成`);
|
||||
|
||||
this.db = database;
|
||||
this.currentUserId = targetUserId;
|
||||
this.isInitialized = true;
|
||||
console.log(`[DatabaseManager ${mutexId}] 初始化完成`);
|
||||
}).finally(() => {
|
||||
console.log(`[DatabaseManager ${callId}] 清理初始化状态`);
|
||||
this.initPromise = null;
|
||||
this.initTargetUserId = null;
|
||||
});
|
||||
|
||||
await this.initPromise;
|
||||
console.log(`[DatabaseManager ${callId}] initialize 返回`);
|
||||
}
|
||||
|
||||
private async openDatabaseWithRetry(dbName: string): Promise<SQLiteDatabase> {
|
||||
const isWeb = typeof window !== 'undefined';
|
||||
|
||||
return LockManager.withWebLock(`${DB_CONFIG.LOCK_PREFIX}${dbName}`, async () => {
|
||||
for (let attempt = 0; attempt <= DB_CONFIG.MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const database = await SQLite.openDatabaseAsync(dbName);
|
||||
console.log(`[DatabaseManager] 成功打开数据库 (尝试 ${attempt + 1}/${DB_CONFIG.MAX_RETRIES + 1})`);
|
||||
return database;
|
||||
} catch (error) {
|
||||
const msg = String(error);
|
||||
console.error(`[DatabaseManager] 打开失败 (尝试 ${attempt + 1}/${DB_CONFIG.MAX_RETRIES + 1}):`, msg);
|
||||
|
||||
if (attempt < DB_CONFIG.MAX_RETRIES) {
|
||||
const delay = isWeb ? DB_CONFIG.WEB_RETRY_DELAY_BASE * (attempt + 1) : DB_CONFIG.NATIVE_RETRY_DELAY;
|
||||
console.warn(`[DatabaseManager] ${delay}ms 后重试...`);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('打开数据库失败');
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.initPromise) {
|
||||
await this.initPromise.catch(() => {});
|
||||
}
|
||||
|
||||
const dbName = getDbName(this.currentUserId);
|
||||
|
||||
await LockManager.withWebLock(`${DB_CONFIG.LOCK_PREFIX}${dbName}`, async () => {
|
||||
await LockManager.withMutex(async () => {
|
||||
if (!this.db) return;
|
||||
|
||||
try {
|
||||
await this.db.closeAsync();
|
||||
console.log('[DatabaseManager] 数据库已关闭');
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
await new Promise(r => setTimeout(r, DB_CONFIG.WEB_FINAL_CLOSE_DELAY));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[DatabaseManager] 关闭失败:', e);
|
||||
}
|
||||
this.db = null;
|
||||
this.currentUserId = null;
|
||||
this.isInitialized = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getDb(timeout: number = 10000): Promise<SQLiteDatabase> {
|
||||
if (this.isInitialized && this.db) return this.db;
|
||||
|
||||
if (this.initPromise) {
|
||||
const timeoutPromise = new Promise<null>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('等待数据库初始化超时')), timeout)
|
||||
);
|
||||
|
||||
await Promise.race([this.initPromise, timeoutPromise]);
|
||||
|
||||
if (this.db) return this.db;
|
||||
}
|
||||
|
||||
throw new Error('数据库未初始化,请先调用 initialize(userId)');
|
||||
}
|
||||
|
||||
getDbSync(): SQLiteDatabase | null {
|
||||
return this.db;
|
||||
}
|
||||
|
||||
getCurrentUserId(): string | null {
|
||||
return this.currentUserId;
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.isInitialized && this.db !== null;
|
||||
}
|
||||
|
||||
async withMutex<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return LockManager.withMutex(fn);
|
||||
}
|
||||
}
|
||||
|
||||
export const databaseManager = DatabaseManager.getInstance();
|
||||
export { DatabaseManager };
|
||||
34
src/database/core/LockManager.ts
Normal file
34
src/database/core/LockManager.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export class LockManager {
|
||||
private static mutex: Promise<void> = Promise.resolve();
|
||||
|
||||
static async withMutex<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const prev = LockManager.mutex;
|
||||
let release: () => void = () => {};
|
||||
LockManager.mutex = new Promise<void>((r) => { release = r; });
|
||||
try {
|
||||
await prev;
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
static async withWebLock<T>(lockName: string, fn: () => Promise<T>): Promise<T> {
|
||||
if (typeof navigator !== 'undefined' && navigator.locks) {
|
||||
return new Promise((resolve, reject) => {
|
||||
navigator.locks.request(lockName, { mode: 'exclusive' }, async (lock) => {
|
||||
if (!lock) {
|
||||
return reject(new Error('无法获取跨标签页锁'));
|
||||
}
|
||||
try {
|
||||
const result = await fn();
|
||||
resolve(result);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return fn();
|
||||
}
|
||||
}
|
||||
54
src/database/index.ts
Normal file
54
src/database/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { localDataSource } from './LocalDataSource';
|
||||
import { databaseManager } from './core/DatabaseManager';
|
||||
|
||||
export { databaseManager } from './core/DatabaseManager';
|
||||
export { DatabaseManager } from './core/DatabaseManager';
|
||||
export { localDataSource, LocalDataSource } from './LocalDataSource';
|
||||
|
||||
export {
|
||||
messageRepository, MessageRepository,
|
||||
conversationRepository, ConversationRepository,
|
||||
userCacheRepository, UserCacheRepository,
|
||||
groupCacheRepository, GroupCacheRepository,
|
||||
postCacheRepository, PostCacheRepository,
|
||||
} from './repositories';
|
||||
|
||||
export { CachedMessage } from './types';
|
||||
|
||||
export async function clearAllData(): Promise<void> {
|
||||
await localDataSource.enqueueWrite(async () => {
|
||||
await localDataSource.execute(`DELETE FROM messages`);
|
||||
await localDataSource.execute(`DELETE FROM conversations`);
|
||||
await localDataSource.execute(`DELETE FROM conversation_cache`);
|
||||
await localDataSource.execute(`DELETE FROM conversation_list_cache`);
|
||||
await localDataSource.execute(`DELETE FROM users`);
|
||||
await localDataSource.execute(`DELETE FROM current_user_cache`);
|
||||
await localDataSource.execute(`DELETE FROM groups`);
|
||||
await localDataSource.execute(`DELETE FROM group_members`);
|
||||
await localDataSource.execute(`DELETE FROM posts_cache`);
|
||||
});
|
||||
}
|
||||
|
||||
export const initDatabase = async (userId?: string | null): Promise<void> => {
|
||||
await databaseManager.initialize(userId);
|
||||
};
|
||||
|
||||
export const closeDatabase = async (): Promise<void> => {
|
||||
await databaseManager.close();
|
||||
};
|
||||
|
||||
export const getCurrentDbUserId = (): string | null => {
|
||||
return databaseManager.getCurrentUserId();
|
||||
};
|
||||
|
||||
export const isDbInitialized = (): boolean => {
|
||||
return databaseManager.isReady();
|
||||
};
|
||||
|
||||
export const getDbInstance = () => {
|
||||
return databaseManager.getDbSync();
|
||||
};
|
||||
|
||||
export const getDb = async (timeout?: number) => {
|
||||
return databaseManager.getDb(timeout);
|
||||
};
|
||||
197
src/database/repositories/ConversationRepository.ts
Normal file
197
src/database/repositories/ConversationRepository.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { localDataSource } from '../LocalDataSource';
|
||||
import type { ILocalDataSource } from '@/data/datasources/interfaces';
|
||||
import type { ConversationResponse, ConversationDetailResponse } from '@/types/dto';
|
||||
|
||||
type ConversationCacheData = ConversationResponse | ConversationDetailResponse;
|
||||
|
||||
const safeParseJson = <T>(value: string): T | null => {
|
||||
try { return JSON.parse(value) as T; } catch { return null; }
|
||||
};
|
||||
|
||||
export interface IConversationRepository {
|
||||
save(conv: { id: string; participantId: string; lastMessageId?: string; lastSeq?: number; unreadCount?: number; createdAt: string; updatedAt: string }): Promise<void>;
|
||||
getAll(): Promise<any[]>;
|
||||
updateUnreadCount(id: string, count: number): Promise<void>;
|
||||
updateLastMessage(id: string, lastMessageId: string, lastSeq: number, updatedAt: string): Promise<void>;
|
||||
delete(conversationId: string): Promise<void>;
|
||||
getCache(id: string): Promise<ConversationCacheData | null>;
|
||||
getListCache(): Promise<ConversationResponse[]>;
|
||||
saveCache(conv: ConversationCacheData): Promise<void>;
|
||||
saveListCache(convs: ConversationResponse[]): Promise<void>;
|
||||
updateListEntry(id: string, updates: Partial<ConversationResponse>): Promise<void>;
|
||||
updateCacheUnreadCount(id: string, count: number, myLastReadSeq?: number): Promise<void>;
|
||||
saveWithRelated(convs: ConversationResponse[], users?: any[], groups?: any[]): Promise<void>;
|
||||
}
|
||||
|
||||
export class ConversationRepository implements IConversationRepository {
|
||||
constructor(private dataSource: ILocalDataSource = localDataSource) {}
|
||||
|
||||
async save(conv: { id: string; participantId: string; lastMessageId?: string; lastSeq?: number; unreadCount?: number; createdAt: string; updatedAt: string }): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
await this.dataSource.run(
|
||||
`INSERT OR REPLACE INTO conversations (id, participantId, lastMessageId, lastSeq, unreadCount, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[conv.id, conv.participantId, conv.lastMessageId || null, conv.lastSeq || 0, conv.unreadCount || 0, conv.createdAt, conv.updatedAt]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getAll(): Promise<any[]> {
|
||||
return this.dataSource.query<any>(`SELECT * FROM conversations ORDER BY updatedAt DESC`);
|
||||
}
|
||||
|
||||
async updateUnreadCount(id: string, count: number): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
await this.dataSource.run(`UPDATE conversations SET unreadCount = ? WHERE id = ?`, [count, id]);
|
||||
});
|
||||
}
|
||||
|
||||
async updateLastMessage(id: string, lastMessageId: string, lastSeq: number, updatedAt: string): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
await this.dataSource.run(
|
||||
`UPDATE conversations SET lastMessageId = ?, lastSeq = ?, updatedAt = ? WHERE id = ?`,
|
||||
[lastMessageId, lastSeq, updatedAt, id]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async delete(conversationId: string): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
await this.dataSource.run(`DELETE FROM messages WHERE conversationId = ?`, [conversationId]);
|
||||
await this.dataSource.run(`DELETE FROM conversations WHERE id = ?`, [conversationId]);
|
||||
});
|
||||
}
|
||||
|
||||
async getCache(id: string): Promise<ConversationCacheData | null> {
|
||||
const r = await this.dataSource.getFirst<{ data: string }>(
|
||||
`SELECT data FROM conversation_cache WHERE id = ?`, [String(id)]
|
||||
);
|
||||
return r?.data ? safeParseJson<ConversationCacheData>(r.data) : null;
|
||||
}
|
||||
|
||||
async getListCache(): Promise<ConversationResponse[]> {
|
||||
let rows = await this.dataSource.query<{ data: string }>(
|
||||
`SELECT data FROM conversation_list_cache ORDER BY updatedAt DESC`
|
||||
);
|
||||
if (!rows.length) {
|
||||
rows = await this.dataSource.query<{ data: string }>(
|
||||
`SELECT data FROM conversation_cache ORDER BY updatedAt DESC`
|
||||
);
|
||||
}
|
||||
const list = rows.map(r => safeParseJson<ConversationResponse>(r.data))
|
||||
.filter((c): c is ConversationResponse => Boolean(c))
|
||||
.filter(c => typeof c.id !== 'undefined' && typeof c.type !== 'undefined');
|
||||
return list.sort((a, b) => {
|
||||
const aPinned = a.is_pinned ? 1 : 0;
|
||||
const bPinned = b.is_pinned ? 1 : 0;
|
||||
if (aPinned !== bPinned) return bPinned - aPinned;
|
||||
const aTime = new Date(a.last_message_at || a.updated_at || 0).getTime();
|
||||
const bTime = new Date(b.last_message_at || b.updated_at || 0).getTime();
|
||||
return bTime - aTime;
|
||||
});
|
||||
}
|
||||
|
||||
async saveCache(conv: ConversationCacheData): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
const updatedAt = ('updated_at' in conv && conv.updated_at) ? String(conv.updated_at) : new Date().toISOString();
|
||||
await this.dataSource.run(
|
||||
`INSERT OR REPLACE INTO conversation_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
||||
[String(conv.id), JSON.stringify(conv), updatedAt]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async saveListCache(convs: ConversationResponse[]): Promise<void> {
|
||||
if (!convs?.length) return;
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
for (const c of convs) {
|
||||
await this.dataSource.run(
|
||||
`INSERT OR REPLACE INTO conversation_list_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
||||
[String(c.id), JSON.stringify(c), c.updated_at || new Date().toISOString()]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async updateListEntry(id: string, updates: Partial<ConversationResponse>): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
const r = await this.dataSource.getFirst<{ data: string }>(
|
||||
`SELECT data FROM conversation_list_cache WHERE id = ?`, [String(id)]
|
||||
);
|
||||
if (!r?.data) return;
|
||||
const conv = safeParseJson<ConversationResponse>(r.data);
|
||||
if (!conv) return;
|
||||
const updated = { ...conv, ...updates };
|
||||
const updatedAt = updates.last_message_at || updated.last_message_at || new Date().toISOString();
|
||||
await this.dataSource.run(
|
||||
`UPDATE conversation_list_cache SET data = ?, updatedAt = ? WHERE id = ?`,
|
||||
[JSON.stringify(updated), updatedAt, String(id)]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async updateCacheUnreadCount(id: string, count: number, myLastReadSeq?: number): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
const listRow = await this.dataSource.getFirst<{ data: string }>(
|
||||
`SELECT data FROM conversation_list_cache WHERE id = ?`, [String(id)]
|
||||
);
|
||||
if (listRow?.data) {
|
||||
const conv = safeParseJson<ConversationResponse>(listRow.data);
|
||||
if (conv) {
|
||||
conv.unread_count = count;
|
||||
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) conv.my_last_read_seq = myLastReadSeq;
|
||||
await this.dataSource.run(
|
||||
`UPDATE conversation_list_cache SET data = ? WHERE id = ?`,
|
||||
[JSON.stringify(conv), String(id)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cacheRow = await this.dataSource.getFirst<{ data: string }>(
|
||||
`SELECT data FROM conversation_cache WHERE id = ?`, [String(id)]
|
||||
);
|
||||
if (cacheRow?.data) {
|
||||
const conv = safeParseJson<Record<string, unknown>>(cacheRow.data);
|
||||
if (conv) {
|
||||
conv.unread_count = count;
|
||||
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) conv.my_last_read_seq = myLastReadSeq;
|
||||
await this.dataSource.run(
|
||||
`UPDATE conversation_cache SET data = ? WHERE id = ?`,
|
||||
[JSON.stringify(conv), String(id)]
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async saveWithRelated(convs: ConversationResponse[], users?: any[], groups?: any[]): Promise<void> {
|
||||
if (!convs?.length) return;
|
||||
const now = new Date().toISOString();
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
for (const c of convs) {
|
||||
await this.dataSource.run(
|
||||
`INSERT OR REPLACE INTO conversation_list_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
||||
[String(c.id), JSON.stringify(c), c.updated_at || now]
|
||||
);
|
||||
}
|
||||
if (users?.length) {
|
||||
for (const u of users) {
|
||||
await this.dataSource.run(
|
||||
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
|
||||
[String(u.id), JSON.stringify(u), now]
|
||||
);
|
||||
}
|
||||
}
|
||||
if (groups?.length) {
|
||||
for (const g of groups) {
|
||||
await this.dataSource.run(
|
||||
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
|
||||
[String(g.id), JSON.stringify(g), now]
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const conversationRepository = new ConversationRepository();
|
||||
78
src/database/repositories/GroupCacheRepository.ts
Normal file
78
src/database/repositories/GroupCacheRepository.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { localDataSource } from '../LocalDataSource';
|
||||
import type { ILocalDataSource } from '@/data/datasources/interfaces';
|
||||
import type { GroupResponse, GroupMemberResponse } from '@/types/dto';
|
||||
|
||||
const safeParseJson = <T>(value: string): T | null => {
|
||||
try { return JSON.parse(value) as T; } catch { return null; }
|
||||
};
|
||||
|
||||
export interface IGroupCacheRepository {
|
||||
save(group: GroupResponse): Promise<void>;
|
||||
saveBatch(groups: GroupResponse[]): Promise<void>;
|
||||
get(groupId: string): Promise<GroupResponse | null>;
|
||||
getAll(): Promise<GroupResponse[]>;
|
||||
saveMembers(groupId: string, members: GroupMemberResponse[]): Promise<void>;
|
||||
getMembers(groupId: string): Promise<GroupMemberResponse[]>;
|
||||
}
|
||||
|
||||
export class GroupCacheRepository implements IGroupCacheRepository {
|
||||
constructor(private dataSource: ILocalDataSource = localDataSource) {}
|
||||
|
||||
async save(group: GroupResponse): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
await this.dataSource.run(
|
||||
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
|
||||
[String(group.id), JSON.stringify(group), new Date().toISOString()]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async saveBatch(groups: GroupResponse[]): Promise<void> {
|
||||
if (!groups?.length) return;
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
const now = new Date().toISOString();
|
||||
for (const g of groups) {
|
||||
await this.dataSource.run(
|
||||
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
|
||||
[String(g.id), JSON.stringify(g), now]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async get(groupId: string): Promise<GroupResponse | null> {
|
||||
const r = await this.dataSource.getFirst<{ data: string }>(
|
||||
`SELECT data FROM groups WHERE id = ?`, [String(groupId)]
|
||||
);
|
||||
return r?.data ? safeParseJson<GroupResponse>(r.data) : null;
|
||||
}
|
||||
|
||||
async getAll(): Promise<GroupResponse[]> {
|
||||
const rows = await this.dataSource.query<{ data: string }>(
|
||||
`SELECT data FROM groups ORDER BY updatedAt DESC`
|
||||
);
|
||||
return rows.map(r => safeParseJson<GroupResponse>(r.data)).filter((g): g is GroupResponse => Boolean(g));
|
||||
}
|
||||
|
||||
async saveMembers(groupId: string, members: GroupMemberResponse[]): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
const now = new Date().toISOString();
|
||||
await this.dataSource.run(`DELETE FROM group_members WHERE groupId = ?`, [String(groupId)]);
|
||||
for (const m of members) {
|
||||
await this.dataSource.run(
|
||||
`INSERT OR REPLACE INTO group_members (groupId, userId, data, updatedAt) VALUES (?, ?, ?, ?)`,
|
||||
[String(groupId), String(m.user_id), JSON.stringify(m), now]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getMembers(groupId: string): Promise<GroupMemberResponse[]> {
|
||||
const rows = await this.dataSource.query<{ data: string }>(
|
||||
`SELECT data FROM group_members WHERE groupId = ? ORDER BY updatedAt DESC`, [String(groupId)]
|
||||
);
|
||||
return rows.map(r => safeParseJson<GroupMemberResponse>(r.data)).filter((m): m is GroupMemberResponse => Boolean(m));
|
||||
}
|
||||
}
|
||||
|
||||
export const groupCacheRepository = new GroupCacheRepository();
|
||||
152
src/database/repositories/MessageRepository.ts
Normal file
152
src/database/repositories/MessageRepository.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { localDataSource } from '../LocalDataSource';
|
||||
import { CachedMessage } from '../types';
|
||||
import type { ILocalDataSource } from '@/data/datasources/interfaces';
|
||||
|
||||
export interface IMessageRepository {
|
||||
saveMessage(message: CachedMessage): Promise<void>;
|
||||
saveMessagesBatch(messages: CachedMessage[]): Promise<void>;
|
||||
getByConversation(conversationId: string, limit?: number): Promise<CachedMessage[]>;
|
||||
getMaxSeq(conversationId: string): Promise<number>;
|
||||
getMinSeq(conversationId: string): Promise<number>;
|
||||
getBeforeSeq(conversationId: string, beforeSeq: number, limit?: number): Promise<CachedMessage[]>;
|
||||
getCount(conversationId: string): Promise<number>;
|
||||
getCountBeforeSeq(conversationId: string, beforeSeq: number): Promise<number>;
|
||||
markAsRead(messageId: string): Promise<void>;
|
||||
markConversationAsRead(conversationId: string): Promise<void>;
|
||||
delete(messageId: string): Promise<void>;
|
||||
updateStatus(messageId: string, status: string, clearContent?: boolean): Promise<void>;
|
||||
clearConversation(conversationId: string): Promise<void>;
|
||||
search(keyword: string): Promise<CachedMessage[]>;
|
||||
getStats(): Promise<{ totalMessages: number; totalConversations: number }>;
|
||||
}
|
||||
|
||||
export class MessageRepository implements IMessageRepository {
|
||||
constructor(private dataSource: ILocalDataSource = localDataSource) {}
|
||||
|
||||
async saveMessage(message: CachedMessage): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
await this.dataSource.run(
|
||||
`INSERT OR REPLACE INTO messages (id, conversationId, senderId, content, type, isRead, createdAt, seq, status, segments)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[message.id, message.conversationId, message.senderId, message.content || '',
|
||||
message.type || 'text', message.isRead ? 1 : 0, message.createdAt,
|
||||
message.seq || 0, message.status || 'normal',
|
||||
message.segments ? JSON.stringify(message.segments) : null]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async saveMessagesBatch(messages: CachedMessage[]): Promise<void> {
|
||||
if (!messages?.length) return;
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
for (const msg of messages) {
|
||||
await this.dataSource.run(
|
||||
`INSERT OR REPLACE INTO messages (id, conversationId, senderId, content, type, isRead, createdAt, seq, status, segments)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[msg.id, msg.conversationId, msg.senderId, msg.content || '',
|
||||
msg.type || 'text', msg.isRead ? 1 : 0, msg.createdAt,
|
||||
msg.seq || 0, msg.status || 'normal',
|
||||
msg.segments ? JSON.stringify(msg.segments) : null]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getByConversation(conversationId: string, limit: number = 20): Promise<CachedMessage[]> {
|
||||
const rows = await this.dataSource.query<any>(
|
||||
`SELECT * FROM messages WHERE conversationId = ? ORDER BY seq DESC LIMIT ?`,
|
||||
[conversationId, limit]
|
||||
);
|
||||
return rows.reverse().map(r => ({
|
||||
...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
async getMaxSeq(conversationId: string): Promise<number> {
|
||||
const r = await this.dataSource.getFirst<{ maxSeq: number }>(
|
||||
`SELECT MAX(seq) as maxSeq FROM messages WHERE conversationId = ?`, [conversationId]
|
||||
);
|
||||
return r?.maxSeq || 0;
|
||||
}
|
||||
|
||||
async getMinSeq(conversationId: string): Promise<number> {
|
||||
const r = await this.dataSource.getFirst<{ minSeq: number }>(
|
||||
`SELECT MIN(seq) as minSeq FROM messages WHERE conversationId = ?`, [conversationId]
|
||||
);
|
||||
return r?.minSeq || 0;
|
||||
}
|
||||
|
||||
async getBeforeSeq(conversationId: string, beforeSeq: number, limit: number = 20): Promise<CachedMessage[]> {
|
||||
const rows = await this.dataSource.query<any>(
|
||||
`SELECT * FROM messages WHERE conversationId = ? AND seq < ? ORDER BY seq DESC LIMIT ?`,
|
||||
[conversationId, beforeSeq, limit]
|
||||
);
|
||||
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined })).reverse();
|
||||
}
|
||||
|
||||
async getCount(conversationId: string): Promise<number> {
|
||||
const r = await this.dataSource.getFirst<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ?`, [conversationId]
|
||||
);
|
||||
return r?.count || 0;
|
||||
}
|
||||
|
||||
async getCountBeforeSeq(conversationId: string, beforeSeq: number): Promise<number> {
|
||||
const r = await this.dataSource.getFirst<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ? AND seq < ?`, [conversationId, beforeSeq]
|
||||
);
|
||||
return r?.count || 0;
|
||||
}
|
||||
|
||||
async markAsRead(messageId: string): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
await this.dataSource.run(`UPDATE messages SET isRead = 1 WHERE id = ?`, [messageId]);
|
||||
});
|
||||
}
|
||||
|
||||
async markConversationAsRead(conversationId: string): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
await this.dataSource.run(`UPDATE messages SET isRead = 1 WHERE conversationId = ?`, [conversationId]);
|
||||
});
|
||||
}
|
||||
|
||||
async delete(messageId: string): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
await this.dataSource.run(`DELETE FROM messages WHERE id = ?`, [messageId]);
|
||||
});
|
||||
}
|
||||
|
||||
async updateStatus(messageId: string, status: string, clearContent: boolean = false): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
if (clearContent) {
|
||||
await this.dataSource.run(
|
||||
`UPDATE messages SET status = ?, content = '', segments = '[]' WHERE id = ?`,
|
||||
[status, messageId]
|
||||
);
|
||||
} else {
|
||||
await this.dataSource.run(`UPDATE messages SET status = ? WHERE id = ?`, [status, messageId]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async clearConversation(conversationId: string): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
await this.dataSource.run(`DELETE FROM messages WHERE conversationId = ?`, [conversationId]);
|
||||
});
|
||||
}
|
||||
|
||||
async search(keyword: string): Promise<CachedMessage[]> {
|
||||
const rows = await this.dataSource.query<any>(
|
||||
`SELECT * FROM messages WHERE content LIKE ? ORDER BY createdAt DESC LIMIT 50`, [`%${keyword}%`]
|
||||
);
|
||||
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined }));
|
||||
}
|
||||
|
||||
async getStats(): Promise<{ totalMessages: number; totalConversations: number }> {
|
||||
const msgCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM messages`);
|
||||
const convCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM conversations`);
|
||||
return { totalMessages: msgCount?.count || 0, totalConversations: convCount?.count || 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export const messageRepository = new MessageRepository();
|
||||
44
src/database/repositories/PostCacheRepository.ts
Normal file
44
src/database/repositories/PostCacheRepository.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { localDataSource } from '../LocalDataSource';
|
||||
import type { ILocalDataSource } from '@/data/datasources/interfaces';
|
||||
|
||||
export interface IPostCacheRepository {
|
||||
save(id: string, data: any): Promise<void>;
|
||||
get(id: string): Promise<any | null>;
|
||||
getAll(): Promise<any[]>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class PostCacheRepository implements IPostCacheRepository {
|
||||
constructor(private dataSource: ILocalDataSource = localDataSource) {}
|
||||
|
||||
async save(id: string, data: any): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
await this.dataSource.run(
|
||||
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
||||
[String(id), JSON.stringify(data), new Date().toISOString()]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async get(id: string): Promise<any | null> {
|
||||
const r = await this.dataSource.getFirst<{ data: string }>(
|
||||
`SELECT data FROM posts_cache WHERE id = ?`, [String(id)]
|
||||
);
|
||||
return r?.data ? JSON.parse(r.data) : null;
|
||||
}
|
||||
|
||||
async getAll(): Promise<any[]> {
|
||||
const rows = await this.dataSource.query<{ data: string }>(
|
||||
`SELECT data FROM posts_cache ORDER BY updatedAt DESC`
|
||||
);
|
||||
return rows.map(r => JSON.parse(r.data));
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
await this.dataSource.run(`DELETE FROM posts_cache WHERE id = ?`, [String(id)]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const postCacheRepository = new PostCacheRepository();
|
||||
81
src/database/repositories/UserCacheRepository.ts
Normal file
81
src/database/repositories/UserCacheRepository.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { localDataSource } from '../LocalDataSource';
|
||||
import type { ILocalDataSource } from '@/data/datasources/interfaces';
|
||||
import type { UserDTO } from '@/types/dto';
|
||||
|
||||
const safeParseJson = <T>(value: string): T | null => {
|
||||
try { return JSON.parse(value) as T; } catch { return null; }
|
||||
};
|
||||
|
||||
export interface IUserCacheRepository {
|
||||
save(user: UserDTO): Promise<void>;
|
||||
saveBatch(users: UserDTO[]): Promise<void>;
|
||||
get(userId: string): Promise<UserDTO | null>;
|
||||
getLatest(): Promise<UserDTO | null>;
|
||||
saveCurrent(user: UserDTO): Promise<void>;
|
||||
getCurrent(): Promise<UserDTO | null>;
|
||||
clearCurrent(): Promise<void>;
|
||||
}
|
||||
|
||||
export class UserCacheRepository implements IUserCacheRepository {
|
||||
constructor(private dataSource: ILocalDataSource = localDataSource) {}
|
||||
|
||||
async save(user: UserDTO): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
await this.dataSource.run(
|
||||
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
|
||||
[String(user.id), JSON.stringify(user), new Date().toISOString()]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async saveBatch(users: UserDTO[]): Promise<void> {
|
||||
if (!users?.length) return;
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
const now = new Date().toISOString();
|
||||
for (const u of users) {
|
||||
await this.dataSource.run(
|
||||
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
|
||||
[String(u.id), JSON.stringify(u), now]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async get(userId: string): Promise<UserDTO | null> {
|
||||
const r = await this.dataSource.getFirst<{ data: string }>(
|
||||
`SELECT data FROM users WHERE id = ?`, [String(userId)]
|
||||
);
|
||||
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
|
||||
}
|
||||
|
||||
async getLatest(): Promise<UserDTO | null> {
|
||||
const r = await this.dataSource.getFirst<{ data: string }>(
|
||||
`SELECT data FROM users ORDER BY updatedAt DESC LIMIT 1`
|
||||
);
|
||||
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
|
||||
}
|
||||
|
||||
async saveCurrent(user: UserDTO): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
await this.dataSource.run(
|
||||
`INSERT OR REPLACE INTO current_user_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
||||
['me', JSON.stringify(user), new Date().toISOString()]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getCurrent(): Promise<UserDTO | null> {
|
||||
const r = await this.dataSource.getFirst<{ data: string }>(
|
||||
`SELECT data FROM current_user_cache WHERE id = 'me'`
|
||||
);
|
||||
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
|
||||
}
|
||||
|
||||
async clearCurrent(): Promise<void> {
|
||||
await this.dataSource.enqueueWrite(async () => {
|
||||
await this.dataSource.run(`DELETE FROM current_user_cache WHERE id = 'me'`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const userCacheRepository = new UserCacheRepository();
|
||||
5
src/database/repositories/index.ts
Normal file
5
src/database/repositories/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { IMessageRepository, MessageRepository, messageRepository } from './MessageRepository';
|
||||
export { IConversationRepository, ConversationRepository, conversationRepository } from './ConversationRepository';
|
||||
export { IUserCacheRepository, UserCacheRepository, userCacheRepository } from './UserCacheRepository';
|
||||
export { IGroupCacheRepository, GroupCacheRepository, groupCacheRepository } from './GroupCacheRepository';
|
||||
export { IPostCacheRepository, PostCacheRepository, postCacheRepository } from './PostCacheRepository';
|
||||
40
src/database/schema/ConversationSchema.ts
Normal file
40
src/database/schema/ConversationSchema.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { SQLiteDatabase } from 'expo-sqlite';
|
||||
|
||||
export const CONVERSATIONS_TABLE = 'conversations';
|
||||
export const CONVERSATION_CACHE_TABLE = 'conversation_cache';
|
||||
export const CONVERSATION_LIST_CACHE_TABLE = 'conversation_list_cache';
|
||||
|
||||
export const CREATE_CONVERSATIONS_TABLE = `CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
participantId TEXT NOT NULL,
|
||||
lastMessageId TEXT,
|
||||
lastSeq INTEGER DEFAULT 0,
|
||||
unreadCount INTEGER DEFAULT 0,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`;
|
||||
|
||||
export const CREATE_CONVERSATION_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS conversation_cache (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`;
|
||||
|
||||
export const CREATE_CONVERSATION_LIST_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS conversation_list_cache (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`;
|
||||
|
||||
export const MIGRATE_CONVERSATIONS_TABLE = async (db: SQLiteDatabase): Promise<void> => {
|
||||
try {
|
||||
const tableInfo = await db.getAllAsync<{ name: string }>(`PRAGMA table_info(conversations)`);
|
||||
const columns = tableInfo.map(col => col.name);
|
||||
|
||||
if (!columns.includes('lastSeq')) {
|
||||
await db.execAsync(`ALTER TABLE conversations ADD COLUMN lastSeq INTEGER DEFAULT 0`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ConversationSchema] 迁移失败:', error);
|
||||
}
|
||||
};
|
||||
20
src/database/schema/GroupSchema.ts
Normal file
20
src/database/schema/GroupSchema.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const GROUPS_TABLE = 'groups';
|
||||
export const GROUP_MEMBERS_TABLE = 'group_members';
|
||||
|
||||
export const CREATE_GROUPS_TABLE = `CREATE TABLE IF NOT EXISTS groups (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`;
|
||||
|
||||
export const CREATE_GROUP_MEMBERS_TABLE = `CREATE TABLE IF NOT EXISTS group_members (
|
||||
groupId TEXT NOT NULL,
|
||||
userId TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
PRIMARY KEY (groupId, userId)
|
||||
)`;
|
||||
|
||||
export const CREATE_GROUP_INDEXES = [
|
||||
`CREATE INDEX IF NOT EXISTS idx_group_members_groupId ON group_members(groupId)`,
|
||||
];
|
||||
41
src/database/schema/MessageSchema.ts
Normal file
41
src/database/schema/MessageSchema.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { SQLiteDatabase } from 'expo-sqlite';
|
||||
|
||||
export const MESSAGES_TABLE = 'messages';
|
||||
|
||||
export const CREATE_MESSAGES_TABLE = `CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
conversationId TEXT NOT NULL,
|
||||
senderId TEXT NOT NULL,
|
||||
content TEXT,
|
||||
type TEXT DEFAULT 'text',
|
||||
isRead INTEGER DEFAULT 0,
|
||||
createdAt TEXT NOT NULL,
|
||||
seq INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'normal',
|
||||
segments TEXT
|
||||
)`;
|
||||
|
||||
export const CREATE_MESSAGES_INDEXES = [
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_conversationId ON messages(conversationId)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_conversation_seq ON messages(conversationId, seq)`,
|
||||
];
|
||||
|
||||
export const MIGRATE_MESSAGES_TABLE = async (db: SQLiteDatabase): Promise<void> => {
|
||||
try {
|
||||
const tableInfo = await db.getAllAsync<{ name: string }>(`PRAGMA table_info(messages)`);
|
||||
const columns = tableInfo.map(col => col.name);
|
||||
|
||||
if (!columns.includes('seq')) {
|
||||
await db.execAsync(`ALTER TABLE messages ADD COLUMN seq INTEGER DEFAULT 0`);
|
||||
}
|
||||
if (!columns.includes('status')) {
|
||||
await db.execAsync(`ALTER TABLE messages ADD COLUMN status TEXT DEFAULT 'normal'`);
|
||||
}
|
||||
if (!columns.includes('segments')) {
|
||||
await db.execAsync(`ALTER TABLE messages ADD COLUMN segments TEXT`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MessageSchema] 迁移失败:', error);
|
||||
}
|
||||
};
|
||||
57
src/database/schema/MigrationManager.ts
Normal file
57
src/database/schema/MigrationManager.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { SQLiteDatabase } from 'expo-sqlite';
|
||||
import {
|
||||
CREATE_MESSAGES_TABLE,
|
||||
CREATE_MESSAGES_INDEXES,
|
||||
MIGRATE_MESSAGES_TABLE,
|
||||
} from './MessageSchema';
|
||||
import {
|
||||
CREATE_CONVERSATIONS_TABLE,
|
||||
CREATE_CONVERSATION_CACHE_TABLE,
|
||||
CREATE_CONVERSATION_LIST_CACHE_TABLE,
|
||||
MIGRATE_CONVERSATIONS_TABLE,
|
||||
} from './ConversationSchema';
|
||||
import {
|
||||
CREATE_USERS_TABLE,
|
||||
CREATE_CURRENT_USER_CACHE_TABLE,
|
||||
} from './UserSchema';
|
||||
import {
|
||||
CREATE_GROUPS_TABLE,
|
||||
CREATE_GROUP_MEMBERS_TABLE,
|
||||
CREATE_GROUP_INDEXES,
|
||||
} from './GroupSchema';
|
||||
import {
|
||||
CREATE_POSTS_CACHE_TABLE,
|
||||
} from './PostSchema';
|
||||
|
||||
export async function runMigrations(db: SQLiteDatabase): Promise<void> {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
await db.execAsync(`PRAGMA locking_mode = EXCLUSIVE;`);
|
||||
await db.execAsync(`PRAGMA journal_mode = MEMORY;`);
|
||||
await db.execAsync(`PRAGMA temp_store = MEMORY;`);
|
||||
console.log('[MigrationManager] Web: 已设置 EXCLUSIVE locking + MEMORY journal/temp 模式');
|
||||
} catch (e) {
|
||||
console.warn('[MigrationManager] 设置 PRAGMA 失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
await db.execAsync(CREATE_MESSAGES_TABLE);
|
||||
await db.execAsync(CREATE_CONVERSATIONS_TABLE);
|
||||
await db.execAsync(CREATE_CONVERSATION_CACHE_TABLE);
|
||||
await db.execAsync(CREATE_CONVERSATION_LIST_CACHE_TABLE);
|
||||
await db.execAsync(CREATE_USERS_TABLE);
|
||||
await db.execAsync(CREATE_CURRENT_USER_CACHE_TABLE);
|
||||
await db.execAsync(CREATE_GROUPS_TABLE);
|
||||
await db.execAsync(CREATE_GROUP_MEMBERS_TABLE);
|
||||
await db.execAsync(CREATE_POSTS_CACHE_TABLE);
|
||||
|
||||
for (const indexSql of CREATE_MESSAGES_INDEXES) {
|
||||
await db.execAsync(indexSql);
|
||||
}
|
||||
for (const indexSql of CREATE_GROUP_INDEXES) {
|
||||
await db.execAsync(indexSql);
|
||||
}
|
||||
|
||||
await MIGRATE_MESSAGES_TABLE(db);
|
||||
await MIGRATE_CONVERSATIONS_TABLE(db);
|
||||
}
|
||||
9
src/database/schema/PostSchema.ts
Normal file
9
src/database/schema/PostSchema.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const POSTS_CACHE_TABLE = 'posts_cache';
|
||||
|
||||
export const CREATE_POSTS_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS posts_cache (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`;
|
||||
|
||||
export const CREATE_POSTS_INDEXES: string[] = [];
|
||||
16
src/database/schema/UserSchema.ts
Normal file
16
src/database/schema/UserSchema.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const USERS_TABLE = 'users';
|
||||
export const CURRENT_USER_CACHE_TABLE = 'current_user_cache';
|
||||
|
||||
export const CREATE_USERS_TABLE = `CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`;
|
||||
|
||||
export const CREATE_CURRENT_USER_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS current_user_cache (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`;
|
||||
|
||||
export const CREATE_USER_INDEXES: string[] = [];
|
||||
6
src/database/schema/index.ts
Normal file
6
src/database/schema/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './MessageSchema';
|
||||
export * from './ConversationSchema';
|
||||
export * from './UserSchema';
|
||||
export * from './GroupSchema';
|
||||
export * from './PostSchema';
|
||||
export { runMigrations } from './MigrationManager';
|
||||
14
src/database/types/CachedMessage.ts
Normal file
14
src/database/types/CachedMessage.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface CachedMessage {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
content?: string;
|
||||
type?: string;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
seq: number;
|
||||
status: string;
|
||||
segments?: any;
|
||||
}
|
||||
|
||||
export { CachedMessage as default };
|
||||
1
src/database/types/index.ts
Normal file
1
src/database/types/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CachedMessage } from './CachedMessage';
|
||||
Reference in New Issue
Block a user