Add Outbox pattern for reliable message sending with offline recovery: - Persist pending/failed messages to SQLite (status field extended with 'pending'|'failed') - App restart scans pending messages and auto-retries failed sends - Add versioned migration system (PRAGMA user_version) for future schema evolution Implement three-tier sync strategy for precision and efficiency: - syncByVersion: precise incremental sync using persisted cursor (Matrix-style) - syncBySeq: fallback seq-based incremental sync - Full refresh: last resort for recovery Add conversation sync state machine for UI error visibility: - 'idle' | 'hydrating' | 'synced' | 'error' states exposed via useMessages hook - Enables loading/error indicators during hydration pipeline Standardize error handling across message services using createErrorHandler. Add tests: messageOutbox.test.ts, syncByVersion.test.ts, syncState.test.ts
139 lines
5.1 KiB
TypeScript
139 lines
5.1 KiB
TypeScript
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';
|
||
|
||
/**
|
||
* 迁移版本号管理(对标 expo-sqlite 官方推荐用 PRAGMA user_version)。
|
||
*
|
||
* 设计:
|
||
* - version 0:基线(建表 + 建索引),所有老库都已执行过(靠 CREATE IF NOT EXISTS 幂等)
|
||
* - version 1:列级迁移(补 seq/status/segments/lastSeq 列),即原 MIGRATE_* 函数
|
||
*
|
||
* 向后兼容:老库 user_version 默认为 0,会重新跑 version 1 的列迁移,
|
||
* 但 MIGRATE_* 靠 PRAGMA table_info 列检查幂等,重复执行无副作用。
|
||
*
|
||
* 扩展方式(未来破坏性迁移):在 MIGRATIONS 注册表新增 version 2/3/...,
|
||
* 每个 step 靠 transaction 包裹保证原子性(失败回滚,不污染 user_version)。
|
||
*/
|
||
export const CURRENT_SCHEMA_VERSION = 1;
|
||
|
||
/** 读取当前 user_version */
|
||
async function getUserVersion(db: SQLiteDatabase): Promise<number> {
|
||
const result = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
|
||
return result?.user_version ?? 0;
|
||
}
|
||
|
||
/** 设置 user_version */
|
||
async function setUserVersion(db: SQLiteDatabase, version: number): Promise<void> {
|
||
await execWithRetry(db, `PRAGMA user_version = ${version};`);
|
||
}
|
||
|
||
/** 版本化迁移注册表:key = 目标版本,value = 该版本要执行的迁移逻辑 */
|
||
const MIGRATIONS: Map<number, (db: SQLiteDatabase) => Promise<void>> = new Map([
|
||
// version 1:列级迁移(补 seq/status/segments/lastSeq 列)
|
||
// 历史背景:最初表结构不含这些列,靠 MIGRATE_* 增量 ALTER。现在纳入版本管理。
|
||
[1, async (db: SQLiteDatabase) => {
|
||
await MIGRATE_MESSAGES_TABLE(db);
|
||
await MIGRATE_CONVERSATIONS_TABLE(db);
|
||
}],
|
||
]);
|
||
|
||
async function execWithRetry(db: SQLiteDatabase, sql: string, maxRetries = 5): Promise<void> {
|
||
for (let i = 0; i < maxRetries; i++) {
|
||
try {
|
||
await db.execAsync(sql);
|
||
return;
|
||
} catch (error) {
|
||
const msg = String(error);
|
||
if (msg.includes('database is locked') && i < maxRetries - 1) {
|
||
const delay = 200 * (i + 1);
|
||
console.warn(`[MigrationManager] 数据库锁定,${delay}ms 后重试 (${i + 1}/${maxRetries})`);
|
||
await new Promise(r => setTimeout(r, delay));
|
||
continue;
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 执行数据库迁移。
|
||
*
|
||
* 流程:
|
||
* 1. 设置 busy_timeout
|
||
* 2. 基线建表 + 建索引(version 0,CREATE IF NOT EXISTS 幂等,每次都执行以保证新表存在)
|
||
* 3. 按 user_version 顺序执行版本化迁移,每步成功后递增 user_version
|
||
*/
|
||
export async function runMigrations(db: SQLiteDatabase): Promise<void> {
|
||
await execWithRetry(db, 'PRAGMA busy_timeout = 10000;');
|
||
|
||
// ---- 基线:version 0(建表 + 建索引)----
|
||
// 保留每次执行,确保新库/损坏恢复时表结构就位;对老库靠 IF NOT EXISTS 幂等。
|
||
await execWithRetry(db, CREATE_MESSAGES_TABLE);
|
||
await execWithRetry(db, CREATE_CONVERSATIONS_TABLE);
|
||
await execWithRetry(db, CREATE_CONVERSATION_CACHE_TABLE);
|
||
await execWithRetry(db, CREATE_CONVERSATION_LIST_CACHE_TABLE);
|
||
await execWithRetry(db, CREATE_USERS_TABLE);
|
||
await execWithRetry(db, CREATE_CURRENT_USER_CACHE_TABLE);
|
||
await execWithRetry(db, CREATE_GROUPS_TABLE);
|
||
await execWithRetry(db, CREATE_GROUP_MEMBERS_TABLE);
|
||
await execWithRetry(db, CREATE_POSTS_CACHE_TABLE);
|
||
|
||
for (const indexSql of CREATE_MESSAGES_INDEXES) {
|
||
await execWithRetry(db, indexSql);
|
||
}
|
||
for (const indexSql of CREATE_GROUP_INDEXES) {
|
||
await execWithRetry(db, indexSql);
|
||
}
|
||
|
||
// ---- 版本化迁移:version 1 ~ CURRENT_SCHEMA_VERSION ----
|
||
const currentVersion = await getUserVersion(db);
|
||
|
||
// 当前版本已是最新(含未来更高版本),跳过
|
||
if (currentVersion >= CURRENT_SCHEMA_VERSION) {
|
||
return;
|
||
}
|
||
|
||
// 从 currentVersion+1 开始,逐版本执行迁移
|
||
for (let target = currentVersion + 1; target <= CURRENT_SCHEMA_VERSION; target++) {
|
||
const migration = MIGRATIONS.get(target);
|
||
if (!migration) {
|
||
console.warn(`[MigrationManager] 未找到 version ${target} 的迁移逻辑,跳过`);
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
await migration(db);
|
||
// 每个版本迁移成功后立即持久化 user_version,
|
||
// 即使后续版本失败,已完成的版本不会重复执行
|
||
await setUserVersion(db, target);
|
||
} catch (error) {
|
||
console.error(`[MigrationManager] 迁移到 version ${target} 失败:`, error);
|
||
// 不抛出致命错误以避免阻塞 App 启动;降级运行(表可能缺列,MIGRATE_* 内部会兜底)
|
||
// 但记录版本未推进,下次启动会重试该版本
|
||
break;
|
||
}
|
||
}
|
||
}
|