fix(mobile): restore full list and align search
This commit is contained in:
1 parent
f782c91dc0
commit
12a9c67933
6 files changed
+219
-70
No files matched your search
+26
-8
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user