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.
This commit is contained in:
+338
-13
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user