feat: persist download progress in SQLite with queryable API#14
Conversation
- Add drizzle-orm schema for downloads table with status tracking - Create connection module with migration support (bun:sqlite) - Implement DownloadsRepository with CRUD operations - Add GET /downloads and GET /downloads/:id API endpoints - Wire services into getApp with optional services parameter
- Emit headers/progress/completed events from PeerConnector.downloadFile() - Store expectedBytes (null on missing/invalid Content-Length) and flag mismatches - Split blackhole watcher into watcher (watch/scan/stability) and DownloadsService - DownloadsService owns the download lifecycle and persists row transitions - Wire DB open, stale-row reconciliation, service, and shutdown into index.ts
Reject any release.filename that is not already a plain basename before building destPath/partPath, so a malicious peer cannot escape completedPath with a traversal or absolute path. The throw happens inside the post-create guard, so an unsafe filename marks the download row failed.
|
@greptileai review |
|
| Filename | Overview |
|---|---|
| apps/backend/src/modules/downloads/downloads.service.ts | New DownloadsService split from blackhole.ts; isSafeName guard correctly precedes row creation; status lifecycle (downloading → completed → import_queued) is well-handled; onProgress callbacks properly check the download variable guard. |
| apps/backend/src/lib/servers/peer.ts | Adds PeerDownloadProgressEvent emission; post-rename completed callback is wrapped in its own try/catch (previous review concern addressed); headers, progress, and completed events are correctly sequenced. |
| apps/backend/src/modules/downloads/downloads.repository.ts | Clean synchronous Drizzle repository; reconcileStaleDownloads correctly materialises stale rows before iterating; markFailed preserves completedAt timestamp set by markCompleted. |
| apps/backend/src/database/connection.ts | Opens WAL-mode SQLite with foreign keys enabled, runs Drizzle migrations at startup; MIGRATIONS_FOLDER resolved relative to import.meta.dir so it's correct under bun:test and direct invocation. |
| apps/backend/src/index.ts | Correctly wires DB, repository, reconcileStaleDownloads, and shutdown handlers; SIGINT/SIGTERM close the DB without draining in-progress downloads, leaving .part files on disk (reconciled on next start — accepted limitation). |
| apps/backend/src/database/schema.ts | Well-defined downloads table with status CHECK constraint, expected_bytes_source CHECK, and indexes on status/updated_at; boolean integer mode and nullable fields correctly modelled. |
| apps/backend/src/modules/downloads/blackhole.watcher.ts | Clean extraction of watch/scan/stability logic from old blackhole.ts; processing Set deduplication is correct; watcher registered before scan to avoid missed events during startup. |
| apps/backend/src/app.ts | getApp gains optional services param (backward compatible); downloads router only mounted when repository is present; all existing routes unaffected. |
| apps/backend/src/tests/downloads-service.test.ts | Good coverage: happy path, metadata failure, path traversal rejection (no row created), and download failure with row marked failed; all assertions are meaningful. |
Reviews (3): Last reviewed commit: "fix: handle download tracking edge cases" | Re-trigger Greptile
Summary
Persists each peer file download's lifecycle in SQLite so active, completed, failed, and import-queued downloads are queryable without scanning logs or folders. Captures real byte progress through
PeerConnector.downloadFile()progress events, splits the blackhole watcher from the download workflow, and exposes a read-only downloads API.Implements the plan at
ai_docs/plans/2026-06-04-download-progress-sqlite/.What's included
Phase 1 — Queryable downloads store
downloadstable (bun:sqlite), withstatus/updated_atindexes and CHECK constraints/config/database.sqliteand applying committed migrations at startup viamigrate()(folder resolved fromimport.meta.dir)DownloadsRepository(create-after-metadata, newest-first list, get, status transitions, stale-row reconciliation)GET /downloadsandGET /downloads/:idbehind the existingrequireApiKeymiddlewaregetAppgains an optionalservicesparam (backward compatible)Phase 2 — Progress capture + runtime wiring
PeerConnector.downloadFile()emitsheaders/progress/completedevents; a missing/invalidContent-LengthstoresexpectedBytes: null, and a mismatch againstreleaseSizeis flaggedcontentLength > 0toexpectedBytes != nullblackhole.tsintoblackhole.watcher.ts(watch/scan/stability) andDownloadsService(download lifecycle + row persistence). Any post-create failure marks the rowfailedindex.tsopens the DB, reconciles staledownloadingrows on restart (using.partsize when present), passes the repository into the app, and closes the DB on SIGINT/SIGTERMSecurity fix (raised in review)
release.filenameto a plain basename before buildingdestPath/partPath, rejecting traversal/absolute paths so a malicious peer cannot escapecompletedPath. Unsafe names mark the rowfailed. (This hardens logic that pre-existed onmain.)Out of scope
No
/api/v3/queueshim, no download resume (stale rows are failed, not resumed — issue #13), no concurrency semaphore, no frontend, no runtime schema editing.Testing
bun test apps packages— 84 pass, 0 failbun run eslint .— cleanbunx --bun tsc --noEmit— cleane2e suite (
mise run test:e2e) is a separate CI job requiring the docker-compose stack and was not run locally.