SQLite-backed caching for bookmarks and articles feed

Bookmarks load instantly from DB cache, then fetch missing notes
from relays in background. Articles feed shows cached kind-30023
events immediately on the latest tab. Both persist to SQLite for
instant load on next visit.
This commit is contained in:
Jure
2026-03-29 20:46:18 +02:00
parent d450f8fdeb
commit 5d94220e5d
4 changed files with 205 additions and 42 deletions

View File

@@ -77,7 +77,14 @@ fn open_db(data_dir: std::path::PathBuf) -> rusqlite::Result<Connection> {
cached_at INTEGER NOT NULL, cached_at INTEGER NOT NULL,
PRIMARY KEY (pubkey, owner_pubkey) PRIMARY KEY (pubkey, owner_pubkey)
); );
CREATE INDEX IF NOT EXISTS idx_followers_owner ON followers(owner_pubkey);", CREATE INDEX IF NOT EXISTS idx_followers_owner ON followers(owner_pubkey);
CREATE TABLE IF NOT EXISTS bookmarked_notes (
id TEXT PRIMARY KEY,
owner_pubkey TEXT NOT NULL,
raw TEXT NOT NULL,
cached_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_bookmarks_owner ON bookmarked_notes(owner_pubkey);",
)?; )?;
Ok(conn) Ok(conn)
} }
@@ -289,6 +296,75 @@ fn db_load_followers(
Ok(result) Ok(result)
} }
// ── Bookmarks cache ─────────────────────────────────────────────────────────
#[tauri::command]
fn db_save_bookmarked_notes(
state: tauri::State<DbState>,
notes: Vec<String>,
owner_pubkey: String,
) -> Result<(), String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
for raw in &notes {
let v: serde_json::Value = serde_json::from_str(raw).map_err(|e| e.to_string())?;
let id = v["id"].as_str().unwrap_or_default();
conn.execute(
"INSERT OR REPLACE INTO bookmarked_notes (id, owner_pubkey, raw, cached_at) VALUES (?1,?2,?3,?4)",
params![id, owner_pubkey, raw, now],
)
.map_err(|e| e.to_string())?;
}
// Prune to 500 per owner
conn.execute(
"DELETE FROM bookmarked_notes WHERE owner_pubkey=?1 AND id NOT IN \
(SELECT id FROM bookmarked_notes WHERE owner_pubkey=?1 ORDER BY cached_at DESC LIMIT 500)",
params![owner_pubkey],
)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
fn db_load_bookmarked_notes(
state: tauri::State<DbState>,
owner_pubkey: String,
) -> Result<Vec<String>, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT raw FROM bookmarked_notes WHERE owner_pubkey=?1 ORDER BY cached_at DESC")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([&owner_pubkey], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?;
let mut result = Vec::new();
for row in rows {
result.push(row.map_err(|e| e.to_string())?);
}
Ok(result)
}
// ── Articles cache ──────────────────────────────────────────────────────────
#[tauri::command]
fn db_load_articles(state: tauri::State<DbState>, limit: u32) -> Result<Vec<String>, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT raw FROM notes WHERE kind=30023 ORDER BY created_at DESC LIMIT ?1")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([limit], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?;
let mut result = Vec::new();
for row in rows {
result.push(row.map_err(|e| e.to_string())?);
}
Ok(result)
}
// ── App entry ──────────────────────────────────────────────────────────────── // ── App entry ────────────────────────────────────────────────────────────────
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -372,6 +448,9 @@ pub fn run() {
db_newest_notification_ts, db_newest_notification_ts,
db_save_followers, db_save_followers,
db_load_followers, db_load_followers,
db_save_bookmarked_notes,
db_load_bookmarked_notes,
db_load_articles,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import { fetchArticleFeed } from "../../lib/nostr"; import { fetchArticleFeed, getNDK } from "../../lib/nostr";
import { useUserStore } from "../../stores/user"; import { useUserStore } from "../../stores/user";
import { useUIStore } from "../../stores/ui"; import { useUIStore } from "../../stores/ui";
import { dbLoadArticles, dbSaveNotes } from "../../lib/db";
import { ArticleCard } from "./ArticleCard"; import { ArticleCard } from "./ArticleCard";
type ArticleTab = "latest" | "following"; type ArticleTab = "latest" | "following";
@@ -21,11 +22,42 @@ export function ArticleFeed() {
if (tab === "following" && follows.length === 0) return; if (tab === "following" && follows.length === 0) return;
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
const authors = tab === "following" ? follows : undefined;
fetchArticleFeed(40, authors) (async () => {
.then((result) => { if (!cancelled) setArticles(result); }) // 1) Instant: load from SQLite cache (latest tab only — following is filtered)
.catch(() => { if (!cancelled) setArticles([]); }) if (tab === "latest") {
.finally(() => { if (!cancelled) setLoading(false); }); const cached = await dbLoadArticles(40);
if (!cancelled && cached.length > 0) {
const ndk = getNDK();
const events = cached
.map((raw) => { try { return new NDKEvent(ndk, JSON.parse(raw)); } catch { return null; } })
.filter((e): e is NDKEvent => e !== null)
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
if (events.length > 0) {
setArticles(events);
setLoading(false);
}
}
}
// 2) Background: fetch from relays and merge
const authors = tab === "following" ? follows : undefined;
try {
const result = await fetchArticleFeed(40, authors);
if (!cancelled) {
setArticles(result);
// Save to notes table for next time
if (result.length > 0) {
dbSaveNotes(result.map((e) => JSON.stringify(e.rawEvent())));
}
}
} catch {
if (!cancelled && articles.length === 0) setArticles([]);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [followsKey]); }, [followsKey]);

View File

@@ -2,7 +2,8 @@ import { useEffect, useState } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useBookmarkStore } from "../../stores/bookmark"; import { useBookmarkStore } from "../../stores/bookmark";
import { useUserStore } from "../../stores/user"; import { useUserStore } from "../../stores/user";
import { fetchNoteById, fetchByAddr } from "../../lib/nostr"; import { fetchNoteById, fetchByAddr, getNDK } from "../../lib/nostr";
import { dbLoadBookmarkedNotes, dbSaveBookmarkedNotes } from "../../lib/db";
import { NoteCard } from "../feed/NoteCard"; import { NoteCard } from "../feed/NoteCard";
import { ArticleCard } from "../article/ArticleCard"; import { ArticleCard } from "../article/ArticleCard";
import { SkeletonNoteList } from "../shared/Skeleton"; import { SkeletonNoteList } from "../shared/Skeleton";
@@ -46,53 +47,84 @@ export function BookmarkView() {
if (pubkey) fetchBookmarks(pubkey); if (pubkey) fetchBookmarks(pubkey);
}, [pubkey]); }, [pubkey]);
// Load bookmarked notes: DB cache first (instant), then relay fetch for missing
useEffect(() => { useEffect(() => {
if (bookmarkedIds.length === 0) { if (bookmarkedIds.length === 0) {
setNotes([]); setNotes([]);
return; return;
} }
loadNotes(); let cancelled = false;
setLoadingNotes(true);
(async () => {
// 1) Instant: load from SQLite cache
if (pubkey) {
const cached = await dbLoadBookmarkedNotes(pubkey);
if (!cancelled && cached.length > 0) {
const ndk = getNDK();
const events = cached
.map((raw) => { try { return new NDKEvent(ndk, JSON.parse(raw)); } catch { return null; } })
.filter((e): e is NDKEvent => e !== null)
.filter((e) => bookmarkedIds.includes(e.id))
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
if (events.length > 0) {
setNotes(events);
setLoadingNotes(false);
}
}
}
// 2) Background: fetch from relays and merge
try {
const results = await Promise.all(
bookmarkedIds.map((id) => fetchNoteById(id))
);
if (!cancelled) {
const fetched = results
.filter((e): e is NDKEvent => e !== null)
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
setNotes(fetched);
// Save to DB for next time
if (pubkey && fetched.length > 0) {
dbSaveBookmarkedNotes(fetched.map((e) => JSON.stringify(e.rawEvent())), pubkey);
}
}
} finally {
if (!cancelled) setLoadingNotes(false);
}
})();
return () => { cancelled = true; };
}, [bookmarkedIds]); }, [bookmarkedIds]);
// Load bookmarked articles (no DB cache yet — articles are fetched by addr)
useEffect(() => { useEffect(() => {
if (bookmarkedArticleAddrs.length === 0) { if (bookmarkedArticleAddrs.length === 0) {
setArticles([]); setArticles([]);
return; return;
} }
loadArticles(); let cancelled = false;
}, [bookmarkedArticleAddrs]);
const loadNotes = async () => {
setLoadingNotes(true);
try {
const results = await Promise.all(
bookmarkedIds.map((id) => fetchNoteById(id))
);
setNotes(
results
.filter((e): e is NDKEvent => e !== null)
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
);
} finally {
setLoadingNotes(false);
}
};
const loadArticles = async () => {
setLoadingArticles(true); setLoadingArticles(true);
try {
const results = await Promise.all( (async () => {
bookmarkedArticleAddrs.map((addr) => fetchByAddr(addr)) try {
); const results = await Promise.all(
setArticles( bookmarkedArticleAddrs.map((addr) => fetchByAddr(addr))
results );
.filter((e): e is NDKEvent => e !== null) if (!cancelled) {
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)) setArticles(
); results
} finally { .filter((e): e is NDKEvent => e !== null)
setLoadingArticles(false); .sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
} );
}; }
} finally {
if (!cancelled) setLoadingArticles(false);
}
})();
return () => { cancelled = true; };
}, [bookmarkedArticleAddrs]);
const totalCount = bookmarkedIds.length + bookmarkedArticleAddrs.length; const totalCount = bookmarkedIds.length + bookmarkedArticleAddrs.length;
const loading = tab === "notes" ? loadingNotes : loadingArticles; const loading = tab === "notes" ? loadingNotes : loadingArticles;

View File

@@ -62,3 +62,23 @@ export function dbSaveFollowers(followers: string[], ownerPubkey: string): void
export async function dbLoadFollowers(ownerPubkey: string): Promise<string[]> { export async function dbLoadFollowers(ownerPubkey: string): Promise<string[]> {
return invoke<string[]>("db_load_followers", { ownerPubkey }).catch(() => []); return invoke<string[]>("db_load_followers", { ownerPubkey }).catch(() => []);
} }
// ── Bookmarks cache ────────────────────────────────────────────────────────
/** Save bookmarked note events to SQLite. Fire-and-forget. */
export function dbSaveBookmarkedNotes(raws: string[], ownerPubkey: string): void {
if (raws.length === 0) return;
invoke("db_save_bookmarked_notes", { notes: raws, ownerPubkey }).catch(() => {});
}
/** Load cached bookmarked note JSONs for owner. */
export async function dbLoadBookmarkedNotes(ownerPubkey: string): Promise<string[]> {
return invoke<string[]>("db_load_bookmarked_notes", { ownerPubkey }).catch(() => []);
}
// ── Articles cache ─────────────────────────────────────────────────────────
/** Load cached articles (kind 30023) from the notes table. */
export async function dbLoadArticles(limit = 100): Promise<string[]> {
return invoke<string[]>("db_load_articles", { limit }).catch(() => []);
}