Files
rsspipe/internal/db/db.go
T
haopengzhan ebcaa5d72a
CI Quality Checks & Tests / Gitleaks Secret Detection (push) Failing after 1m59s
CI Quality Checks & Tests / Go Lint & Unit Tests (push) Failing after 2m26s
CI Quality Checks & Tests / Frontend Typecheck & Build (push) Successful in 2m37s
refactor(core): overhaul state transition architecture, persistent job queue, and API layer
- Implement atomic state transitions: add EntryService.PatchEntry() with EntryDiff computation and EventEntryTransition event emission.
- Add persistent SQLite task queue (JobEngine): add jobs table with crash recovery, exponential backoff retries, concurrency control, and idempotency key deduplication.
- Decompose monolithic HTTP handlers: split handlers.go into domain handlers (auth, feeds, entries, jobs, settings, rules, sse).
- Implement real-time Server-Sent Events (/api/events/stream) for multi-tab/device state synchronization.
- Fix SQLite concurrency: apply busy_timeout(10000) and journal_mode(WAL) via DSN query parameters to resolve SQLITE_BUSY (5).
- Fix AI settings & prompt loading: unmarshal JSON from ai_settings, apply configured prompt templates, and enforce strict enabled/API key validations.
- Fix undo share state: remove omitempty from ShareUUID to serialize null for state resets.
- Fix middle list counter: compute true unread/total stats from DB and clean switcher button labels.
- Adapt mobile UI for iOS: add viewport-fit=cover in index.html and calibrate safe-area insets for the bottom navigation and article reader toolbar.
- Add architectural documentation and worklog in docs/009_worklog.md.
2026-09-01 00:41:05 -07:00

1619 lines
43 KiB
Go

package db
import (
"database/sql"
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
"github.com/google/uuid"
_ "modernc.org/sqlite"
)
type DB struct {
*sql.DB
}
func InitDB(dbPath string) (*DB, error) {
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create db directory: %w", err)
}
dsn := fmt.Sprintf("%s?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=cache_size(-20000)", dbPath)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open sqlite db: %w", err)
}
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Hour)
database := &DB{DB: db}
if err := database.migrate(); err != nil {
return nil, fmt.Errorf("failed to run migrations: %w", err)
}
return database, nil
}
func (d *DB) migrate() error {
schema := `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
parent_id TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME NOT NULL,
FOREIGN KEY(parent_id) REFERENCES categories(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS feeds (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
feed_url TEXT UNIQUE NOT NULL,
site_url TEXT,
category_id TEXT,
is_virtual_digest BOOLEAN DEFAULT 0,
refresh_interval_minutes INTEGER DEFAULT 30,
timeout_seconds INTEGER DEFAULT 10,
etag TEXT DEFAULT '',
last_modified TEXT DEFAULT '',
last_fetched_at DATETIME,
fetch_error TEXT DEFAULT '',
created_at DATETIME NOT NULL,
FOREIGN KEY(category_id) REFERENCES categories(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS feed_entries (
id TEXT PRIMARY KEY,
feed_id TEXT NOT NULL,
guid TEXT NOT NULL,
url TEXT NOT NULL,
title TEXT NOT NULL,
author TEXT DEFAULT '',
content TEXT NOT NULL,
raw_content TEXT DEFAULT '',
published_at DATETIME NOT NULL,
is_read BOOLEAN DEFAULT 0,
is_starred BOOLEAN DEFAULT 0,
share_uuid TEXT UNIQUE,
metadata TEXT DEFAULT '{}',
created_at DATETIME NOT NULL,
FOREIGN KEY(feed_id) REFERENCES feeds(id) ON DELETE CASCADE,
UNIQUE(feed_id, guid)
);
CREATE INDEX IF NOT EXISTS idx_entries_feed_read ON feed_entries(feed_id, is_read);
CREATE INDEX IF NOT EXISTS idx_entries_published ON feed_entries(published_at DESC);
CREATE INDEX IF NOT EXISTS idx_entries_is_read ON feed_entries(is_read);
CREATE INDEX IF NOT EXISTS idx_entries_is_starred ON feed_entries(is_starred);
CREATE INDEX IF NOT EXISTS idx_entries_share_uuid ON feed_entries(share_uuid);
CREATE TABLE IF NOT EXISTS entry_variants (
entry_id TEXT NOT NULL,
variant_key TEXT NOT NULL,
content_type TEXT NOT NULL,
text_value TEXT,
blob_path TEXT,
metadata TEXT DEFAULT '{}',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (entry_id, variant_key),
FOREIGN KEY(entry_id) REFERENCES feed_entries(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_entry_variants_key ON entry_variants(variant_key);
CREATE TABLE IF NOT EXISTS rules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
trigger TEXT NOT NULL,
conditions TEXT DEFAULT '[]',
match_mode TEXT DEFAULT 'all',
actions TEXT DEFAULT '[]',
is_active BOOLEAN DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS search_history (
keyword TEXT PRIMARY KEY,
last_searched_at DATETIME NOT NULL,
search_count INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_search_history_time ON search_history(last_searched_at DESC);
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL,
progress INTEGER DEFAULT 0,
result TEXT DEFAULT '{}',
error_message TEXT DEFAULT '',
attempts INTEGER DEFAULT 0,
max_attempts INTEGER DEFAULT 3,
idempotency_key TEXT UNIQUE,
run_at DATETIME NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_jobs_status_run_at ON jobs(status, run_at);
CREATE INDEX IF NOT EXISTS idx_jobs_idempotency ON jobs(idempotency_key);
`
if _, err := d.Exec(schema); err != nil {
return err
}
// Migration for timeout_seconds column if older DB exists
_, _ = d.Exec("ALTER TABLE feeds ADD COLUMN timeout_seconds INTEGER DEFAULT 10")
// Migration for icon_url column if older DB exists
_, _ = d.Exec("ALTER TABLE feeds ADD COLUMN icon_url TEXT DEFAULT ''")
// Backfill existing feeds with favicons if empty
_, _ = d.Exec(`
UPDATE feeds
SET icon_url = 'https://www.google.com/s2/favicons?domain=' || REPLACE(REPLACE(REPLACE(site_url, 'https://', ''), 'http://', ''), '/', '') || '&sz=64'
WHERE (icon_url IS NULL OR icon_url = '') AND site_url IS NOT NULL AND site_url != ''
`)
// Migration for preferences column on users table if older DB exists
_, _ = d.Exec("ALTER TABLE users ADD COLUMN preferences TEXT DEFAULT '{}'")
// Migration for rules table schema if older table exists
var testCol string
err := d.QueryRow("SELECT trigger FROM rules LIMIT 1").Scan(&testCol)
if err != nil && err != sql.ErrNoRows {
_, _ = d.Exec("DROP TABLE IF EXISTS rules")
_, _ = d.Exec(`
CREATE TABLE IF NOT EXISTS rules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
trigger TEXT NOT NULL,
conditions TEXT DEFAULT '[]',
match_mode TEXT DEFAULT 'all',
actions TEXT DEFAULT '[]',
is_active BOOLEAN DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
`)
}
return nil
}
// User operations
func (d *DB) CountUsers() (int, error) {
var count int
err := d.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
return count, err
}
func (d *DB) GetUserByUsername(username string) (*models.User, error) {
var u models.User
var prefsRaw sql.NullString
err := d.QueryRow(
"SELECT id, username, password_hash, preferences, created_at, updated_at FROM users WHERE username = ?",
username,
).Scan(&u.ID, &u.Username, &u.PasswordHash, &prefsRaw, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
return nil, err
}
if prefsRaw.Valid && prefsRaw.String != "" {
_ = json.Unmarshal([]byte(prefsRaw.String), &u.Preferences)
}
if u.Preferences == nil {
u.Preferences = make(map[string]interface{})
}
return &u, nil
}
func (d *DB) CreateUser(username, passwordHash string) (*models.User, error) {
now := time.Now().UTC()
u := &models.User{
ID: uuid.New().String(),
Username: username,
PasswordHash: passwordHash,
Preferences: make(map[string]interface{}),
CreatedAt: now,
UpdatedAt: now,
}
_, err := d.Exec(
"INSERT INTO users (id, username, password_hash, preferences, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
u.ID, u.Username, u.PasswordHash, "{}", u.CreatedAt, u.UpdatedAt,
)
if err != nil {
return nil, err
}
return u, nil
}
func (d *DB) UpdateUserPassword(userID, passwordHash string) error {
now := time.Now().UTC()
_, err := d.Exec(
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?",
passwordHash, now, userID,
)
return err
}
func (d *DB) UpdateUserPreferences(userID string, prefs map[string]interface{}) error {
raw, err := json.Marshal(prefs)
if err != nil {
return err
}
now := time.Now().UTC()
_, err = d.Exec(
"UPDATE users SET preferences = ?, updated_at = ? WHERE id = ?",
string(raw), now, userID,
)
return err
}
// Category operations
func (d *DB) ListCategories() ([]models.Category, error) {
query := `
SELECT c.id, c.title, c.parent_id, c.sort_order, c.created_at, COUNT(f.id) as feed_count
FROM categories c
LEFT JOIN feeds f ON f.category_id = c.id
GROUP BY c.id
ORDER BY c.sort_order ASC, c.title ASC
`
rows, err := d.Query(query)
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
var categories []models.Category
for rows.Next() {
var c models.Category
var parentID sql.NullString
if err := rows.Scan(&c.ID, &c.Title, &parentID, &c.SortOrder, &c.CreatedAt, &c.FeedCount); err != nil {
return nil, err
}
if parentID.Valid {
p := parentID.String
c.ParentID = &p
}
categories = append(categories, c)
}
return categories, nil
}
func (d *DB) GetCategoryByTitle(title string) (*models.Category, error) {
var c models.Category
var parentID sql.NullString
err := d.QueryRow("SELECT id, title, parent_id, sort_order, created_at FROM categories WHERE title = ?", title).
Scan(&c.ID, &c.Title, &parentID, &c.SortOrder, &c.CreatedAt)
if err != nil {
return nil, err
}
if parentID.Valid {
p := parentID.String
c.ParentID = &p
}
return &c, nil
}
func (d *DB) CreateCategory(title string, parentID *string) (*models.Category, error) {
c := &models.Category{
ID: uuid.New().String(),
Title: title,
ParentID: parentID,
SortOrder: 0,
CreatedAt: time.Now().UTC(),
}
var pID interface{}
if parentID != nil {
pID = *parentID
}
_, err := d.Exec("INSERT INTO categories (id, title, parent_id, sort_order, created_at) VALUES (?, ?, ?, ?, ?)",
c.ID, c.Title, pID, c.SortOrder, c.CreatedAt)
if err != nil {
return nil, err
}
return c, nil
}
func (d *DB) DeleteCategory(id string) error {
_, err := d.Exec("DELETE FROM categories WHERE id = ?", id)
return err
}
// ResolveFavicon generates a high-quality favicon URL from a website or feed URL
func ResolveFavicon(siteURL, feedURL string) string {
targetURL := strings.TrimSpace(siteURL)
if targetURL == "" {
targetURL = strings.TrimSpace(feedURL)
}
if targetURL == "" {
return ""
}
if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") {
targetURL = "https://" + targetURL
}
u, err := url.Parse(targetURL)
if err != nil || u.Host == "" {
return ""
}
return fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=64", u.Host)
}
// Feed operations
func (d *DB) ListFeeds() ([]models.Feed, error) {
query := `
SELECT
f.id, f.title, f.feed_url, f.site_url, COALESCE(f.icon_url, '') as icon_url, f.category_id,
COALESCE(c.title, '') as category_title,
f.is_virtual_digest, f.refresh_interval_minutes, COALESCE(f.timeout_seconds, 10),
f.etag, f.last_modified, f.last_fetched_at, f.fetch_error, f.created_at,
COALESCE(SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END), 0) as unread_count,
COUNT(e.id) as total_count
FROM feeds f
LEFT JOIN categories c ON c.id = f.category_id
LEFT JOIN feed_entries e ON e.feed_id = f.id
GROUP BY f.id
ORDER BY f.title ASC
`
rows, err := d.Query(query)
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
var feeds []models.Feed
for rows.Next() {
var f models.Feed
var catID, siteURL, iconURL, etag, lastMod, fetchErr sql.NullString
var lastFetched sql.NullTime
if err := rows.Scan(
&f.ID, &f.Title, &f.FeedURL, &siteURL, &iconURL, &catID, &f.CategoryTitle,
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds,
&etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount,
); err != nil {
return nil, err
}
if catID.Valid {
cid := catID.String
f.CategoryID = &cid
}
if siteURL.Valid {
f.SiteURL = siteURL.String
}
if iconURL.Valid {
f.IconURL = iconURL.String
}
if f.IconURL == "" {
f.IconURL = ResolveFavicon(f.SiteURL, f.FeedURL)
}
if etag.Valid {
f.ETag = etag.String
}
if lastMod.Valid {
f.LastModified = lastMod.String
}
if lastFetched.Valid {
t := lastFetched.Time
f.LastFetchedAt = &t
}
if fetchErr.Valid {
f.FetchError = fetchErr.String
}
feeds = append(feeds, f)
}
return feeds, nil
}
func (d *DB) GetFeed(id string) (*models.Feed, error) {
query := `
SELECT
f.id, f.title, f.feed_url, f.site_url, COALESCE(f.icon_url, '') as icon_url, f.category_id,
COALESCE(c.title, '') as category_title,
f.is_virtual_digest, f.refresh_interval_minutes, COALESCE(f.timeout_seconds, 10),
f.etag, f.last_modified, f.last_fetched_at, f.fetch_error, f.created_at,
COALESCE(SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END), 0) as unread_count,
COUNT(e.id) as total_count
FROM feeds f
LEFT JOIN categories c ON c.id = f.category_id
LEFT JOIN feed_entries e ON e.feed_id = f.id
WHERE f.id = ?
GROUP BY f.id
`
var f models.Feed
var catID, siteURL, iconURL, etag, lastMod, fetchErr sql.NullString
var lastFetched sql.NullTime
err := d.QueryRow(query, id).Scan(
&f.ID, &f.Title, &f.FeedURL, &siteURL, &iconURL, &catID, &f.CategoryTitle,
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds,
&etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount,
)
if err != nil {
return nil, err
}
if catID.Valid {
cid := catID.String
f.CategoryID = &cid
}
if siteURL.Valid {
f.SiteURL = siteURL.String
}
if iconURL.Valid {
f.IconURL = iconURL.String
}
if f.IconURL == "" {
f.IconURL = ResolveFavicon(f.SiteURL, f.FeedURL)
}
if etag.Valid {
f.ETag = etag.String
}
if lastMod.Valid {
f.LastModified = lastMod.String
}
if lastFetched.Valid {
t := lastFetched.Time
f.LastFetchedAt = &t
}
if fetchErr.Valid {
f.FetchError = fetchErr.String
}
return &f, nil
}
func (d *DB) GetFeedByURL(feedURL string) (*models.Feed, error) {
var f models.Feed
var catID, siteURL, iconURL, etag, lastMod, fetchErr sql.NullString
var lastFetched sql.NullTime
err := d.QueryRow(
"SELECT id, title, feed_url, site_url, COALESCE(icon_url, ''), category_id, is_virtual_digest, refresh_interval_minutes, COALESCE(timeout_seconds, 10), etag, last_modified, last_fetched_at, fetch_error, created_at FROM feeds WHERE feed_url = ?",
feedURL,
).Scan(
&f.ID, &f.Title, &f.FeedURL, &siteURL, &iconURL, &catID,
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds, &etag, &lastMod,
&lastFetched, &fetchErr, &f.CreatedAt,
)
if err != nil {
return nil, err
}
if catID.Valid {
cid := catID.String
f.CategoryID = &cid
}
if siteURL.Valid {
f.SiteURL = siteURL.String
}
if iconURL.Valid {
f.IconURL = iconURL.String
}
if f.IconURL == "" {
f.IconURL = ResolveFavicon(f.SiteURL, f.FeedURL)
}
return &f, nil
}
func (d *DB) CreateFeed(title, feedURL, siteURL, iconURL string, categoryID *string) (*models.Feed, error) {
now := time.Now().UTC()
if iconURL == "" {
iconURL = ResolveFavicon(siteURL, feedURL)
}
f := &models.Feed{
ID: uuid.New().String(),
Title: title,
FeedURL: feedURL,
SiteURL: siteURL,
IconURL: iconURL,
CategoryID: categoryID,
RefreshIntervalMinutes: 30,
TimeoutSeconds: 10,
CreatedAt: now,
}
var catID interface{}
if categoryID != nil {
catID = *categoryID
}
_, err := d.Exec(
"INSERT INTO feeds (id, title, feed_url, site_url, icon_url, category_id, refresh_interval_minutes, timeout_seconds, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
f.ID, f.Title, f.FeedURL, f.SiteURL, f.IconURL, catID, f.RefreshIntervalMinutes, f.TimeoutSeconds, f.CreatedAt,
)
if err != nil {
return nil, err
}
return f, nil
}
func (d *DB) UpdateFeed(id string, req models.UpdateFeedRequest) error {
feed, err := d.GetFeed(id)
if err != nil {
return err
}
title := feed.Title
if req.Title != nil && *req.Title != "" {
title = *req.Title
}
iconURL := feed.IconURL
if req.IconURL != nil {
iconURL = strings.TrimSpace(*req.IconURL)
}
catID := feed.CategoryID
if req.CategoryID != nil {
if *req.CategoryID == "" {
catID = nil
} else {
catID = req.CategoryID
}
}
interval := feed.RefreshIntervalMinutes
if req.RefreshIntervalMinutes != nil && *req.RefreshIntervalMinutes > 0 {
interval = *req.RefreshIntervalMinutes
}
timeout := feed.TimeoutSeconds
if req.TimeoutSeconds != nil && *req.TimeoutSeconds > 0 {
timeout = *req.TimeoutSeconds
}
var cID interface{}
if catID != nil {
cID = *catID
}
_, err = d.Exec(`
UPDATE feeds
SET title = ?, icon_url = ?, category_id = ?, refresh_interval_minutes = ?, timeout_seconds = ?
WHERE id = ?
`, title, iconURL, cID, interval, timeout, id)
return err
}
func (d *DB) UpdateFeedFetchStatus(id string, etag, lastModified, fetchErr string) error {
now := time.Now().UTC()
_, err := d.Exec(
"UPDATE feeds SET etag = ?, last_modified = ?, fetch_error = ?, last_fetched_at = ? WHERE id = ?",
etag, lastModified, fetchErr, now, id,
)
return err
}
func (d *DB) DeleteFeed(id string) error {
_, err := d.Exec("DELETE FROM feeds WHERE id = ?", id)
return err
}
// Feed Entries operations
func (d *DB) UpsertFeedEntries(feedID string, entries []models.FeedEntry) ([]models.FeedEntry, error) {
if len(entries) == 0 {
return nil, nil
}
var newEntries []models.FeedEntry
now := time.Now().UTC()
insertStmt, err := d.Prepare(`
INSERT INTO feed_entries (
id, feed_id, guid, url, title, author, content, raw_content, published_at, is_read, is_starred, metadata, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)
`)
if err != nil {
return nil, err
}
defer func() {
_ = insertStmt.Close()
}()
updateStmt, err := d.Prepare(`
UPDATE feed_entries
SET title = ?, url = ?, author = ?, content = ?, raw_content = ?
WHERE feed_id = ? AND guid = ?
`)
if err != nil {
return nil, err
}
defer func() {
_ = updateStmt.Close()
}()
for _, e := range entries {
var existingID string
err := d.QueryRow("SELECT id FROM feed_entries WHERE feed_id = ? AND guid = ?", feedID, e.GUID).Scan(&existingID)
switch err {
case sql.ErrNoRows:
e.ID = uuid.New().String()
e.FeedID = feedID
pubAt := e.PublishedAt
if pubAt.IsZero() {
pubAt = now
}
metaBytes, _ := json.Marshal(e.Metadata)
if len(metaBytes) == 0 {
metaBytes = []byte("{}")
}
_, err := insertStmt.Exec(
e.ID, feedID, e.GUID, e.URL, e.Title, e.Author, e.Content, e.RawContent, pubAt, string(metaBytes), now,
)
if err == nil {
e.CreatedAt = now
newEntries = append(newEntries, e)
}
case nil:
_, _ = updateStmt.Exec(e.Title, e.URL, e.Author, e.Content, e.RawContent, feedID, e.GUID)
}
}
return newEntries, nil
}
type EntryFilter struct {
View string // "inbox" (unread), "starred", "all"
FeedID string
CategoryID string
Search string
Limit int
Offset int
}
func (d *DB) GetEntry(id string) (*models.FeedEntry, error) {
query := `
SELECT
e.id, e.feed_id, f.title as feed_title, COALESCE(f.site_url, '') as feed_site_url,
e.guid, e.url, e.title, e.author, e.content, e.raw_content,
e.published_at, e.is_read, e.is_starred, e.share_uuid, e.metadata, e.created_at
FROM feed_entries e
JOIN feeds f ON f.id = e.feed_id
WHERE e.id = ?
`
var e models.FeedEntry
var author, rawContent, shareUUID, metaStr sql.NullString
err := d.QueryRow(query, id).Scan(
&e.ID, &e.FeedID, &e.FeedTitle, &e.FeedSiteURL,
&e.GUID, &e.URL, &e.Title, &author, &e.Content, &rawContent,
&e.PublishedAt, &e.IsRead, &e.IsStarred, &shareUUID, &metaStr, &e.CreatedAt,
)
if err != nil {
return nil, err
}
if author.Valid {
e.Author = author.String
}
if rawContent.Valid {
e.RawContent = rawContent.String
}
if shareUUID.Valid {
s := shareUUID.String
e.ShareUUID = &s
}
e.Metadata = make(map[string]interface{})
if metaStr.Valid && metaStr.String != "" {
_ = json.Unmarshal([]byte(metaStr.String), &e.Metadata)
}
// Load variants
variants, _ := d.ListVariantsForEntry(e.ID)
e.Variants = variants
return &e, nil
}
func (d *DB) ListEntries(filter EntryFilter) ([]models.FeedEntry, int, error) {
var whereClauses []string
var args []interface{}
switch filter.View {
case "inbox":
whereClauses = append(whereClauses, "e.is_read = 0")
case "starred":
whereClauses = append(whereClauses, "e.is_starred = 1")
case "shared":
whereClauses = append(whereClauses, "e.share_uuid IS NOT NULL AND e.share_uuid != ''")
}
if filter.FeedID != "" {
whereClauses = append(whereClauses, "e.feed_id = ?")
args = append(args, filter.FeedID)
}
if filter.CategoryID != "" {
whereClauses = append(whereClauses, "f.category_id = ?")
args = append(args, filter.CategoryID)
}
if filter.Search != "" {
whereClauses = append(whereClauses, "(e.title LIKE ? OR e.content LIKE ? OR e.author LIKE ?)")
pattern := "%" + filter.Search + "%"
args = append(args, pattern, pattern, pattern)
}
whereSQL := ""
if len(whereClauses) > 0 {
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")
}
countQuery := fmt.Sprintf(`
SELECT COUNT(e.id)
FROM feed_entries e
JOIN feeds f ON f.id = e.feed_id
%s
`, whereSQL)
var total int
err := d.QueryRow(countQuery, args...).Scan(&total)
if err != nil {
return nil, 0, err
}
limit := filter.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
offset := filter.Offset
if offset < 0 {
offset = 0
}
query := fmt.Sprintf(`
SELECT
e.id, e.feed_id, f.title as feed_title, COALESCE(f.site_url, '') as feed_site_url,
e.guid, e.url, e.title, e.author, e.content, e.raw_content,
e.published_at, e.is_read, e.is_starred, e.share_uuid, e.metadata, e.created_at
FROM feed_entries e
JOIN feeds f ON f.id = e.feed_id
%s
ORDER BY e.published_at DESC, e.created_at DESC
LIMIT ? OFFSET ?
`, whereSQL)
queryArgs := append(args, limit, offset)
rows, err := d.Query(query, queryArgs...)
if err != nil {
return nil, 0, err
}
defer func() {
_ = rows.Close()
}()
var entries []models.FeedEntry
for rows.Next() {
var e models.FeedEntry
var author, rawContent, shareUUID, metaStr sql.NullString
err := rows.Scan(
&e.ID, &e.FeedID, &e.FeedTitle, &e.FeedSiteURL,
&e.GUID, &e.URL, &e.Title, &author, &e.Content, &rawContent,
&e.PublishedAt, &e.IsRead, &e.IsStarred, &shareUUID, &metaStr, &e.CreatedAt,
)
if err != nil {
return nil, 0, err
}
if author.Valid {
e.Author = author.String
}
if rawContent.Valid {
e.RawContent = rawContent.String
}
if shareUUID.Valid {
s := shareUUID.String
e.ShareUUID = &s
}
e.Metadata = make(map[string]interface{})
if metaStr.Valid && metaStr.String != "" {
_ = json.Unmarshal([]byte(metaStr.String), &e.Metadata)
}
entries = append(entries, e)
}
// Batch-load variants for all returned entries
if len(entries) > 0 {
ids := make([]string, len(entries))
for i, e := range entries {
ids[i] = e.ID
}
variantMap, err := d.batchLoadVariants(ids)
if err == nil {
for i := range entries {
if v, ok := variantMap[entries[i].ID]; ok {
entries[i].Variants = v
}
}
}
}
return entries, total, nil
}
func (d *DB) UpdateEntryRead(id string, isRead bool) error {
_, err := d.Exec("UPDATE feed_entries SET is_read = ? WHERE id = ?", isRead, id)
return err
}
func (d *DB) UpdateEntryStarred(id string, isStarred bool) error {
_, err := d.Exec("UPDATE feed_entries SET is_starred = ? WHERE id = ?", isStarred, id)
return err
}
func (d *DB) UpdateEntryMetadata(id string, metadata map[string]interface{}) error {
metaBytes, err := json.Marshal(metadata)
if err != nil {
return err
}
_, err = d.Exec("UPDATE feed_entries SET metadata = ? WHERE id = ?", string(metaBytes), id)
return err
}
func (d *DB) ToggleShareEntry(id string) (*string, error) {
entry, err := d.GetEntry(id)
if err != nil {
return nil, err
}
if entry.ShareUUID != nil && *entry.ShareUUID != "" {
// Unshare
_, err := d.Exec("UPDATE feed_entries SET share_uuid = NULL WHERE id = ?", id)
return nil, err
}
// Share
newUUID := uuid.New().String()
_, err = d.Exec("UPDATE feed_entries SET share_uuid = ? WHERE id = ?", newUUID, id)
if err != nil {
return nil, err
}
return &newUUID, nil
}
func (d *DB) MarkAllRead(feedID, categoryID *string) error {
if feedID != nil && *feedID != "" {
_, err := d.Exec("UPDATE feed_entries SET is_read = 1 WHERE feed_id = ?", *feedID)
return err
}
if categoryID != nil && *categoryID != "" {
_, err := d.Exec(`
UPDATE feed_entries
SET is_read = 1
WHERE feed_id IN (SELECT id FROM feeds WHERE category_id = ?)
`, *categoryID)
return err
}
_, err := d.Exec("UPDATE feed_entries SET is_read = 1")
return err
}
// Entry Variants CRUD
func (d *DB) GetVariant(entryID, key string) (*models.EntryVariant, error) {
query := `
SELECT entry_id, variant_key, content_type, COALESCE(text_value, ''), COALESCE(blob_path, ''), metadata, created_at, updated_at
FROM entry_variants
WHERE entry_id = ? AND variant_key = ?
`
var v models.EntryVariant
var metaStr sql.NullString
err := d.QueryRow(query, entryID, key).Scan(
&v.EntryID, &v.VariantKey, &v.ContentType, &v.TextValue, &v.BlobPath, &metaStr, &v.CreatedAt, &v.UpdatedAt,
)
if err != nil {
return nil, err
}
v.Metadata = make(map[string]interface{})
if metaStr.Valid && metaStr.String != "" {
_ = json.Unmarshal([]byte(metaStr.String), &v.Metadata)
}
return &v, nil
}
func (d *DB) ListVariantsForEntry(entryID string) (map[string]models.EntryVariant, error) {
query := `
SELECT entry_id, variant_key, content_type, COALESCE(text_value, ''), COALESCE(blob_path, ''), metadata, created_at, updated_at
FROM entry_variants
WHERE entry_id = ?
`
rows, err := d.Query(query, entryID)
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
variants := make(map[string]models.EntryVariant)
for rows.Next() {
var v models.EntryVariant
var metaStr sql.NullString
err := rows.Scan(
&v.EntryID, &v.VariantKey, &v.ContentType, &v.TextValue, &v.BlobPath, &metaStr, &v.CreatedAt, &v.UpdatedAt,
)
if err != nil {
return nil, err
}
v.Metadata = make(map[string]interface{})
if metaStr.Valid && metaStr.String != "" {
_ = json.Unmarshal([]byte(metaStr.String), &v.Metadata)
}
variants[v.VariantKey] = v
}
return variants, nil
}
// batchLoadVariants loads lightweight variants (tags, ai_summary keys, etc.) for multiple entries.
// Excludes large text variants like readability_full_text for performance.
func (d *DB) batchLoadVariants(entryIDs []string) (map[string]map[string]models.EntryVariant, error) {
if len(entryIDs) == 0 {
return nil, nil
}
placeholders := make([]string, len(entryIDs))
args := make([]interface{}, len(entryIDs))
for i, id := range entryIDs {
placeholders[i] = "?"
args[i] = id
}
query := `
SELECT entry_id, variant_key, content_type,
CASE WHEN variant_key IN ('readability_full_text') THEN '' ELSE COALESCE(text_value, '') END,
COALESCE(blob_path, ''), metadata, created_at, updated_at
FROM entry_variants
WHERE entry_id IN (` + strings.Join(placeholders, ",") + `)
`
rows, err := d.Query(query, args...)
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
result := make(map[string]map[string]models.EntryVariant)
for rows.Next() {
var v models.EntryVariant
var metaStr sql.NullString
err := rows.Scan(
&v.EntryID, &v.VariantKey, &v.ContentType, &v.TextValue, &v.BlobPath, &metaStr, &v.CreatedAt, &v.UpdatedAt,
)
if err != nil {
return nil, err
}
v.Metadata = make(map[string]interface{})
if metaStr.Valid && metaStr.String != "" {
_ = json.Unmarshal([]byte(metaStr.String), &v.Metadata)
}
if result[v.EntryID] == nil {
result[v.EntryID] = make(map[string]models.EntryVariant)
}
result[v.EntryID][v.VariantKey] = v
}
return result, nil
}
func (d *DB) SetVariant(v models.EntryVariant) error {
now := time.Now().UTC()
metaBytes, _ := json.Marshal(v.Metadata)
if len(metaBytes) == 0 {
metaBytes = []byte("{}")
}
query := `
INSERT INTO entry_variants (
entry_id, variant_key, content_type, text_value, blob_path, metadata, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(entry_id, variant_key) DO UPDATE SET
content_type = excluded.content_type,
text_value = excluded.text_value,
blob_path = excluded.blob_path,
metadata = excluded.metadata,
updated_at = excluded.updated_at
`
_, err := d.Exec(query, v.EntryID, v.VariantKey, v.ContentType, v.TextValue, v.BlobPath, string(metaBytes), now, now)
return err
}
func (d *DB) DeleteVariant(entryID, key string) error {
_, err := d.Exec("DELETE FROM entry_variants WHERE entry_id = ? AND variant_key = ?", entryID, key)
return err
}
// Rules CRUD
func (d *DB) ListRules() ([]models.Rule, error) {
query := `
SELECT id, name, trigger, conditions, match_mode, actions, is_active, created_at, updated_at
FROM rules
ORDER BY created_at ASC
`
rows, err := d.Query(query)
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
var rules []models.Rule
for rows.Next() {
var r models.Rule
var condsStr, actionsStr string
err := rows.Scan(
&r.ID, &r.Name, &r.Trigger, &condsStr, &r.MatchMode, &actionsStr, &r.IsActive, &r.CreatedAt, &r.UpdatedAt,
)
if err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(condsStr), &r.Conditions)
_ = json.Unmarshal([]byte(actionsStr), &r.Actions)
rules = append(rules, r)
}
return rules, nil
}
func (d *DB) GetActiveRules(trigger string) ([]models.Rule, error) {
query := `
SELECT id, name, trigger, conditions, match_mode, actions, is_active, created_at, updated_at
FROM rules
WHERE is_active = 1 AND (trigger = ? OR trigger = '*')
ORDER BY created_at ASC
`
rows, err := d.Query(query, trigger)
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
var rules []models.Rule
for rows.Next() {
var r models.Rule
var condsStr, actionsStr string
err := rows.Scan(
&r.ID, &r.Name, &r.Trigger, &condsStr, &r.MatchMode, &actionsStr, &r.IsActive, &r.CreatedAt, &r.UpdatedAt,
)
if err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(condsStr), &r.Conditions)
_ = json.Unmarshal([]byte(actionsStr), &r.Actions)
rules = append(rules, r)
}
return rules, nil
}
func (d *DB) GetRule(id string) (*models.Rule, error) {
query := `
SELECT id, name, trigger, conditions, match_mode, actions, is_active, created_at, updated_at
FROM rules
WHERE id = ?
`
var r models.Rule
var condsStr, actionsStr string
err := d.QueryRow(query, id).Scan(
&r.ID, &r.Name, &r.Trigger, &condsStr, &r.MatchMode, &actionsStr, &r.IsActive, &r.CreatedAt, &r.UpdatedAt,
)
if err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(condsStr), &r.Conditions)
_ = json.Unmarshal([]byte(actionsStr), &r.Actions)
return &r, nil
}
func (d *DB) CreateRule(name, trigger, matchMode string, conditions []models.RuleCondition, actions []models.RuleAction) (*models.Rule, error) {
id := uuid.New().String()
now := time.Now().UTC()
if matchMode == "" {
matchMode = "all"
}
condsBytes, _ := json.Marshal(conditions)
actionsBytes, _ := json.Marshal(actions)
query := `
INSERT INTO rules (
id, name, trigger, conditions, match_mode, actions, is_active, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
`
_, err := d.Exec(query, id, name, trigger, string(condsBytes), matchMode, string(actionsBytes), now, now)
if err != nil {
return nil, err
}
return &models.Rule{
ID: id,
Name: name,
Trigger: trigger,
Conditions: conditions,
MatchMode: matchMode,
Actions: actions,
IsActive: true,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
func (d *DB) UpdateRule(id string, name, trigger, matchMode string, conditions []models.RuleCondition, actions []models.RuleAction, isActive bool) (*models.Rule, error) {
now := time.Now().UTC()
if matchMode == "" {
matchMode = "all"
}
condsBytes, _ := json.Marshal(conditions)
actionsBytes, _ := json.Marshal(actions)
query := `
UPDATE rules
SET name = ?, trigger = ?, conditions = ?, match_mode = ?, actions = ?, is_active = ?, updated_at = ?
WHERE id = ?
`
res, err := d.Exec(query, name, trigger, string(condsBytes), matchMode, string(actionsBytes), isActive, now, id)
if err != nil {
return nil, err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil, fmt.Errorf("rule not found")
}
return d.GetRule(id)
}
func (d *DB) DeleteRule(id string) error {
_, err := d.Exec("DELETE FROM rules WHERE id = ?", id)
return err
}
func (d *DB) GetStats() (*models.Stats, error) {
var s models.Stats
err := d.QueryRow("SELECT COUNT(*) FROM feeds").Scan(&s.TotalFeeds)
if err != nil {
return nil, err
}
err = d.QueryRow("SELECT COUNT(*) FROM feed_entries WHERE is_read = 0").Scan(&s.TotalUnread)
if err != nil {
return nil, err
}
err = d.QueryRow("SELECT COUNT(*) FROM feed_entries WHERE is_starred = 1").Scan(&s.TotalStarred)
if err != nil {
return nil, err
}
_ = d.QueryRow("SELECT COUNT(*) FROM feed_entries WHERE share_uuid IS NOT NULL AND share_uuid != ''").Scan(&s.TotalShared)
err = d.QueryRow("SELECT COUNT(*) FROM categories").Scan(&s.TotalCategories)
if err != nil {
return nil, err
}
_ = d.QueryRow("SELECT COUNT(*) FROM rules").Scan(&s.TotalRules)
return &s, nil
}
// System Settings CRUD
func (d *DB) GetSetting(key string) (string, error) {
var val string
err := d.QueryRow("SELECT value FROM system_settings WHERE key = ?", key).Scan(&val)
if err != nil {
if err == sql.ErrNoRows {
return "", nil
}
return "", err
}
return val, nil
}
func (d *DB) SetSetting(key, value string) error {
now := time.Now().UTC()
_, err := d.Exec(`
INSERT INTO system_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, key, value, now)
return err
}
func (d *DB) GetSettingsMap() (map[string]string, error) {
rows, err := d.Query("SELECT key, value FROM system_settings")
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
res := make(map[string]string)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
res[k] = v
}
return res, nil
}
// Search History operations
func (d *DB) RecordSearch(keyword string) error {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return nil
}
now := time.Now().UTC()
_, err := d.Exec(`
INSERT INTO search_history (keyword, last_searched_at, search_count)
VALUES (?, ?, 1)
ON CONFLICT(keyword) DO UPDATE SET
last_searched_at = excluded.last_searched_at,
search_count = search_history.search_count + 1
`, keyword, now)
return err
}
func (d *DB) GetSearchHistory(query string, limit int) ([]models.SearchHistoryItem, error) {
if limit <= 0 {
limit = 10
}
var rows *sql.Rows
var err error
if query != "" {
rows, err = d.Query(`
SELECT keyword, last_searched_at, search_count
FROM search_history
WHERE keyword LIKE ?
ORDER BY last_searched_at DESC
LIMIT ?
`, "%"+query+"%", limit)
} else {
rows, err = d.Query(`
SELECT keyword, last_searched_at, search_count
FROM search_history
ORDER BY last_searched_at DESC
LIMIT ?
`, limit)
}
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
var items []models.SearchHistoryItem
for rows.Next() {
var item models.SearchHistoryItem
if err := rows.Scan(&item.Keyword, &item.LastSearchedAt, &item.SearchCount); err != nil {
return nil, err
}
items = append(items, item)
}
return items, nil
}
func (d *DB) DeleteSearchHistory(keyword string) error {
_, err := d.Exec("DELETE FROM search_history WHERE keyword = ?", keyword)
return err
}
func (d *DB) ClearSearchHistory() error {
_, err := d.Exec("DELETE FROM search_history")
return err
}
// Job Queue Operations
func (d *DB) CreateJob(job models.Job) (*models.Job, error) {
if job.ID == "" {
job.ID = uuid.New().String()
}
now := time.Now().UTC()
if job.CreatedAt.IsZero() {
job.CreatedAt = now
}
job.UpdatedAt = now
if job.RunAt.IsZero() {
job.RunAt = now
}
if job.Status == "" {
job.Status = "pending"
}
if job.MaxAttempts <= 0 {
job.MaxAttempts = 3
}
payloadBytes, err := json.Marshal(job.Payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal job payload: %w", err)
}
resultBytes, _ := json.Marshal(job.Result)
if len(resultBytes) == 0 {
resultBytes = []byte("{}")
}
var idemKey sql.NullString
if job.IdempotencyKey != "" {
idemKey = sql.NullString{String: job.IdempotencyKey, Valid: true}
}
query := `
INSERT INTO jobs (
id, type, payload, status, progress, result, error_message, attempts, max_attempts, idempotency_key, run_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
_, err = d.Exec(
query,
job.ID, job.Type, string(payloadBytes), job.Status, job.Progress,
string(resultBytes), job.ErrorMessage, job.Attempts, job.MaxAttempts,
idemKey, job.RunAt, job.CreatedAt, job.UpdatedAt,
)
if err != nil {
return nil, err
}
return &job, nil
}
func (d *DB) GetJob(id string) (*models.Job, error) {
query := `
SELECT id, type, payload, status, progress, result, error_message, attempts, max_attempts, idempotency_key, run_at, created_at, updated_at
FROM jobs
WHERE id = ?
`
var j models.Job
var payloadStr, resultStr, errorMsg string
var idemKey sql.NullString
err := d.QueryRow(query, id).Scan(
&j.ID, &j.Type, &payloadStr, &j.Status, &j.Progress, &resultStr,
&errorMsg, &j.Attempts, &j.MaxAttempts, &idemKey, &j.RunAt, &j.CreatedAt, &j.UpdatedAt,
)
if err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(payloadStr), &j.Payload)
_ = json.Unmarshal([]byte(resultStr), &j.Result)
j.ErrorMessage = errorMsg
if idemKey.Valid {
j.IdempotencyKey = idemKey.String
}
return &j, nil
}
func (d *DB) GetJobByIdempotencyKey(key string) (*models.Job, error) {
if key == "" {
return nil, sql.ErrNoRows
}
query := `
SELECT id, type, payload, status, progress, result, error_message, attempts, max_attempts, idempotency_key, run_at, created_at, updated_at
FROM jobs
WHERE idempotency_key = ?
`
var j models.Job
var payloadStr, resultStr, errorMsg string
var idemKey sql.NullString
err := d.QueryRow(query, key).Scan(
&j.ID, &j.Type, &payloadStr, &j.Status, &j.Progress, &resultStr,
&errorMsg, &j.Attempts, &j.MaxAttempts, &idemKey, &j.RunAt, &j.CreatedAt, &j.UpdatedAt,
)
if err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(payloadStr), &j.Payload)
_ = json.Unmarshal([]byte(resultStr), &j.Result)
j.ErrorMessage = errorMsg
if idemKey.Valid {
j.IdempotencyKey = idemKey.String
}
return &j, nil
}
func (d *DB) ListJobs(filter models.JobFilter) ([]models.Job, error) {
if filter.Limit <= 0 {
filter.Limit = 50
}
var conditions []string
var args []interface{}
if filter.Type != "" {
conditions = append(conditions, "type = ?")
args = append(args, filter.Type)
}
if filter.Status != "" {
conditions = append(conditions, "status = ?")
args = append(args, filter.Status)
}
whereClause := ""
if len(conditions) > 0 {
whereClause = "WHERE " + strings.Join(conditions, " AND ")
}
query := fmt.Sprintf(`
SELECT id, type, payload, status, progress, result, error_message, attempts, max_attempts, idempotency_key, run_at, created_at, updated_at
FROM jobs
%s
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`, whereClause)
args = append(args, filter.Limit, filter.Offset)
rows, err := d.Query(query, args...)
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
var jobs []models.Job
for rows.Next() {
var j models.Job
var payloadStr, resultStr, errorMsg string
var idemKey sql.NullString
err := rows.Scan(
&j.ID, &j.Type, &payloadStr, &j.Status, &j.Progress, &resultStr,
&errorMsg, &j.Attempts, &j.MaxAttempts, &idemKey, &j.RunAt, &j.CreatedAt, &j.UpdatedAt,
)
if err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(payloadStr), &j.Payload)
_ = json.Unmarshal([]byte(resultStr), &j.Result)
j.ErrorMessage = errorMsg
if idemKey.Valid {
j.IdempotencyKey = idemKey.String
}
jobs = append(jobs, j)
}
return jobs, nil
}
func (d *DB) UpdateJob(job models.Job) error {
job.UpdatedAt = time.Now().UTC()
payloadBytes, _ := json.Marshal(job.Payload)
resultBytes, _ := json.Marshal(job.Result)
if len(resultBytes) == 0 {
resultBytes = []byte("{}")
}
query := `
UPDATE jobs SET
status = ?, progress = ?, result = ?, error_message = ?,
attempts = ?, run_at = ?, updated_at = ?
WHERE id = ?
`
_, err := d.Exec(
query,
job.Status, job.Progress, string(resultBytes), job.ErrorMessage,
job.Attempts, job.RunAt, job.UpdatedAt, job.ID,
)
_ = payloadBytes
return err
}
func (d *DB) ClaimNextJob(types []string) (*models.Job, error) {
now := time.Now().UTC()
var typeFilter string
var args []interface{}
args = append(args, now)
if len(types) > 0 {
placeholders := make([]string, len(types))
for i, t := range types {
placeholders[i] = "?"
args = append(args, t)
}
typeFilter = fmt.Sprintf("AND type IN (%s)", strings.Join(placeholders, ","))
}
query := fmt.Sprintf(`
SELECT id, type, payload, status, progress, result, error_message, attempts, max_attempts, idempotency_key, run_at, created_at, updated_at
FROM jobs
WHERE status = 'pending' AND run_at <= ? %s
ORDER BY run_at ASC, created_at ASC
LIMIT 1
`, typeFilter)
var j models.Job
var payloadStr, resultStr, errorMsg string
var idemKey sql.NullString
err := d.QueryRow(query, args...).Scan(
&j.ID, &j.Type, &payloadStr, &j.Status, &j.Progress, &resultStr,
&errorMsg, &j.Attempts, &j.MaxAttempts, &idemKey, &j.RunAt, &j.CreatedAt, &j.UpdatedAt,
)
if err != nil {
return nil, err
}
// Atomically mark as running
updateRes, err := d.Exec("UPDATE jobs SET status = 'running', updated_at = ? WHERE id = ? AND status = 'pending'", now, j.ID)
if err != nil {
return nil, err
}
rowsAffected, _ := updateRes.RowsAffected()
if rowsAffected == 0 {
return nil, sql.ErrNoRows
}
_ = json.Unmarshal([]byte(payloadStr), &j.Payload)
_ = json.Unmarshal([]byte(resultStr), &j.Result)
j.ErrorMessage = errorMsg
j.Status = "running"
if idemKey.Valid {
j.IdempotencyKey = idemKey.String
}
return &j, nil
}
func (d *DB) ResetRunningJobsToPending() error {
now := time.Now().UTC()
_, err := d.Exec("UPDATE jobs SET status = 'pending', updated_at = ? WHERE status = 'running'", now)
return err
}
func (d *DB) CancelJob(id string) error {
now := time.Now().UTC()
_, err := d.Exec("UPDATE jobs SET status = 'cancelled', updated_at = ? WHERE id = ? AND status IN ('pending', 'running')", now, id)
return err
}
// GetSharedEntry strictly retrieves an entry by share_uuid only (prevents data leak of unshared articles)
func (d *DB) GetSharedEntry(shareUUID string) (*models.FeedEntry, error) {
if shareUUID == "" {
return nil, sql.ErrNoRows
}
var e models.FeedEntry
var fTitle, fSiteURL, author, rawContent, metaStr, sUUID sql.NullString
query := `
SELECT
e.id, e.feed_id, f.title as feed_title, COALESCE(f.site_url, '') as feed_site_url,
e.guid, e.url, e.title, e.author, e.content, e.raw_content,
e.published_at, e.is_read, e.is_starred, e.share_uuid, e.metadata, e.created_at
FROM feed_entries e
JOIN feeds f ON f.id = e.feed_id
WHERE e.share_uuid IS NOT NULL AND e.share_uuid != '' AND e.share_uuid = ?
`
err := d.QueryRow(query, shareUUID).Scan(
&e.ID, &e.FeedID, &fTitle, &fSiteURL,
&e.GUID, &e.URL, &e.Title, &author, &e.Content, &rawContent,
&e.PublishedAt, &e.IsRead, &e.IsStarred, &sUUID, &metaStr, &e.CreatedAt,
)
if err != nil {
return nil, err
}
if fTitle.Valid {
e.FeedTitle = fTitle.String
}
if fSiteURL.Valid {
e.FeedSiteURL = fSiteURL.String
}
if author.Valid {
e.Author = author.String
}
if sUUID.Valid {
e.ShareUUID = &sUUID.String
}
variants, _ := d.ListVariantsForEntry(e.ID)
if len(variants) > 0 {
e.Variants = make(map[string]models.EntryVariant)
for _, v := range variants {
e.Variants[v.VariantKey] = v
}
}
return &e, nil
}