Files
frontend/src/database/schema/MigrationManager.ts
lafay f9b0999112
Some checks failed
Frontend CI / build-android-apk (push) Waiting to run
Frontend CI / ota (android) (push) Successful in 1m52s
Frontend CI / ota (ios) (push) Successful in 2m36s
Frontend CI / build-and-push-web (push) Failing after 1m15s
feat(messaging): implement Outbox persistence with three-tier sync strategy
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
2026-06-28 23:19:52 +08:00

139 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 0CREATE 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;
}
}
}