# Fusion Backend Design ## 1. Goals - Keep the backend small, easy to self-host, and easy to reason about. - Prefer explicit SQL over ORM abstractions. - Preserve user content via bookmark snapshots. ## 2. Runtime architecture Fusion backend runs two long-lived services in one process: 1. HTTP API server (Gin) 2. Feed pull worker (periodic and manual refresh) Both services share the same SQLite store. ## 3. Tech stack | Area | Choice | | -------------- | ------------------------------------- | | Language | Go 1.25 | | HTTP framework | Gin | | Database | SQLite (`modernc.org/sqlite`) | | Migrations | Embedded SQL files | | Feed parser | `github.com/mmcdole/gofeed` | | Feed discovery | `github.com/0x2E/feedfinder` | | Auth | Password session auth + optional OIDC | ## 4. Module layout ```text backend/ ├── cmd/fusion/main.go # process startup and lifecycle ├── internal/ │ ├── config/ # env parsing │ ├── handler/ # HTTP handlers + middleware │ ├── store/ # SQL persistence + migrations │ ├── pull/ # fetch/parse/schedule/backoff │ ├── pullpolicy/ # pure pull scheduling policy │ ├── auth/ # password + OIDC helpers │ ├── model/ # API/storage models │ └── pkg/httpc/ # HTTP client + SSRF guards ``` ## 5. Database schema (current) Source of truth: - `backend/internal/store/migrations/001_initial.sql` - `backend/internal/store/migrations/002_feed_fetch_state.sql` Legacy compatibility: when an old pre-`schema_migrations` database is detected, backend first creates a timestamped `.bak` backup, builds a fresh temporary database with the current schema, imports legacy `groups/feeds/items` data, atomically swaps files, then records baseline version `1`. ### groups - `id`, `name`, `created_at`, `updated_at` - `name` is unique - Default group: `id=1`, `name='Default'` ### feeds - Core: `id`, `group_id`, `name`, `link`, `site_url` - Runtime control: `suspended` - Network: `proxy` - Meta: `created_at`, `updated_at` - Unique: `link` ### feed_fetch_state - Per-feed runtime fetch state keyed by `feed_id` - Conditional request metadata: `etag`, `last_modified` - HTTP cache hints: `cache_control`, `expires_at`, `retry_after_until` - Scheduler state: `last_checked_at`, `next_check_at` - Outcome state: `last_http_status`, `last_success_at`, `last_error_at`, `last_error`, `consecutive_failures` - `feed_id` references `feeds(id)` with `ON DELETE CASCADE` - API shape: runtime fields are exposed under `feed.fetch_state.*`. ### Feed runtime state map ```mermaid erDiagram FEEDS ||--|| FEED_FETCH_STATE : has FEEDS { int id PK int group_id string name string link bool suspended } FEED_FETCH_STATE { int feed_id PK,FK string etag string last_modified string cache_control int expires_at int retry_after_until int last_checked_at int next_check_at int last_http_status int last_success_at int last_error_at string last_error int consecutive_failures } ``` ### items - `id`, `feed_id`, `guid`, `title`, `link`, `content`, `pub_date`, `unread`, `created_at` - Unique: `(feed_id, guid)` - Indexes: unread partial index, `pub_date` index, `(feed_id, unread)` index ### items full-text search - Virtual table: `items_fts` (FTS5 on `title`, `content`) - Triggers keep `items_fts` synchronized with `items` ### bookmarks - Snapshot table: `item_id`, `link`, `title`, `content`, `pub_date`, `feed_name`, `created_at` - `link` is unique - `item_id` is nullable to preserve snapshots after source item deletion ## 6. Data integrity and cascade strategy - Cascade rules are explicit in store transactions for group/feed/item/bookmark lifecycles: - Delete group: move feeds to group `1`, then delete group. - Delete feed: set matching bookmarks `item_id=NULL`, delete items, then delete feed. - `feed_fetch_state` uses a direct foreign key to `feeds(id)` for guaranteed runtime-state cleanup. This keeps behavior explicit and avoids hidden DB-level side effects. ## 7. API surface (high level) - Sessions: login/logout - OIDC: enabled status, login URL, callback - Groups: list/get/create/update/delete - Feeds: list/get/create/update/delete/validate/batch create/refresh - Items: list/get/mark read/mark unread - Search: feed + item search - Bookmarks: list/get/create/delete Detailed contract: `docs/openapi.yaml`. ### Breaking API change (feed runtime fields) - Feed runtime pull fields moved from top-level `feed.*` to nested `feed.fetch_state.*`. - Removed top-level fields: `last_build`, `last_failure_at`, `failure`, `failures`. - Clients that still decode old fields must update to `fetch_state` before upgrading. ## 8. Feed pull strategy ### Scheduler - Pull interval: `FUSION_PULL_INTERVAL` (default `1800s`) - Concurrency limit: `FUSION_PULL_CONCURRENCY` (default `10`) - Request timeout: `FUSION_PULL_TIMEOUT` (default `30s`) - Global max scheduling delay: `FUSION_PULL_MAX_BACKOFF` (default `48h`) ### Next-check bound - `next_check_at` is always computed from branch delay, then capped by `FUSION_PULL_MAX_BACKOFF`. - Success branch (`200/304`): - `success_delay = max(interval, retry_after_delay, freshness_delay)` - `freshness_delay` comes from `Cache-Control max-age` and/or `Expires`. - Failure branch: - `failure_delay = max(interval, retry_after_delay, backoff_delay)` - Final rule (both branches): - `next_check_at = now + min(branch_delay, pull_max_backoff)` - `suspended` remains an explicit skip switch and is not affected by this cap. ### Skip policy Periodic pull skips feed when: - Feed is suspended, or - Current time is before `retry_after_until`, or - Current time is before `next_check_at`. ### Backoff - Formula: `interval * (1.8 ^ failures)` - `failures` here is the updated `consecutive_failures` value after the current failure is recorded. - `backoff_delay` is capped by `FUSION_PULL_MAX_BACKOFF`. - Failure branch computes `next_check_at` from the strictest delay source: - pull interval - `Retry-After` - exponential backoff - Final `next_check_at` (success/failure) uses the same `FUSION_PULL_MAX_BACKOFF` cap. - Failure counter and `next_check_at` are updated in one DB transaction to avoid stale-counter races during concurrent refresh failures. - Failure updates do not overwrite `cache_control` / `expires_at`; these freshness fields are only refreshed on successful `200/304` checks. ### Conditional requests - Request headers: `If-None-Match` (from `etag`) and `If-Modified-Since` (from `last_modified`) - Response handling: - `200`: parse items and refresh validators/cache metadata - `304`: treat as successful check without item parsing - If a `304` response omits validators or cache headers, previous stored values are kept. ### Pull decision flow ```mermaid flowchart TD A[Start periodic pull] --> B{feed.suspended?} B -- yes --> Z[Skip] B -- no --> C{now < retry_after_until?} C -- yes --> Z C -- no --> D{now < next_check_at?} D -- yes --> Z D -- no --> E[Send GET with If-None-Match/If-Modified-Since] E --> F{HTTP status} F -- 200 --> G[Parse items and upsert] G --> H[Update success state] F -- 304 --> H F -- other/error --> I[Update failure state in one transaction] H --> J[Reset consecutive_failures to 0] J --> K[Compute success delay and cap to now+pull_max_backoff] I --> L[Increment consecutive_failures] L --> M[Compute failure delay and cap to now+pull_max_backoff] ``` ### Manual refresh - `POST /feeds/refresh`: refresh all non-suspended feeds - `POST /feeds/:id/refresh`: refresh one feed - Manual refresh bypasses periodic skip logic ## 9. Security model - Password auth with bcrypt hash computed at startup - Login attempt rate limit (`FUSION_LOGIN_*`) - Session cookie: `HttpOnly`, `SameSite=Lax`, `Secure` on HTTPS - Optional OIDC SSO (`FUSION_OIDC_*`) - URL validation + private-network blocking by default for feed fetches - CORS allowlist via `FUSION_CORS_ALLOWED_ORIGINS` - Trusted proxy list via `FUSION_TRUSTED_PROXIES` ## 10. Observability and logs - Structured logging via `log/slog` - Configurable log level (`FUSION_LOG_LEVEL`) - Configurable output format (`FUSION_LOG_FORMAT`: `auto`, `text`, `json`) ## 11. Release verification checklist - Backend tests: `cd backend && go test ./...` - Build check: `cd backend && go build -o /dev/null ./cmd/fusion` - Migration sanity check: start app on empty DB and ensure schema bootstraps correctly - API smoke tests: login, create feed, manual refresh, search, bookmark create/delete