fix(mobile): restore full list and align search

This commit is contained in:
tianma-if committed 2026-07-19 05:06:39 +08:00
1 parent f782c91dc0
commit 12a9c67933
6 files changed
+219 -70

No files matched your search

+26 -8
View File
@@ -827,7 +827,13 @@ app.get("/api/v1/sync/bootstrap", async (c) => {
LIMIT ?`
).bind(workspaceId, afterId, limit + 1).all<MemoDetailRow>(),
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<MobileSyncChangeRow>();
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<MobileSyncChangeRow>(),
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,
});
});
+21
View File
@@ -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);
});
+28 -2
View File
@@ -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<CursorRow>(
`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,
@@ -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;
+136 -60
View File
@@ -1149,11 +1149,12 @@ export const WorkspaceScreen = () => {
{activeView === "search" ? (
<SearchView
error={searchQuery.error}
isError={searchQuery.isError}
isLoading={searchQuery.isLoading}
isLoadingMore={searchQuery.isFetchingNextPage}
error={searchText.trim() ? searchQuery.error : memosQuery.error}
isError={searchText.trim() ? searchQuery.isError : memosQuery.isError}
isLoading={searchText.trim() ? searchQuery.isLoading : memosQuery.isLoading}
isLoadingMore={searchText.trim() ? searchQuery.isFetchingNextPage : memosQuery.isFetchingNextPage}
isRefreshing={isRefreshing}
listDensity={memoListDensity}
onClose={() => {
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 = () => {
<EditorRuntimePrewarm
dom={{
bounces: false,
containerStyle: styles.editorRuntimePrewarm,
scrollEnabled: false,
style: styles.editorRuntimePrewarm,
}}
@@ -1885,6 +1890,7 @@ const SearchView = ({
isLoading,
isLoadingMore,
isRefreshing,
listDensity,
onClose,
onMemoPress,
onLoadMore,
@@ -1899,6 +1905,7 @@ const SearchView = ({
isLoading: boolean;
isLoadingMore: boolean;
isRefreshing: boolean;
listDensity: MobileMemoListDensity;
onClose: () => void;
onMemoPress: (memo: MemoSummary) => void;
onLoadMore: () => void;
@@ -1909,61 +1916,57 @@ const SearchView = ({
totalCount: number;
}) => (
<View style={styles.viewBody}>
<View style={styles.searchHeader}>
<View style={styles.searchTitleRow}>
<Text style={styles.sectionTitle}>搜索</Text>
<IconButton accessibilityLabel="关闭搜索" onPress={onClose}>
<X color="#0f172a" size={20} />
</IconButton>
<View style={styles.mobileSearchActiveHeader}>
<View style={styles.mobileSearchActiveRow}>
<Pressable accessibilityLabel="退出搜索" accessibilityRole="button" onPress={onClose} style={styles.mobileSearchBackButton}>
<ChevronLeft color="#64748b" size={20} />
</Pressable>
<View style={[styles.mobileSearchActiveBox, Boolean(searchText.trim()) && styles.mobileSearchActiveBoxQuery]}>
<Search color={searchText.trim() ? "#059669" : "#64748b"} size={17} />
<TextInput
autoCapitalize="none"
autoCorrect={false}
autoFocus
onChangeText={setSearchText}
placeholder="搜索笔记"
placeholderTextColor="#94a3b8"
returnKeyType="search"
style={styles.mobileSearchActiveInput}
value={searchText}
/>
{searchText ? (
<Pressable accessibilityLabel="清空搜索" accessibilityRole="button" onPress={() => setSearchText("")} style={styles.mobileSearchClearButton}>
<X color="#64748b" size={15} />
</Pressable>
) : null}
</View>
<Pressable accessibilityLabel="取消搜索" accessibilityRole="button" onPress={onClose} style={styles.mobileSearchCancelButton}>
<Text style={styles.mobileSearchCancelText}>取消</Text>
</Pressable>
</View>
<View style={styles.searchBox}>
<Search color="#64748b" size={18} />
<TextInput
autoCapitalize="none"
autoCorrect={false}
onChangeText={setSearchText}
placeholder="搜索标题、正文或标签"
placeholderTextColor="#94a3b8"
style={styles.searchInput}
value={searchText}
/>
{searchText ? (
<Pressable accessibilityLabel="清空搜索" accessibilityRole="button" onPress={() => setSearchText("")}>
<X color="#64748b" size={18} />
</Pressable>
) : null}
</View>
{searchText.trim() ? <Text style={styles.sectionSubtitle}>{totalCount} 条结果</Text> : null}
{searchText.trim() ? <Text style={styles.mobileSearchResultCount}>正在搜索 · {totalCount} 条结果</Text> : null}
</View>
{searchText.trim() ? (
<MemoList
emptyDescription="换个关键词再试"
emptyTitle="没有找到匹配笔记"
error={error}
isError={isError}
isLoading={isLoading}
isLoadingMore={isLoadingMore}
isRefreshing={isRefreshing}
listDensity="preview"
memos={results}
onMemoPress={(memoId) => {
const memo = results.find((item) => item.id === memoId);
if (memo) {
onMemoPress(memo);
}
}}
onLoadMore={onLoadMore}
onRefresh={onRefresh}
onRetry={onRefresh}
/>
) : (
<View style={styles.centerState}>
<Search color="#94a3b8" size={32} />
<Text style={styles.emptyTitle}>输入关键词开始搜索</Text>
<Text style={styles.mutedText}>搜索本机同步缓存,结果会即时显示</Text>
</View>
)}
<MemoList
emptyDescription="换个关键词再试"
emptyTitle="没有找到匹配笔记"
error={error}
isError={isError}
isLoading={isLoading}
isLoadingMore={isLoadingMore}
isRefreshing={isRefreshing}
listDensity={listDensity}
memos={results}
onMemoPress={(memoId) => {
const memo = results.find((item) => item.id === memoId);
if (memo) {
onMemoPress(memo);
}
}}
onLoadMore={onLoadMore}
onRefresh={onRefresh}
onRetry={onRefresh}
/>
</View>
);
@@ -5302,6 +5305,7 @@ const MemoList = ({
onEndReachedThreshold={0.35}
removeClippedSubviews={Platform.OS === "android"}
refreshControl={<RefreshControl onRefresh={onRefresh} refreshing={isRefreshing} tintColor="#0f172a" />}
style={styles.memoList}
renderItem={({ item }) => (
<MemoCard
memo={item}
@@ -7033,6 +7037,75 @@ const baseWorkspaceStyles = StyleSheet.create({
color: "#64748b",
fontSize: 14,
},
mobileSearchActiveHeader: {
backgroundColor: "#f8fafc",
borderBottomColor: "#e2e8f0",
borderBottomWidth: 1,
gap: 8,
paddingBottom: 8,
paddingHorizontal: 16,
paddingTop: 8,
},
mobileSearchActiveRow: {
alignItems: "center",
flexDirection: "row",
gap: 8,
minHeight: 40,
},
mobileSearchBackButton: {
alignItems: "center",
borderRadius: 8,
height: 36,
justifyContent: "center",
width: 36,
},
mobileSearchActiveBox: {
alignItems: "center",
backgroundColor: "#ffffff",
borderColor: "#e2e8f0",
borderRadius: 18,
borderWidth: 1,
flex: 1,
flexDirection: "row",
gap: 8,
height: 36,
paddingHorizontal: 12,
},
mobileSearchActiveBoxQuery: {
backgroundColor: "#ecfdf5",
borderColor: "#10b981",
},
mobileSearchActiveInput: {
color: "#0f172a",
flex: 1,
fontSize: 14,
height: 36,
paddingVertical: 0,
},
mobileSearchClearButton: {
alignItems: "center",
height: 28,
justifyContent: "center",
width: 28,
},
mobileSearchCancelButton: {
alignItems: "center",
borderRadius: 8,
height: 36,
justifyContent: "center",
paddingHorizontal: 6,
},
mobileSearchCancelText: {
color: "#475569",
fontSize: 14,
fontWeight: "700",
},
mobileSearchResultCount: {
color: "#047857",
fontSize: 12,
fontWeight: "700",
paddingHorizontal: 44,
},
mobileFilterButton: {
alignItems: "center",
backgroundColor: "#ffffff",
@@ -7234,6 +7307,9 @@ const baseWorkspaceStyles = StyleSheet.create({
paddingHorizontal: 12,
paddingTop: 12,
},
memoList: {
flex: 1,
},
listLoadingFooter: {
marginVertical: 18,
},
+3
View File
@@ -91,6 +91,7 @@ export type MobileSyncBootstrapPage = {
notebooks: Notebook[];
memos: MemoDetail[];
snapshotCursor: number;
syncIdentity?: string;
totalCount: number;
nextAfterId: string | null;
};
@@ -108,6 +109,8 @@ export type MobileSyncChangesPage = {
changes: MobileSyncChange[];
cursor: number;
hasMore: boolean;
serverCursor?: number;
syncIdentity?: string;
};
export class ApiRequestError extends Error {