Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,7 @@ async def get_recent_analyses(
async def sync_posts(
since: str = Query(..., description="ISO timestamp — return posts updated after this time"),
limit: int = Query(default=500, ge=1, le=1000),
offset: int = Query(default=0, ge=0, description="Number of matching rows to skip"),
token: str = Depends(verify_token),
):
"""
Expand All @@ -769,12 +770,17 @@ async def sync_posts(
"""
try:
db = get_db()
results = db.get_posts_since(since, limit=limit)
page = db.get_posts_since(since, limit=limit + 1, offset=offset)
has_more = len(page) > limit
results = page[:limit]

return {
"success": True,
"count": len(results),
"since": since,
"offset": offset,
"next_offset": offset + len(results) if has_more else None,
"has_more": has_more,
"data": results
}

Expand Down
6 changes: 3 additions & 3 deletions backend/core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ def get_recent_light(self, limit=50, offset=0):
print(f"[WARNING] Error retrieving recent (light): {e}")
return []

def get_posts_since(self, updated_after: str, limit=1000):
def get_posts_since(self, updated_after: str, limit=1000, offset=0):
"""Return posts updated after the given ISO timestamp (delta sync).
Includes soft-deleted posts so the app knows to hide them."""
if not self.is_connected():
Expand All @@ -304,8 +304,8 @@ def get_posts_since(self, updated_after: str, limit=1000):
cur.execute(
f"SELECT {self.LIGHT_COLUMNS} FROM analyses "
"WHERE updated_at > ? "
"ORDER BY updated_at ASC LIMIT ?",
(updated_after, limit)
"ORDER BY updated_at ASC, shortcode ASC LIMIT ? OFFSET ?",
(updated_after, limit, offset)
)
return [self._row_to_dict(r) for r in cur.fetchall()]
except Exception as e:
Expand Down
52 changes: 52 additions & 0 deletions backend/tests/test_sync_pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Regression tests for deterministic delta-sync pagination."""

import sys
import tempfile
import unittest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

import core.database as database_module # noqa: E402


class SyncPaginationTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.original_db_path = database_module.DB_PATH
database_module.DB_PATH = Path(self.temp_dir.name) / "superbrain.db"
self.db = database_module.Database()

rows = [
("charlie", "2026-01-02T12:00:00"),
("alpha", "2026-01-02T12:00:00"),
("echo", "2026-01-03T12:00:00"),
("bravo", "2026-01-02T12:00:00"),
("delta", "2026-01-03T12:00:00"),
]
self.db._conn.executemany(
"INSERT INTO analyses (shortcode, updated_at, tags) VALUES (?, ?, '[]')",
rows,
)
self.db._conn.commit()

def tearDown(self):
self.db._conn.close()
database_module.DB_PATH = self.original_db_path
self.temp_dir.cleanup()

def test_pages_use_stable_updated_at_and_shortcode_order(self):
since = "2026-01-01T00:00:00"

first = self.db.get_posts_since(since, limit=2, offset=0)
second = self.db.get_posts_since(since, limit=2, offset=2)
third = self.db.get_posts_since(since, limit=2, offset=4)

self.assertEqual([row["shortcode"] for row in first], ["alpha", "bravo"])
self.assertEqual([row["shortcode"] for row in second], ["charlie", "delta"])
self.assertEqual([row["shortcode"] for row in third], ["echo"])


if __name__ == "__main__":
unittest.main()
20 changes: 14 additions & 6 deletions superbrain-app/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,18 +305,27 @@ class ApiService {
/**
* Delta sync — returns posts modified after the given ISO timestamp.
*/
async syncPosts(since: string): Promise<Post[]> {
async syncPosts(
since: string,
limit: number = 200,
offset: number = 0,
): Promise<{ data: Post[]; hasMore: boolean }> {
try {
const headers = await this.getHeaders();
const baseUrl = await this.getBaseUrl();
const response = await axios.get<{ success: boolean; data: Post[] }>(
`${baseUrl}/sync?since=${encodeURIComponent(since)}&limit=1000`,
const response = await axios.get<{
success: boolean; data: Post[]; has_more: boolean
}>(
`${baseUrl}/sync?since=${encodeURIComponent(since)}&limit=${limit}&offset=${offset}`,
{ headers, timeout: 30000 }
);
return (response.data.data || []).map(normalizePost);
return {
data: (response.data.data || []).map(normalizePost),
hasMore: response.data.has_more,
};
} catch (error: any) {
console.error('Error syncing posts:', error.response?.data?.detail || error.message);
return [];
throw error;
}
}

Expand Down Expand Up @@ -756,4 +765,3 @@ class ApiService {
}

export default new ApiService();

15 changes: 13 additions & 2 deletions superbrain-app/src/services/syncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,19 @@ async function deltaSync(): Promise<number> {
return fullSync();
}

// Fetch changed posts
const changedPosts = await apiService.syncPosts(since);
// Fetch every changed row before advancing the cursor. A delta can exceed
// the server's page size after many changes occur between app launches.
const changedPosts: Post[] = [];
let offset = 0;
while (true) {
const page = await apiService.syncPosts(since, BATCH_SIZE, offset);
changedPosts.push(...page.data);
offset += page.data.length;
if (!page.hasMore) break;
if (page.data.length === 0) {
throw new Error('Delta sync returned an empty page before completion');
}
}

// Filter out hidden (soft-deleted) posts for upsert; delete them locally instead
const toUpsert: Post[] = [];
Expand Down