From 12a9c6793391a85ee6ddc5af6f0913ce2f2d92f4 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Sun, 19 Jul 2026 05:06:39 +0800 Subject: [PATCH] fix(mobile): restore full list and align search --- apps/api/src/index.ts | 34 +++- apps/mobile/src/lib/local-mirror.test.ts | 21 +++ apps/mobile/src/lib/local-mirror.ts | 30 ++- apps/mobile/src/lib/mobile-sync-protocol.ts | 5 + apps/mobile/src/screens/WorkspaceScreen.tsx | 196 ++++++++++++++------ packages/client/src/index.ts | 3 + 6 files changed, 219 insertions(+), 70 deletions(-) create mode 100644 apps/mobile/src/lib/local-mirror.test.ts create mode 100644 apps/mobile/src/lib/mobile-sync-protocol.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7e1743e3..3cf0e897 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -827,7 +827,13 @@ app.get("/api/v1/sync/bootstrap", async (c) => { LIMIT ?` ).bind(workspaceId, afterId, limit + 1).all(), c.env.DB.prepare(`SELECT COUNT(*) AS count FROM memos WHERE workspace_id = ?`).bind(workspaceId).first<{ count: number }>(), - c.env.DB.prepare(`SELECT COALESCE(MAX(id), 0) AS cursor FROM mobile_sync_changes WHERE workspace_id = ?`).bind(workspaceId).first<{ cursor: number }>(), + c.env.DB.prepare( + `SELECT w.created_at AS sync_identity, COALESCE(MAX(c.id), 0) AS cursor + FROM workspaces w + LEFT JOIN mobile_sync_changes c ON c.workspace_id = w.id + WHERE w.id = ? + GROUP BY w.created_at` + ).bind(workspaceId).first<{ cursor: number; sync_identity: string }>(), ]); const page = memoRows.results.slice(0, limit); const totalCount = totalRow?.count ?? page.length; @@ -837,6 +843,7 @@ app.get("/api/v1/sync/bootstrap", async (c) => { notebooks: notebookRows.results.map(mapNotebook), memos: page.map(mapMemoDetail), snapshotCursor: cursorRow?.cursor ?? 0, + syncIdentity: cursorRow?.sync_identity, totalCount, nextAfterId, }); @@ -852,13 +859,22 @@ app.get("/api/v1/sync/changes", async (c) => { const workspaceId = getWorkspaceId(c); const cursor = clampNumber(Number(c.req.query("cursor") ?? 0), 0, Number.MAX_SAFE_INTEGER); const limit = clampNumber(Number(c.req.query("limit") ?? 100), 1, 200); - const rows = await c.env.DB.prepare( - `SELECT id, entity_type, entity_id, operation - FROM mobile_sync_changes - WHERE workspace_id = ? AND id > ? - ORDER BY id ASC - LIMIT ?` - ).bind(workspaceId, cursor, limit + 1).all(); + const [rows, cursorRow] = await Promise.all([ + c.env.DB.prepare( + `SELECT id, entity_type, entity_id, operation + FROM mobile_sync_changes + WHERE workspace_id = ? AND id > ? + ORDER BY id ASC + LIMIT ?` + ).bind(workspaceId, cursor, limit + 1).all(), + c.env.DB.prepare( + `SELECT w.created_at AS sync_identity, COALESCE(MAX(c.id), 0) AS cursor + FROM workspaces w + LEFT JOIN mobile_sync_changes c ON c.workspace_id = w.id + WHERE w.id = ? + GROUP BY w.created_at` + ).bind(workspaceId).first<{ cursor: number; sync_identity: string }>(), + ]); const page = rows.results.slice(0, limit); const memoIds = Array.from(new Set(page.filter((change) => change.entity_type === "memo" && change.operation === "upsert").map((change) => change.entity_id))); const notebookIds = Array.from(new Set(page.filter((change) => change.entity_type === "notebook" && change.operation === "upsert").map((change) => change.entity_id))); @@ -903,6 +919,8 @@ app.get("/api/v1/sync/changes", async (c) => { changes, cursor: page.at(-1)?.id ?? cursor, hasMore: rows.results.length > limit, + serverCursor: cursorRow?.cursor ?? 0, + syncIdentity: cursorRow?.sync_identity, }); }); diff --git a/apps/mobile/src/lib/local-mirror.test.ts b/apps/mobile/src/lib/local-mirror.test.ts new file mode 100644 index 00000000..8add4616 --- /dev/null +++ b/apps/mobile/src/lib/local-mirror.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test"; +import { hasMobileSyncCursorRewound, hasMobileSyncIdentityChanged } from "./mobile-sync-protocol"; + +test("rebuilds the mobile mirror when the server change cursor rewinds", () => { + expect(hasMobileSyncCursorRewound(42, 7)).toBe(true); + expect(hasMobileSyncCursorRewound(42, 42)).toBe(false); + expect(hasMobileSyncCursorRewound(42, 64)).toBe(false); +}); + +test("keeps compatibility with servers that do not report their current cursor", () => { + expect(hasMobileSyncCursorRewound(42)).toBe(false); +}); + +test("rebuilds the mobile mirror when the server data identity changes", () => { + expect(hasMobileSyncIdentityChanged("workspace-created-at-a", "workspace-created-at-b")).toBe(true); + expect(hasMobileSyncIdentityChanged("workspace-created-at-a", "workspace-created-at-a")).toBe(false); +}); + +test("keeps compatibility with servers that do not report a data identity", () => { + expect(hasMobileSyncIdentityChanged("legacy")).toBe(false); +}); diff --git a/apps/mobile/src/lib/local-mirror.ts b/apps/mobile/src/lib/local-mirror.ts index 1f01106b..533d19a5 100644 --- a/apps/mobile/src/lib/local-mirror.ts +++ b/apps/mobile/src/lib/local-mirror.ts @@ -1,6 +1,7 @@ import type { createEdgeEverClient, ListMemosResponse, MemoFilterMode, MemoSortMode } from "@edgeever/client"; import type { MemoDetail, MemoSummary, Notebook } from "@edgeever/shared"; import * as SQLite from "expo-sqlite"; +import { hasMobileSyncCursorRewound, hasMobileSyncIdentityChanged } from "./mobile-sync-protocol"; const DATABASE_NAME = "edgeever-mobile.db"; const BOOTSTRAP_PAGE_SIZE = 200; @@ -178,20 +179,29 @@ const performMobileLocalMirrorSync = async ( `SELECT value FROM mobile_sync_meta WHERE scope = ? AND key = 'cursor'`, scope ); + const identityRow = await db.getFirstAsync( + `SELECT value FROM mobile_sync_meta WHERE scope = ? AND key = 'identity'`, + scope + ); let cursor = cursorRow ? Number(cursorRow.value) : null; + let syncIdentity = identityRow?.value ?? null; - if (cursor === null || !Number.isFinite(cursor)) { + if (cursor === null || !Number.isFinite(cursor) || syncIdentity === null) { let afterId: string | null = null; let snapshotCursor = 0; + let snapshotIdentity = "legacy"; await db.withExclusiveTransactionAsync(async (tx) => { await tx.runAsync(`DELETE FROM mobile_notebooks WHERE scope = ?`, scope); - await tx.runAsync(`DELETE FROM mobile_memos WHERE scope = ?`, scope); + // Offline-created memos are owned by the local sync queue and must + // survive a remote snapshot rebuild. + await tx.runAsync(`DELETE FROM mobile_memos WHERE scope = ? AND id NOT LIKE 'local:%'`, scope); }); while (true) { const page = await client.getMobileSyncBootstrapPage(afterId, BOOTSTRAP_PAGE_SIZE); if (afterId === null) { snapshotCursor = page.snapshotCursor; + snapshotIdentity = page.syncIdentity || "legacy"; } await db.withExclusiveTransactionAsync(async (tx) => { for (const notebook of page.notebooks) { @@ -207,11 +217,20 @@ const performMobileLocalMirrorSync = async ( afterId = page.nextAfterId; } cursor = snapshotCursor; + syncIdentity = snapshotIdentity; await setCursor(db, scope, cursor); + await setSyncIdentity(db, scope, syncIdentity); } while (true) { const page = await client.getMobileSyncChanges(cursor, CHANGE_PAGE_SIZE); + if (hasMobileSyncCursorRewound(cursor, page.serverCursor) || hasMobileSyncIdentityChanged(syncIdentity, page.syncIdentity)) { + // A restored/replaced server database can legitimately restart its + // change sequence. Rebuild the mirror instead of treating the stale + // local cursor as proof that no remote notes exist. + await db.runAsync(`DELETE FROM mobile_sync_meta WHERE scope = ? AND key IN ('cursor', 'identity')`, scope); + return performMobileLocalMirrorSync(client, scope); + } await db.withExclusiveTransactionAsync(async (tx) => { for (const change of page.changes) { if (change.entityType === "memo") { @@ -300,6 +319,13 @@ const setCursor = (db: SQLite.SQLiteDatabase, scope: string, cursor: number) => String(cursor) ); +const setSyncIdentity = (db: SQLite.SQLiteDatabase, scope: string, identity: string) => + db.runAsync( + `INSERT OR REPLACE INTO mobile_sync_meta (scope, key, value) VALUES (?, 'identity', ?)`, + scope, + identity + ); + const toMemoSummary = (memo: MemoDetail): MemoSummary => ({ id: memo.id, notebookId: memo.notebookId, diff --git a/apps/mobile/src/lib/mobile-sync-protocol.ts b/apps/mobile/src/lib/mobile-sync-protocol.ts new file mode 100644 index 00000000..d4876858 --- /dev/null +++ b/apps/mobile/src/lib/mobile-sync-protocol.ts @@ -0,0 +1,5 @@ +export const hasMobileSyncCursorRewound = (localCursor: number, serverCursor?: number) => + typeof serverCursor === "number" && Number.isFinite(serverCursor) && serverCursor < localCursor; + +export const hasMobileSyncIdentityChanged = (localIdentity: string, serverIdentity?: string) => + typeof serverIdentity === "string" && serverIdentity.length > 0 && serverIdentity !== localIdentity; diff --git a/apps/mobile/src/screens/WorkspaceScreen.tsx b/apps/mobile/src/screens/WorkspaceScreen.tsx index ed0cbc49..c8e31aa0 100644 --- a/apps/mobile/src/screens/WorkspaceScreen.tsx +++ b/apps/mobile/src/screens/WorkspaceScreen.tsx @@ -1149,11 +1149,12 @@ export const WorkspaceScreen = () => { {activeView === "search" ? ( { setSearchText(""); setActiveView("notes"); @@ -1161,14 +1162,17 @@ export const WorkspaceScreen = () => { onMemoPress={handleSearchMemoPress} onRefresh={refresh} onLoadMore={() => { - if (searchQuery.hasNextPage && !searchQuery.isFetchingNextPage) { - void searchQuery.fetchNextPage(); + const query = searchText.trim() ? searchQuery : memosQuery; + if (query.hasNextPage && !query.isFetchingNextPage) { + void query.fetchNextPage(); } }} - results={searchResults} + results={searchText.trim() ? searchResults : memos} searchText={searchText} setSearchText={setSearchText} - totalCount={searchQuery.data?.pages[0]?.totalCount ?? searchResults.length} + totalCount={searchText.trim() + ? searchQuery.data?.pages[0]?.totalCount ?? searchResults.length + : memosQuery.data?.pages[0]?.totalCount ?? memos.length} /> ) : null} @@ -1228,6 +1232,7 @@ export const WorkspaceScreen = () => { void; onMemoPress: (memo: MemoSummary) => void; onLoadMore: () => void; @@ -1909,61 +1916,57 @@ const SearchView = ({ totalCount: number; }) => ( - - - 搜索 - - - + + + + + + + + + {searchText ? ( + setSearchText("")} style={styles.mobileSearchClearButton}> + + + ) : null} + + + 取消 + - - - - {searchText ? ( - setSearchText("")}> - - - ) : null} - - {searchText.trim() ? {totalCount} 条结果 : null} + {searchText.trim() ? 正在搜索 · {totalCount} 条结果 : null} - {searchText.trim() ? ( - { - const memo = results.find((item) => item.id === memoId); - if (memo) { - onMemoPress(memo); - } - }} - onLoadMore={onLoadMore} - onRefresh={onRefresh} - onRetry={onRefresh} - /> - ) : ( - - - 输入关键词开始搜索 - 搜索本机同步缓存,结果会即时显示 - - )} + { + const memo = results.find((item) => item.id === memoId); + if (memo) { + onMemoPress(memo); + } + }} + onLoadMore={onLoadMore} + onRefresh={onRefresh} + onRetry={onRefresh} + /> ); @@ -5302,6 +5305,7 @@ const MemoList = ({ onEndReachedThreshold={0.35} removeClippedSubviews={Platform.OS === "android"} refreshControl={} + style={styles.memoList} renderItem={({ item }) => (