refactor(core): overhaul state transition architecture, persistent job queue, and API layer
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

- 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.
This commit is contained in:
2026-09-01 00:41:05 -07:00
parent 271a542196
commit ebcaa5d72a
38 changed files with 3748 additions and 1202 deletions
+338 -13
View File
@@ -25,7 +25,8 @@ func InitDB(dbPath string) (*DB, error) {
return nil, fmt.Errorf("failed to create db directory: %w", err)
}
db, err := sql.Open("sqlite", dbPath)
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)
}
@@ -34,18 +35,6 @@ func InitDB(dbPath string) (*DB, error) {
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Hour)
pragmas := []string{
"PRAGMA journal_mode = WAL;",
"PRAGMA busy_timeout = 5000;",
"PRAGMA synchronous = NORMAL;",
"PRAGMA foreign_keys = ON;",
}
for _, p := range pragmas {
if _, err := db.Exec(p); err != nil {
return nil, fmt.Errorf("failed to set pragma %s: %w", p, err)
}
}
database := &DB{DB: db}
if err := database.migrate(); err != nil {
return nil, fmt.Errorf("failed to run migrations: %w", err)
@@ -153,6 +142,24 @@ func (d *DB) migrate() error {
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
@@ -848,6 +855,15 @@ func (d *DB) UpdateEntryStarred(id string, isStarred bool) error {
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 {
@@ -1291,3 +1307,312 @@ 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
}