loadsmith_thunderstore/index/
sqlite.rs1use std::{
2 path::Path,
3 sync::Arc,
4 time::{Duration, Instant},
5};
6
7use chrono::{DateTime, Utc};
8use futures::{TryStreamExt, pin_mut};
9use loadsmith_core::{PackageId, PackageRef, Version};
10use parking_lot::Mutex;
11use rusqlite::OptionalExtension;
12use thunderstore::VersionIdent;
13use tracing::{debug, trace};
14
15use crate::Result;
16
17#[derive(Debug, Clone)]
33pub struct SqliteIndex {
34 client: thunderstore::Client,
35 db: Arc<Mutex<rusqlite::Connection>>,
36}
37
38#[derive(Debug, Clone)]
52pub struct CommunityMetadata {
53 pub community: String,
54 pub last_updated: Option<DateTime<Utc>>,
55}
56
57impl SqliteIndex {
58 pub fn connect(client: thunderstore::Client, db: rusqlite::Connection) -> Result<Self> {
71 db.execute_batch(include_str!("queries/create_schema.sql"))?;
72 Ok(Self {
73 client,
74 db: Arc::new(Mutex::new(db)),
75 })
76 }
77
78 pub fn open(client: thunderstore::Client, db_path: impl AsRef<Path>) -> Result<Self> {
91 let db = rusqlite::Connection::open(db_path)?;
92 db.pragma_update(None, "journal_mode", "WAL")?;
93 Self::connect(client, db)
94 }
95
96 pub fn community_metadata(
112 &self,
113 community: impl AsRef<str>,
114 ) -> Result<Option<CommunityMetadata>> {
115 let db = self.db.lock();
116
117 let metadata = db
118 .prepare("select * from community_meta where community = ?")?
119 .query_row(rusqlite::params![community.as_ref()], |row| {
120 let community: String = row.get(0)?;
121 let last_updated: Option<String> = row.get(1)?;
122 let last_updated = last_updated
123 .map(|s| DateTime::parse_from_rfc3339(&s).map(|dt| dt.with_timezone(&Utc)))
124 .transpose()
125 .unwrap();
126
127 Ok(CommunityMetadata {
128 community,
129 last_updated,
130 })
131 })
132 .optional()?;
133
134 Ok(metadata)
135 }
136
137 fn set_community_metadata(
138 &self,
139 community: impl AsRef<str>,
140 last_updated: Option<DateTime<Utc>>,
141 ) -> Result<()> {
142 let db = self.db.lock();
143
144 db.execute(
145 "insert or replace into community_meta (community, last_updated) values (?1, ?2)",
146 rusqlite::params![community.as_ref(), last_updated.map(|dt| dt.to_rfc3339())],
147 )?;
148
149 Ok(())
150 }
151
152 pub async fn update(&self, community: impl AsRef<str>) -> Result<()> {
171 let community = community.as_ref();
172
173 debug!(community, "fetching package index from thunderstore");
174
175 let mut start = Instant::now();
176 let mut time_waiting = Duration::ZERO;
177
178 let stream = self.client.stream_package_index_v1(community).await?;
179 pin_mut!(stream);
180
181 time_waiting += start.elapsed();
182 start = Instant::now();
183
184 while let Some(batch) = stream.try_next().await? {
185 trace!(len = batch.len(), waited = ?start.elapsed(), "received package batch");
186
187 time_waiting += start.elapsed();
188 start = Instant::now();
189
190 let mut db = self.db.lock();
191 let tx = db.transaction()?;
192
193 for package in batch {
194 tx.execute(
195 "insert or replace into packages (community, package_id, package) values (?1, ?2, ?3)",
196 rusqlite::params![community, package.ident.as_str(), serde_json::to_string(&package)?],
197 )?;
198 }
199
200 tx.commit()?;
201
202 trace!(
203 duration = ?start.elapsed(),
204 "inserted package batch into database"
205 );
206
207 start = Instant::now();
208 }
209
210 self.set_community_metadata(community, Some(Utc::now()))?;
211
212 debug!(
213 total_time_waiting = ?time_waiting,
214 "finished fetching package index from thunderstore"
215 );
216
217 Ok(())
218 }
219
220 pub fn version_info(
234 &self,
235 id: &PackageId,
236 ) -> Result<Option<Vec<loadsmith_registry::VersionInfo>>> {
237 let db = self.db.lock();
238
239 let versions = db
240 .prepare(include_str!("queries/select_version_info.sql"))?
241 .query_map(rusqlite::params![id.as_str()], |row| {
242 let version: Version = row.get::<_, String>(0)?.parse().unwrap();
243
244 Ok(loadsmith_registry::VersionInfo { version })
245 })?
246 .collect::<rusqlite::Result<Vec<_>>>()?;
247
248 if versions.is_empty() {
249 Ok(None)
250 } else {
251 Ok(Some(versions))
252 }
253 }
254
255 pub fn resolve(
275 &self,
276 ref_: &PackageRef,
277 ) -> Result<Option<loadsmith_registry::ResolvedVersion>> {
278 let db = self.db.lock();
279
280 let resolved = db
281 .prepare(include_str!("queries/select_resolved.sql"))?
282 .query_one(
283 rusqlite::params![ref_.id().as_str(), ref_.version().to_string()],
284 |row| {
285 let url = row.get::<_, String>(0)?;
286 let size = row.get::<_, i64>(1)?;
287
288 let deps_json = row.get::<_, String>(2)?;
289 let deps: Vec<VersionIdent> = serde_json::from_str(&deps_json).unwrap();
290
291 let categories_json = row.get::<_, String>(3)?;
292 let categories: Vec<String> = serde_json::from_str(&categories_json).unwrap();
293 let is_modpack = categories.contains(&super::MODPACK_CATEGORY.to_string());
294 let deps = super::dependencies_from_idents(&deps, is_modpack);
295
296 let url = loadsmith_core::FileUrl::try_from_url(&url).unwrap();
297
298 Ok(loadsmith_registry::ResolvedVersion {
299 url,
300 deps,
301 size: Some(size as u64),
302 checksum: None,
303 })
304 },
305 )?;
306
307 Ok(Some(resolved))
308 }
309
310 pub fn search_packages(&self, query: &str, community: Option<&str>) -> Result<Vec<PackageId>> {
326 let db = self.db.lock();
327
328 let packages = db
329 .prepare(include_str!("queries/search_packages.sql"))?
330 .query_map(
331 rusqlite::params![format!("%{}%", query), community],
332 |row| {
333 let id: String = row.get(0)?;
334 Ok(PackageId::new(&id))
335 },
336 )?
337 .collect::<rusqlite::Result<Vec<_>>>()?;
338
339 Ok(packages)
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[tokio::test]
348 #[ignore]
349 async fn fetch_and_resolve_version() {
350 const COMMUNITY: &str = "rounds";
351
352 let client = thunderstore::Client::new();
353 let db = rusqlite::Connection::open_in_memory().unwrap();
354 let index = SqliteIndex::connect(client, db).unwrap();
355
356 assert!(index.community_metadata(COMMUNITY).unwrap().is_none());
357
358 index.update("rounds").await.unwrap();
359
360 assert!(
361 index
362 .community_metadata(COMMUNITY)
363 .unwrap()
364 .and_then(|c| c.last_updated)
365 .is_some()
366 );
367
368 let rounds_with_friends = PackageId::new("olavim-RoundsWithFriends");
369
370 let versions = index.version_info(&rounds_with_friends).unwrap();
371 assert!(versions.is_some());
372 println!("{versions:#?}");
373
374 let resolved = index
375 .resolve(&PackageRef::new(
376 rounds_with_friends,
377 versions.unwrap().pop().unwrap().version,
378 ))
379 .unwrap();
380 assert!(resolved.is_some());
381 println!("{resolved:#?}");
382
383 let versions = index.version_info(&PackageId::new("fake_package")).unwrap();
384
385 assert!(versions.is_none());
386 }
387}