feat(messaging): implement Outbox persistence with three-tier sync strategy
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

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
This commit is contained in:
lafay
2026-06-28 23:19:52 +08:00
parent d8386b5f76
commit f9b0999112
23 changed files with 1094 additions and 85 deletions

View File

@@ -0,0 +1,117 @@
/**
* syncByVersion 游标同步单测(批次二③)
*
* 锁定基于版本号的精确增量恢复不变量:
* 1. 成功同步并推进持久化游标
* 2. full_sync=true 时退化(返回 false
* 3. 异常时退化(返回 false
* 4. 无 version<=0时直接退化
*
* 对标 Matrix resume 游标:断线恢复从全量轮询升级为精确增量。
*/
import { useMessageStore } from '../store';
import { MessageSyncService } from '../services/MessageSyncService';
import { messageService } from '@/services/message';
import { messageRepository } from '@/database';
function createSyncService(): MessageSyncService {
return new MessageSyncService(
() => 'me',
{ markAsRead: async () => {}, markAllAsRead: async () => {} } as any,
{ getSenderInfo: async () => null, enrichMessagesWithSenderInfo: () => {} } as any,
undefined
);
}
describe('syncByVersion 游标同步', () => {
beforeEach(() => {
useMessageStore.getState().reset();
(messageRepository as any).__reset();
(messageService as any).__reset();
});
it('无变更时返回成功且推进游标', async () => {
const serviceMock = messageService as any;
serviceMock.__setSyncByVersionHandler((version: number) => ({
current_version: version + 10,
changes: [],
full_sync: false,
has_more: false,
}));
const sync = createSyncService();
const ok = await sync.syncByVersion(100);
expect(ok).toBe(true);
// 游标推进到 current_version
expect(useMessageStore.getState().syncVersion).toBe(110);
});
it('full_sync=true 时退化为 false', async () => {
const serviceMock = messageService as any;
serviceMock.__setSyncByVersionHandler(() => ({
current_version: 0,
changes: [],
full_sync: true,
has_more: false,
}));
const sync = createSyncService();
const ok = await sync.syncByVersion(100);
expect(ok).toBe(false);
});
it('API 抛异常时退化为 false', async () => {
const serviceMock = messageService as any;
serviceMock.__setSyncByVersionHandler(() => {
throw new Error('网络故障');
});
const sync = createSyncService();
const ok = await sync.syncByVersion(100);
expect(ok).toBe(false);
});
it('version <= 0 时直接退化', async () => {
const sync = createSyncService();
const ok1 = await sync.syncByVersion(0);
const ok2 = await sync.syncByVersion(-1);
expect(ok1).toBe(false);
expect(ok2).toBe(false);
});
it('有会话变更时按需同步会话详情', async () => {
const serviceMock = messageService as any;
// 先在 store 放一个会话,让 fetchConversationDetail 有上下文
useMessageStore.getState().setConversationsWithUnread(
new Map([['c1', { id: 'c1', last_seq: 5, unread_count: 0 } as any]]),
0, 0
);
let detailCalled = false;
serviceMock.__setSyncByVersionHandler((version: number) => ({
current_version: version + 5,
changes: [
{ conversation_id: 'c1', change_type: 'new_message', max_seq: 10, version: version + 1 },
],
full_sync: false,
has_more: false,
}));
// fetchConversationDetail 内部调 getConversationById
serviceMock.getConversationById = async () => {
detailCalled = true;
return { id: 'c1', last_seq: 10 } as any;
};
serviceMock.getMessages = async () => ({ messages: [], total: 0, page: 1, page_size: 20 });
const sync = createSyncService();
const ok = await sync.syncByVersion(100);
expect(ok).toBe(true);
expect(detailCalled).toBe(true);
expect(useMessageStore.getState().syncVersion).toBe(105);
});
});