0
2
server: An index of grain's own, its records put by URI and CID
Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.
crates/grain/src/keys.rs+40−0
| @@ -19,6 +19,37 @@ pub fn now_secs() -> u64 { | ||
now_micros() / 1_000_000 | ||
} | ||
| ||
/// `micros` since the epoch as RFC 3339 in UTC to the millisecond, | ||
/// `2026-10-31T12:00:00.000Z`: as JavaScript's `toISOString` writes it, so | ||
/// such times sort as text beside the ones hatk wrote. | ||
pub fn iso(micros: u64) -> String { | ||
let secs = micros / 1_000_000; | ||
let millis = (micros / 1_000) % 1_000; | ||
let days = (secs / 86_400) as i64; | ||
let rem = secs % 86_400; | ||
// Howard Hinnant's days_from_civil, inverted. | ||
let z = days + 719_468; | ||
let era = z.div_euclid(146_097); | ||
let doe = z.rem_euclid(146_097); | ||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; | ||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); | ||
let mp = (5 * doy + 2) / 153; | ||
let day = doy - (153 * mp + 2) / 5 + 1; | ||
let month = if mp < 10 { mp + 3 } else { mp - 9 }; | ||
let year = yoe + era * 400 + i64::from(month <= 2); | ||
format!( | ||
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{millis:03}Z", | ||
rem / 3_600, | ||
rem % 3_600 / 60, | ||
rem % 60 | ||
) | ||
} | ||
| ||
/// The time now, as [`iso`] writes it. | ||
pub fn now_iso() -> String { | ||
iso(now_micros()) | ||
} | ||
| ||
/// A TID that sorts after every TID this process made before it. | ||
pub fn tid() -> String { | ||
let now = now_micros(); | ||
| @@ -57,4 +88,13 @@ mod tests { | ||
assert_eq!(tid_micros("nope"), None); | ||
assert!(now_secs().abs_diff(now_micros() / 1_000_000) <= 1); | ||
} | ||
| ||
#[test] | ||
fn times_are_written_as_javascript_writes_them() { | ||
assert_eq!(iso(0), "1970-01-01T00:00:00.000Z"); | ||
assert_eq!(iso(951_782_400_000_000), "2000-02-29T00:00:00.000Z"); | ||
assert_eq!(iso(1_793_448_000_123_456), "2026-10-31T12:00:00.123Z"); | ||
assert_eq!(iso(4_102_444_799_999_000), "2099-12-31T23:59:59.999Z"); | ||
assert_eq!(now_iso().len(), 24); | ||
} | ||
} | ||
crates/grain/src/lexicon.rsnew+1230−0
1230 lines changed: not loaded until asked for.
crates/grain/src/lib.rs+1−0
| @@ -11,6 +11,7 @@ pub mod drisl; | ||
pub mod env; | ||
pub mod identity; | ||
pub mod keys; | ||
pub mod lexicon; | ||
pub mod oauth; | ||
pub mod session; | ||
pub mod store; | ||
crates/grain/Cargo.toml+1−0
| @@ -22,6 +22,7 @@ serde_json.workspace = true | ||
sha2.workspace = true | ||
tokio.workspace = true | ||
tracing.workspace = true | ||
unicode-segmentation.workspace = true | ||
| ||
[dev-dependencies] | ||
axum.workspace = true | ||
crates/grain-serve/src/index/mod.rsnew+398−0
| @@ -0,0 +1,398 @@ | ||
//! The index: grain's records as the network has them, the accounts it | ||
//! tracks, and what grain itself keeps (takedowns, labels, reports, mutes, | ||
//! preferences and the rest). One SQLite file in WAL mode, written through | ||
//! one connection, so writes take turns. [`schema`] says what is in it. | ||
//! | ||
//! A record is put by its URI and CID: the same record delivered twice, by | ||
//! `social.grain.sync.indexRecords` reading it back and then by Jetstream, | ||
//! is applied once, and one deleted from its PDS is deleted here. Each is | ||
//! checked against its lexicon first ([`grain::lexicon`]), and refused if | ||
//! it does not fit. | ||
| ||
pub mod records; | ||
pub mod schema; | ||
| ||
use anyhow::{Context, bail}; | ||
use grain::lexicon::{self, Invalid, Lexicons}; | ||
use records::{Any, Record}; | ||
use rusqlite::{Connection, OptionalExtension, ToSql, params}; | ||
use serde_json::Value; | ||
use std::collections::HashSet; | ||
use std::path::Path; | ||
use std::sync::Mutex; | ||
| ||
pub struct Index { | ||
db: Mutex<Connection>, | ||
lexicons: Lexicons, | ||
/// The accounts in `repo`, kept here too: the stream asks of every | ||
/// Bluesky post on the network whether its author is one. | ||
tracked: Mutex<HashSet<String>>, | ||
} | ||
| ||
/// What putting a record did. | ||
#[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
pub enum Applied { | ||
/// The index had no record at its URI. | ||
Created, | ||
/// It had another version of it, which this replaced. | ||
Updated, | ||
/// It had this version already, by its CID. | ||
Unchanged, | ||
} | ||
| ||
/// Why a record was not put. | ||
#[derive(Debug)] | ||
pub enum PutError { | ||
/// It is not one the index keeps, or does not fit its lexicon. | ||
Refused(Invalid), | ||
Db(rusqlite::Error), | ||
} | ||
| ||
impl std::fmt::Display for PutError { | ||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
match self { | ||
PutError::Refused(why) => write!(f, "refused: {why}"), | ||
PutError::Db(e) => write!(f, "index: {e}"), | ||
} | ||
} | ||
} | ||
| ||
impl std::error::Error for PutError {} | ||
| ||
impl From<rusqlite::Error> for PutError { | ||
fn from(e: rusqlite::Error) -> PutError { | ||
PutError::Db(e) | ||
} | ||
} | ||
| ||
fn refused(path: &str, message: impl Into<String>) -> PutError { | ||
PutError::Refused(Invalid { | ||
path: path.into(), | ||
message: message.into(), | ||
}) | ||
} | ||
| ||
/// A record as the index holds it. | ||
#[derive(Debug, Clone, PartialEq)] | ||
pub struct Row<T> { | ||
pub uri: String, | ||
pub cid: String, | ||
pub did: String, | ||
pub indexed_at: String, | ||
pub record: T, | ||
} | ||
| ||
/// A label, as `com.atproto.label.defs#label` has one, less its version | ||
/// and signature. | ||
#[derive(Debug, Clone, PartialEq)] | ||
pub struct Label { | ||
pub src: String, | ||
/// The subject: a record's AT-URI, or an account's bare DID. | ||
pub uri: String, | ||
pub val: String, | ||
pub neg: bool, | ||
pub cts: String, | ||
pub exp: Option<String>, | ||
} | ||
| ||
/// The author, collection and key of a record's AT-URI. | ||
pub fn record_uri(uri: &str) -> Option<(&str, &str, &str)> { | ||
let mut parts = uri.strip_prefix("at://")?.split('/'); | ||
let (did, collection, rkey) = (parts.next()?, parts.next()?, parts.next()?); | ||
let valid = lexicon::valid_did(did) | ||
&& lexicon::valid_nsid(collection) | ||
&& lexicon::valid_record_key(rkey); | ||
(valid && parts.next().is_none()).then_some((did, collection, rkey)) | ||
} | ||
| ||
/// Brings `db`'s tables to the last of `steps`, running each it has not | ||
/// run, each in a transaction of its own. A database further on than | ||
/// `steps` is from a newer grain, and is refused rather than read. | ||
fn migrate(db: &mut Connection, steps: &[&str]) -> anyhow::Result<()> { | ||
let at: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?; | ||
let at = at as usize; | ||
if at > steps.len() { | ||
bail!( | ||
"the index is at version {at}, past this grain's {}", | ||
steps.len() | ||
); | ||
} | ||
for (done, step) in steps.iter().enumerate().skip(at) { | ||
let tx = db.transaction()?; | ||
tx.execute_batch(step) | ||
.with_context(|| format!("migrating the index to version {}", done + 1))?; | ||
tx.pragma_update(None, "user_version", done as i64 + 1)?; | ||
tx.commit()?; | ||
} | ||
Ok(()) | ||
} | ||
| ||
impl Index { | ||
/// The index at `path`, made or migrated to this version's tables. | ||
pub fn open(path: &Path) -> anyhow::Result<Index> { | ||
let db = Connection::open(path).with_context(|| format!("opening {}", path.display()))?; | ||
Index::with(db, schema::MIGRATIONS) | ||
} | ||
| ||
/// An index held in memory, for tests. | ||
pub fn memory() -> anyhow::Result<Index> { | ||
Index::with(Connection::open_in_memory()?, schema::MIGRATIONS) | ||
} | ||
| ||
fn with(mut db: Connection, steps: &[&str]) -> anyhow::Result<Index> { | ||
db.busy_timeout(std::time::Duration::from_secs(5))?; | ||
db.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")?; | ||
migrate(&mut db, steps)?; | ||
let tracked = { | ||
let mut stmt = db.prepare("SELECT did FROM repo")?; | ||
let dids = stmt.query_map([], |r| r.get(0))?; | ||
dids.collect::<rusqlite::Result<HashSet<String>>>()? | ||
}; | ||
Ok(Index { | ||
db: Mutex::new(db), | ||
lexicons: Lexicons::grain(), | ||
tracked: Mutex::new(tracked), | ||
}) | ||
} | ||
| ||
/// Runs `f` on the index's connection, as no other write runs. | ||
pub fn read<T>(&self, f: impl FnOnce(&Connection) -> T) -> T { | ||
f(&self.db.lock().unwrap()) | ||
} | ||
| ||
/// Puts the record at `uri`, of `cid`, as it is now. | ||
pub fn put(&self, uri: &str, cid: &str, record: &Value) -> Result<Applied, PutError> { | ||
self.put_at(uri, cid, record, &grain::keys::now_iso()) | ||
} | ||
| ||
/// [`Index::put`], as though it were first indexed at `indexed_at`. | ||
/// A record already held keeps when it was. | ||
pub fn put_at( | ||
&self, | ||
uri: &str, | ||
cid: &str, | ||
record: &Value, | ||
indexed_at: &str, | ||
) -> Result<Applied, PutError> { | ||
let (did, collection, _) = | ||
record_uri(uri).ok_or_else(|| refused("", "not a record URI"))?; | ||
let Some(table) = records::table_of(collection) else { | ||
return Err(refused("", format!("the index keeps no {collection}"))); | ||
}; | ||
if !lexicon::valid_cid(cid) { | ||
return Err(refused("", format!("not a CID: {cid}"))); | ||
} | ||
if record["$type"] != collection { | ||
return Err(refused("$type", format!("must be {collection}"))); | ||
} | ||
let lexicons = &self.lexicons; | ||
lexicons | ||
.validate_record(collection, record) | ||
.map_err(PutError::Refused)?; | ||
let row = Any::parse(collection, record).expect("a collection with a table parses"); | ||
let row = row.map_err(|e| refused("", e.to_string()))?; | ||
let mut db = self.db.lock().unwrap(); | ||
let tx = db.transaction()?; | ||
let held = format!("SELECT cid FROM {table} WHERE uri = ?1"); | ||
let was: Option<String> = tx.query_row(&held, [uri], |r| r.get(0)).optional()?; | ||
if was.as_deref() == Some(cid) { | ||
return Ok(Applied::Unchanged); | ||
} | ||
let columns = row.columns(); | ||
let sql = format!( | ||
"INSERT INTO {table} (uri, cid, did, indexed_at, {names}) VALUES (?1, ?2, ?3, ?4, {slots}) | ||
ON CONFLICT (uri) DO UPDATE SET cid = excluded.cid, {sets}", | ||
names = columns.join(", "), | ||
slots = (5..5 + columns.len()).map(|i| format!("?{i}")).collect::<Vec<_>>().join(", "), | ||
sets = columns.iter().map(|c| format!("{c} = excluded.{c}")).collect::<Vec<_>>().join(", "), | ||
); | ||
let mut values: Vec<&dyn ToSql> = vec![&uri, &cid, &did, &indexed_at]; | ||
values.extend(row.params()); | ||
tx.prepare_cached(&sql)?.execute(&*values)?; | ||
tx.execute("DELETE FROM self_label WHERE uri = ?1", [uri])?; | ||
for val in records::self_labels(record) { | ||
tx.execute( | ||
"INSERT OR IGNORE INTO self_label VALUES (?1, ?2)", | ||
[uri, val], | ||
)?; | ||
} | ||
tx.commit()?; | ||
Ok(match was { | ||
Some(_) => Applied::Updated, | ||
None => Applied::Created, | ||
}) | ||
} | ||
| ||
/// Deletes the record at `uri`; whether the index had it. | ||
pub fn delete(&self, uri: &str) -> rusqlite::Result<bool> { | ||
let table = record_uri(uri).and_then(|(_, c, _)| records::table_of(c)); | ||
let Some(table) = table else { | ||
return Ok(false); | ||
}; | ||
let mut db = self.db.lock().unwrap(); | ||
let tx = db.transaction()?; | ||
tx.execute("DELETE FROM self_label WHERE uri = ?1", [uri])?; | ||
let deleted = tx.execute(&format!("DELETE FROM {table} WHERE uri = ?1"), [uri])?; | ||
tx.commit()?; | ||
Ok(deleted > 0) | ||
} | ||
| ||
/// The record at `uri`, of `T`'s collection. | ||
pub fn get<T: Record>(&self, uri: &str) -> rusqlite::Result<Option<Row<T>>> { | ||
let db = self.db.lock().unwrap(); | ||
let sql = format!("SELECT * FROM {} WHERE uri = ?1", T::TABLE); | ||
db.query_row(&sql, [uri], |r| { | ||
Ok(Row { | ||
uri: r.get("uri")?, | ||
cid: r.get("cid")?, | ||
did: r.get("did")?, | ||
indexed_at: r.get("indexed_at")?, | ||
record: T::from_row(r)?, | ||
}) | ||
}) | ||
.optional() | ||
} | ||
| ||
/// The self-labels the record at `uri` carries. | ||
pub fn self_labels(&self, uri: &str) -> rusqlite::Result<Vec<String>> { | ||
let db = self.db.lock().unwrap(); | ||
let mut stmt = db.prepare("SELECT val FROM self_label WHERE uri = ?1 ORDER BY val")?; | ||
let vals = stmt.query_map([uri], |r| r.get(0))?; | ||
vals.collect() | ||
} | ||
| ||
/// Tracks the account `did`; whether it was not tracked before. | ||
pub fn track(&self, did: &str) -> rusqlite::Result<bool> { | ||
let db = self.db.lock().unwrap(); | ||
let added = db.execute("INSERT OR IGNORE INTO repo (did) VALUES (?1)", [did])?; | ||
self.tracked.lock().unwrap().insert(did.to_string()); | ||
Ok(added > 0) | ||
} | ||
| ||
/// Whether the account `did` is tracked. | ||
pub fn tracks(&self, did: &str) -> bool { | ||
self.tracked.lock().unwrap().contains(did) | ||
} | ||
| ||
pub fn set_handle(&self, did: &str, handle: &str) -> rusqlite::Result<()> { | ||
let db = self.db.lock().unwrap(); | ||
db.execute("UPDATE repo SET handle = ?2 WHERE did = ?1", [did, handle])?; | ||
Ok(()) | ||
} | ||
| ||
/// The tracked account `did`'s handle, and how far backfill has it. | ||
pub fn repo(&self, did: &str) -> rusqlite::Result<Option<(Option<String>, String)>> { | ||
let db = self.db.lock().unwrap(); | ||
let sql = "SELECT handle, state FROM repo WHERE did = ?1"; | ||
db.query_row(sql, [did], |r| Ok((r.get(0)?, r.get(1)?))) | ||
.optional() | ||
} | ||
| ||
/// What `did`'s host last said of it, if not that it is active. | ||
pub fn host_status(&self, did: &str) -> rusqlite::Result<Option<String>> { | ||
let db = self.db.lock().unwrap(); | ||
let sql = "SELECT host_status FROM repo WHERE did = ?1"; | ||
let status = db.query_row(sql, [did], |r| r.get(0)).optional()?; | ||
Ok(status.flatten()) | ||
} | ||
| ||
/// Keeps what `did`'s host says of it, none when it is active. One | ||
/// that comes back is backfilled again, as it may have changed while | ||
/// it was gone. | ||
pub fn set_host_status(&self, did: &str, status: Option<&str>) -> rusqlite::Result<()> { | ||
let db = self.db.lock().unwrap(); | ||
db.execute( | ||
"UPDATE repo SET host_status = ?2, | ||
state = CASE WHEN ?2 IS NULL AND host_status IS NOT NULL THEN 'pending' ELSE state END | ||
WHERE did = ?1", | ||
params![did, status], | ||
)?; | ||
Ok(()) | ||
} | ||
| ||
/// Deletes every record `did` wrote; how many. | ||
pub fn purge(&self, did: &str) -> rusqlite::Result<usize> { | ||
let mut db = self.db.lock().unwrap(); | ||
let tx = db.transaction()?; | ||
// URIs from `at://<did>/` up to, not including, `at://<did>0`. | ||
let (from, to) = (format!("at://{did}/"), format!("at://{did}0")); | ||
tx.execute( | ||
"DELETE FROM self_label WHERE uri >= ?1 AND uri < ?2", | ||
[&from, &to], | ||
)?; | ||
let mut purged = 0; | ||
for (_, table) in records::COLLECTIONS { | ||
purged += tx.execute(&format!("DELETE FROM {table} WHERE did = ?1"), [did])?; | ||
} | ||
tx.commit()?; | ||
Ok(purged) | ||
} | ||
| ||
/// Takes the account `did` down, by an admin's word. | ||
pub fn take_down(&self, did: &str) -> rusqlite::Result<()> { | ||
let db = self.db.lock().unwrap(); | ||
let sql = "INSERT OR IGNORE INTO takedown VALUES (?1, ?2)"; | ||
db.execute(sql, [did, &grain::keys::now_iso()])?; | ||
Ok(()) | ||
} | ||
| ||
/// Lifts an admin's takedown of `did`. | ||
pub fn reverse_takedown(&self, did: &str) -> rusqlite::Result<()> { | ||
let db = self.db.lock().unwrap(); | ||
db.execute("DELETE FROM takedown WHERE did = ?1", [did])?; | ||
Ok(()) | ||
} | ||
| ||
pub fn taken_down(&self, did: &str) -> rusqlite::Result<bool> { | ||
let db = self.db.lock().unwrap(); | ||
db.prepare("SELECT 1 FROM takedown WHERE did = ?1")? | ||
.exists([did]) | ||
} | ||
| ||
/// The last sequence number read from the stream `source`. | ||
pub fn cursor(&self, source: &str) -> rusqlite::Result<Option<i64>> { | ||
let db = self.db.lock().unwrap(); | ||
let sql = "SELECT seq FROM stream_cursor WHERE source = ?1"; | ||
db.query_row(sql, [source], |r| r.get(0)).optional() | ||
} | ||
| ||
pub fn set_cursor(&self, source: &str, seq: i64) -> rusqlite::Result<()> { | ||
let db = self.db.lock().unwrap(); | ||
let sql = "INSERT OR REPLACE INTO stream_cursor VALUES (?1, ?2)"; | ||
db.execute(sql, params![source, seq])?; | ||
Ok(()) | ||
} | ||
| ||
/// Gives out `label`; its sequence number. | ||
pub fn label(&self, label: &Label) -> rusqlite::Result<i64> { | ||
let db = self.db.lock().unwrap(); | ||
db.execute( | ||
"INSERT INTO label (src, uri, val, neg, cts, exp) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", | ||
params![ | ||
label.src, label.uri, label.val, label.neg, label.cts, label.exp | ||
], | ||
)?; | ||
Ok(db.last_insert_rowid()) | ||
} | ||
| ||
/// The labels given out on `uri`, in the order they were. | ||
pub fn labels(&self, uri: &str) -> rusqlite::Result<Vec<Label>> { | ||
let db = self.db.lock().unwrap(); | ||
let sql = "SELECT src, uri, val, neg, cts, exp FROM label WHERE uri = ?1 ORDER BY id"; | ||
let mut stmt = db.prepare(sql)?; | ||
let labels = stmt.query_map([uri], |r| { | ||
Ok(Label { | ||
src: r.get(0)?, | ||
uri: r.get(1)?, | ||
val: r.get(2)?, | ||
neg: r.get(3)?, | ||
cts: r.get(4)?, | ||
exp: r.get(5)?, | ||
}) | ||
})?; | ||
labels.collect() | ||
} | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests; | ||
crates/grain-serve/src/index/records.rsnew+268−0
| @@ -0,0 +1,268 @@ | ||
//! A typed row for each collection the index keeps: its fields as the | ||
//! record names them, its columns as the table does, and the table. | ||
//! | ||
//! A row holds what grain's queries and views read. Strings, integers and | ||
//! datetimes are columns of their own; anything with structure (a blob, a | ||
//! facet list, a location) is one JSON column, as hatk kept them, so a view | ||
//! is built from the row and nothing else. | ||
| ||
use rusqlite::ToSql; | ||
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef}; | ||
use serde::Deserialize; | ||
use serde_json::Value; | ||
| ||
/// A JSON value kept as its text. | ||
#[derive(Debug, Clone, Default, PartialEq, Deserialize)] | ||
#[serde(transparent)] | ||
pub struct Json(pub Value); | ||
| ||
impl ToSql for Json { | ||
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> { | ||
Ok(ToSqlOutput::from(self.0.to_string())) | ||
} | ||
} | ||
| ||
impl FromSql for Json { | ||
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> { | ||
let text = value.as_str()?; | ||
serde_json::from_str(text) | ||
.map(Json) | ||
.map_err(|e| FromSqlError::Other(Box::new(e))) | ||
} | ||
} | ||
| ||
/// A collection's row. | ||
pub trait Record: Sized { | ||
/// The collection, `social.grain.gallery`. | ||
const NSID: &'static str; | ||
/// Its table, `gallery`. | ||
const TABLE: &'static str; | ||
/// Its columns past the envelope (`uri`, `cid`, `did`, `indexed_at`), | ||
/// each a field of the row by the same name. | ||
const COLUMNS: &'static [&'static str]; | ||
/// The row's values, in the order of [`Record::COLUMNS`]. | ||
fn params(&self) -> Vec<&dyn ToSql>; | ||
/// The row from a result row that names its columns. | ||
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Self>; | ||
} | ||
| ||
macro_rules! records { | ||
($( | ||
$(#[$doc:meta])* | ||
$name:ident: $nsid:tt => $table:tt { | ||
$( $field:ident: $ty:ty = $json:tt, )* | ||
} | ||
)*) => { | ||
$( | ||
$(#[$doc])* | ||
#[derive(Debug, Clone, Default, PartialEq, Deserialize)] | ||
#[serde(default)] | ||
pub struct $name { | ||
$( #[serde(rename = $json)] pub $field: $ty, )* | ||
} | ||
| ||
impl Record for $name { | ||
const NSID: &'static str = $nsid; | ||
const TABLE: &'static str = $table; | ||
const COLUMNS: &'static [&'static str] = &[$(stringify!($field)),*]; | ||
fn params(&self) -> Vec<&dyn ToSql> { | ||
vec![$(&self.$field),*] | ||
} | ||
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Self> { | ||
Ok($name { $( $field: row.get(stringify!($field))?, )* }) | ||
} | ||
} | ||
)* | ||
| ||
/// A row of any collection the index keeps. | ||
#[derive(Debug, Clone, PartialEq)] | ||
pub enum Any { | ||
$( $name($name), )* | ||
} | ||
| ||
impl Any { | ||
/// `value` as the row of `collection`, or `None` when the index | ||
/// keeps no such collection. | ||
pub fn parse(collection: &str, value: &Value) -> Option<serde_json::Result<Any>> { | ||
match collection { | ||
$( $nsid => Some($name::deserialize(value).map(Any::$name)), )* | ||
_ => None, | ||
} | ||
} | ||
| ||
pub fn table(&self) -> &'static str { | ||
match self { $( Any::$name(_) => $name::TABLE, )* } | ||
} | ||
| ||
pub fn columns(&self) -> &'static [&'static str] { | ||
match self { $( Any::$name(_) => $name::COLUMNS, )* } | ||
} | ||
| ||
pub fn params(&self) -> Vec<&dyn ToSql> { | ||
match self { $( Any::$name(r) => r.params(), )* } | ||
} | ||
} | ||
| ||
/// The collections the index keeps, and each one's table. | ||
pub const COLLECTIONS: &[(&str, &str)] = &[ $( ($nsid, $table), )* ]; | ||
| ||
/// Each table's columns past the envelope. | ||
pub const COLUMNS: &[(&str, &[&str])] = &[ $( ($table, $name::COLUMNS), )* ]; | ||
}; | ||
} | ||
| ||
records! { | ||
/// A gallery: a titled set of photos, by way of its items. | ||
Gallery: "social.grain.gallery" => "gallery" { | ||
title: String = "title", | ||
description: Option<String> = "description", | ||
facets: Option<Json> = "facets", | ||
labels: Option<Json> = "labels", | ||
location: Option<Json> = "location", | ||
address: Option<Json> = "address", | ||
updated_at: Option<String> = "updatedAt", | ||
created_at: String = "createdAt", | ||
} | ||
| ||
/// A photo's place in a gallery. | ||
GalleryItem: "social.grain.gallery.item" => "gallery_item" { | ||
gallery: String = "gallery", | ||
item: String = "item", | ||
position: i64 = "position", | ||
created_at: String = "createdAt", | ||
} | ||
| ||
Photo: "social.grain.photo" => "photo" { | ||
photo: Json = "photo", | ||
alt: Option<String> = "alt", | ||
aspect_ratio: Json = "aspectRatio", | ||
created_at: String = "createdAt", | ||
} | ||
| ||
/// A photo's EXIF, its decimals scaled by a million. | ||
PhotoExif: "social.grain.photo.exif" => "photo_exif" { | ||
photo: String = "photo", | ||
created_at: String = "createdAt", | ||
date_time_original: Option<String> = "dateTimeOriginal", | ||
exposure_time: Option<i64> = "exposureTime", | ||
f_number: Option<i64> = "fNumber", | ||
flash: Option<String> = "flash", | ||
focal_length_in35mm_format: Option<i64> = "focalLengthIn35mmFormat", | ||
iso: Option<i64> = "iSO", | ||
lens_make: Option<String> = "lensMake", | ||
lens_model: Option<String> = "lensModel", | ||
make: Option<String> = "make", | ||
model: Option<String> = "model", | ||
} | ||
| ||
/// An account's grain profile. | ||
Profile: "social.grain.actor.profile" => "profile" { | ||
display_name: Option<String> = "displayName", | ||
description: Option<String> = "description", | ||
avatar: Option<Json> = "avatar", | ||
created_at: Option<String> = "createdAt", | ||
} | ||
| ||
Follow: "social.grain.graph.follow" => "follow" { | ||
subject: String = "subject", | ||
created_at: String = "createdAt", | ||
} | ||
| ||
Block: "social.grain.graph.block" => "block" { | ||
subject: String = "subject", | ||
created_at: String = "createdAt", | ||
} | ||
| ||
Favorite: "social.grain.favorite" => "favorite" { | ||
subject: String = "subject", | ||
created_at: String = "createdAt", | ||
} | ||
| ||
Comment: "social.grain.comment" => "comment" { | ||
text: String = "text", | ||
facets: Option<Json> = "facets", | ||
subject: String = "subject", | ||
focus: Option<String> = "focus", | ||
reply_to: Option<String> = "replyTo", | ||
created_at: String = "createdAt", | ||
} | ||
| ||
Story: "social.grain.story" => "story" { | ||
media: Json = "media", | ||
aspect_ratio: Json = "aspectRatio", | ||
location: Option<Json> = "location", | ||
address: Option<Json> = "address", | ||
labels: Option<Json> = "labels", | ||
created_at: String = "createdAt", | ||
} | ||
| ||
/// A gallery in a group's pool, in the group's repo. | ||
GroupItem: "social.grain.group.item" => "group_item" { | ||
gallery: String = "gallery", | ||
submission: Option<String> = "submission", | ||
created_at: String = "createdAt", | ||
} | ||
| ||
/// A member offering a gallery to a group, in the member's repo. | ||
GroupSubmission: "social.grain.group.submission" => "group_submission" { | ||
group_did: String = "group", | ||
gallery: String = "gallery", | ||
created_at: String = "createdAt", | ||
} | ||
| ||
/// A group turning a submission down, in the group's repo. | ||
GroupDecline: "social.grain.group.decline" => "group_decline" { | ||
submission: String = "submission", | ||
gallery: String = "gallery", | ||
created_at: String = "createdAt", | ||
} | ||
| ||
/// That an account is a group. | ||
GroupDeclaration: "fyi.opensocial.declaration" => "group_declaration" { | ||
meta: String = "meta", | ||
created_at: String = "createdAt", | ||
} | ||
| ||
/// A Bluesky post, kept of accounts grain tracks: a gallery's | ||
/// cross-post is found among them. | ||
BskyPost: "app.bsky.feed.post" => "bsky_post" { | ||
text: String = "text", | ||
embed: Option<Json> = "embed", | ||
created_at: String = "createdAt", | ||
} | ||
| ||
/// A Bluesky follow, kept of accounts grain tracks, for suggesting whom | ||
/// to follow here. | ||
BskyFollow: "app.bsky.graph.follow" => "bsky_follow" { | ||
subject: String = "subject", | ||
created_at: String = "createdAt", | ||
} | ||
| ||
/// That an account takes messages on Germ, and from whom. | ||
GermDeclaration: "com.germnetwork.declaration" => "germ_declaration" { | ||
version: String = "version", | ||
current_key: Json = "currentKey", | ||
message_me: Option<Json> = "messageMe", | ||
key_package: Option<Json> = "keyPackage", | ||
continuity_proofs: Option<Json> = "continuityProofs", | ||
} | ||
} | ||
| ||
/// The table of `collection`, if the index keeps it. | ||
pub fn table_of(collection: &str) -> Option<&'static str> { | ||
COLLECTIONS | ||
.iter() | ||
.find(|(nsid, _)| *nsid == collection) | ||
.map(|(_, table)| *table) | ||
} | ||
| ||
/// The self-label values a record carries, `labels.values[].val`. | ||
pub fn self_labels(record: &Value) -> Vec<&str> { | ||
if record["labels"]["$type"] != "com.atproto.label.defs#selfLabels" { | ||
return Vec::new(); | ||
} | ||
record["labels"]["values"] | ||
.as_array() | ||
.map(|values| values.iter().filter_map(|v| v["val"].as_str()).collect()) | ||
.unwrap_or_default() | ||
} | ||
crates/grain-serve/src/index/schema.rsnew+257−0
| @@ -0,0 +1,257 @@ | ||
//! The index's tables, made by [`MIGRATIONS`] in order. | ||
//! | ||
//! # Records | ||
//! | ||
//! One table a collection, named for it ([`super::records::COLLECTIONS`]), | ||
//! rebuildable from the network by backfill. Each row is its record's | ||
//! envelope, then the record's fields as columns ([`super::records`]): | ||
//! | ||
//! - `uri` (key), `cid`, `did`, the record's author; | ||
//! - `indexed_at`, when the index first held the record: kept as the | ||
//! record changes, and carried over from hatk's tables by the import, | ||
//! since feeds order by it. A gallery's `sort_at` is the earlier of it | ||
//! and `created_at`, so a clock set ahead cannot pin one to the top; | ||
//! - the fields, under their names in snake case. | ||
//! | ||
//! `self_label` holds the self-labels a gallery or story carries in | ||
//! `labels`, a row each, for feeds to leave out the ones that hide. | ||
//! | ||
//! # Accounts | ||
//! | ||
//! `repo` is every account the index tracks, written by backfill and the | ||
//! stream: its handle, how far backfill has it (`state`: `pending`, | ||
//! `active`, `failed`), and `host_status`, null while its host says it is | ||
//! active, and otherwise what the host said: `deactivated`, `suspended`, | ||
//! `takendown`, `deleted`. `stream_cursor` is where each stream was read | ||
//! to, by source (`jetstream:<url>`, as hatk's `_cursor` keys it). | ||
//! | ||
//! # What the network cannot rebuild | ||
//! | ||
//! These hold what was decided or said here, and nothing refills them: | ||
//! | ||
//! - `takedown`: accounts an admin took down, apart from `repo` so that | ||
//! rebuilding the records never reinstates one; | ||
//! - `label`: the labels grain gives out, as `com.atproto.label.defs#label` | ||
//! has them; one on an account names it by its bare DID, not | ||
//! `at://<did>`, as Bluesky's do; | ||
//! - `report`, `mute`, `preference`, `push_token`, `story_view`, | ||
//! `space_invite`, `space_support`. | ||
//! | ||
//! A migration may drop and remake a record table, which backfill refills; | ||
//! these it only alters in place. | ||
//! | ||
//! # Indexes | ||
//! | ||
//! Each serves a query grain runs: the feeds (recent, following, actor, | ||
//! camera, hashtag, location, group, groups, for you), the views that | ||
//! hydrate them, and the moderation filters every feed applies | ||
//! ([`shown`], [`HIDDEN_LABELS`]). | ||
| ||
/// Each step of the schema, run once, in order, by `PRAGMA user_version`. | ||
/// A step is only ever appended: one that has run is never changed. | ||
pub const MIGRATIONS: &[&str] = &[V1]; | ||
| ||
const V1: &str = " | ||
CREATE TABLE gallery ( | ||
uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, indexed_at TEXT NOT NULL, | ||
title TEXT NOT NULL, description TEXT, facets TEXT, labels TEXT, location TEXT, | ||
address TEXT, updated_at TEXT, created_at TEXT NOT NULL, | ||
sort_at TEXT GENERATED ALWAYS AS (min(created_at, indexed_at)) VIRTUAL, | ||
h3 TEXT GENERATED ALWAYS AS (json_extract(location, '$.value')) VIRTUAL); | ||
CREATE INDEX gallery_sort ON gallery (sort_at); | ||
CREATE INDEX gallery_did ON gallery (did, created_at); | ||
CREATE INDEX gallery_did_sort ON gallery (did, sort_at); | ||
CREATE INDEX gallery_h3 ON gallery (h3, sort_at); | ||
| ||
CREATE TABLE gallery_item ( | ||
uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, indexed_at TEXT NOT NULL, | ||
gallery TEXT NOT NULL, item TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, | ||
created_at TEXT NOT NULL); | ||
CREATE INDEX gallery_item_gallery ON gallery_item (gallery, position); | ||
CREATE INDEX gallery_item_item ON gallery_item (item); | ||
CREATE INDEX gallery_item_did ON gallery_item (did); | ||
| ||
CREATE TABLE photo ( | ||
uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, indexed_at TEXT NOT NULL, | ||
photo TEXT NOT NULL, alt TEXT, aspect_ratio TEXT NOT NULL, created_at TEXT NOT NULL); | ||
CREATE INDEX photo_did ON photo (did, created_at); | ||
| ||
CREATE TABLE photo_exif ( | ||
uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, indexed_at TEXT NOT NULL, | ||
photo TEXT NOT NULL, created_at TEXT NOT NULL, date_time_original TEXT, | ||
exposure_time INTEGER, f_number INTEGER, flash TEXT, focal_length_in35mm_format INTEGER, | ||
iso INTEGER, lens_make TEXT, lens_model TEXT, make TEXT, model TEXT); | ||
CREATE INDEX photo_exif_photo ON photo_exif (photo); | ||
CREATE INDEX photo_exif_camera ON photo_exif (make, model); | ||
CREATE INDEX photo_exif_did ON photo_exif (did); | ||
| ||
CREATE TABLE profile ( | ||
uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, indexed_at TEXT NOT NULL, | ||
display_name TEXT, description TEXT, avatar TEXT, created_at TEXT); | ||
CREATE INDEX profile_did ON profile (did); | ||
| ||
CREATE TABLE follow ( | ||
uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, indexed_at TEXT NOT NULL, | ||
subject TEXT NOT NULL, created_at TEXT NOT NULL); | ||
CREATE INDEX follow_did ON follow (did, subject); | ||
CREATE INDEX follow_subject ON follow (subject, did); | ||
| ||
CREATE TABLE block ( | ||
uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, indexed_at TEXT NOT NULL, | ||
subject TEXT NOT NULL, created_at TEXT NOT NULL); | ||
CREATE INDEX block_did ON block (did, subject); | ||
CREATE INDEX block_subject ON block (subject, did); | ||
| ||
CREATE TABLE favorite ( | ||
uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, indexed_at TEXT NOT NULL, | ||
subject TEXT NOT NULL, created_at TEXT NOT NULL); | ||
CREATE INDEX favorite_subject ON favorite (subject, did); | ||
CREATE INDEX favorite_did ON favorite (did, created_at); | ||
CREATE INDEX favorite_did_subject ON favorite (did, subject); | ||
| ||
CREATE TABLE comment ( | ||
uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, indexed_at TEXT NOT NULL, | ||
text TEXT NOT NULL, facets TEXT, subject TEXT NOT NULL, focus TEXT, reply_to TEXT, | ||
created_at TEXT NOT NULL); | ||
CREATE INDEX comment_subject ON comment (subject, created_at); | ||
CREATE INDEX comment_reply_to ON comment (reply_to); | ||
CREATE INDEX comment_did ON comment (did, created_at); | ||
| ||
CREATE TABLE story ( | ||
uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, indexed_at TEXT NOT NULL, | ||
media TEXT NOT NULL, aspect_ratio TEXT NOT NULL, location TEXT, address TEXT, | ||
labels TEXT, created_at TEXT NOT NULL); | ||
CREATE INDEX story_did ON story (did, created_at); | ||
CREATE INDEX story_created ON story (created_at); | ||
| ||
CREATE TABLE group_item ( | ||
uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, indexed_at TEXT NOT NULL, | ||
gallery TEXT NOT NULL, submission TEXT, created_at TEXT NOT NULL); | ||
CREATE INDEX group_item_did ON group_item (did, created_at); | ||
CREATE INDEX group_item_gallery ON group_item (gallery, did); | ||
CREATE INDEX group_item_created ON group_item (created_at); | ||
| ||
CREATE TABLE group_submission ( | ||
uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, indexed_at TEXT NOT NULL, | ||
group_did TEXT NOT NULL, gallery TEXT NOT NULL, created_at TEXT NOT NULL); | ||
CREATE INDEX group_submission_group ON group_submission (group_did, created_at); | ||
CREATE INDEX group_submission_did ON group_submission (did, gallery); | ||
| ||
CREATE TABLE group_decline ( | ||
uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, indexed_at TEXT NOT NULL, | ||
submission TEXT NOT NULL, gallery TEXT NOT NULL, created_at TEXT NOT NULL); | ||
CREATE INDEX group_decline_did ON group_decline (did, submission); | ||
CREATE INDEX group_decline_gallery ON group_decline (gallery, did); | ||
| ||
CREATE TABLE group_declaration ( | ||
uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, indexed_at TEXT NOT NULL, | ||
meta TEXT NOT NULL, created_at TEXT NOT NULL); | ||
CREATE INDEX group_declaration_did ON group_declaration (did); | ||
| ||
CREATE TABLE bsky_post ( | ||
uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, indexed_at TEXT NOT NULL, | ||
text TEXT NOT NULL, embed TEXT, created_at TEXT NOT NULL); | ||
CREATE INDEX bsky_post_did ON bsky_post (did, created_at); | ||
| ||
CREATE TABLE bsky_follow ( | ||
uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, indexed_at TEXT NOT NULL, | ||
subject TEXT NOT NULL, created_at TEXT NOT NULL); | ||
CREATE INDEX bsky_follow_did ON bsky_follow (did, subject); | ||
CREATE INDEX bsky_follow_subject ON bsky_follow (subject); | ||
| ||
CREATE TABLE germ_declaration ( | ||
uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, indexed_at TEXT NOT NULL, | ||
version TEXT NOT NULL, current_key TEXT NOT NULL, message_me TEXT, key_package TEXT, | ||
continuity_proofs TEXT); | ||
CREATE INDEX germ_declaration_did ON germ_declaration (did); | ||
| ||
CREATE TABLE self_label ( | ||
uri TEXT NOT NULL, val TEXT NOT NULL, PRIMARY KEY (uri, val)) WITHOUT ROWID; | ||
| ||
CREATE TABLE repo ( | ||
did TEXT PRIMARY KEY, handle TEXT, state TEXT NOT NULL DEFAULT 'pending', | ||
host_status TEXT, rev TEXT, backfilled_at TEXT, | ||
retry_count INTEGER NOT NULL DEFAULT 0, retry_after INTEGER NOT NULL DEFAULT 0); | ||
CREATE INDEX repo_handle ON repo (handle); | ||
CREATE INDEX repo_state ON repo (state); | ||
| ||
CREATE TABLE stream_cursor (source TEXT PRIMARY KEY, seq INTEGER NOT NULL); | ||
| ||
CREATE TABLE takedown (did TEXT PRIMARY KEY, at TEXT NOT NULL); | ||
| ||
CREATE TABLE label ( | ||
id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT NOT NULL, uri TEXT NOT NULL, | ||
val TEXT NOT NULL, neg INTEGER NOT NULL DEFAULT 0, cts TEXT NOT NULL, exp TEXT); | ||
CREATE INDEX label_uri ON label (uri, val, cts); | ||
CREATE INDEX label_src ON label (src, id); | ||
| ||
CREATE TABLE report ( | ||
id INTEGER PRIMARY KEY AUTOINCREMENT, subject_uri TEXT NOT NULL, | ||
subject_did TEXT NOT NULL, label TEXT NOT NULL, reason TEXT, | ||
reported_by TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', resolved_by TEXT, | ||
resolved_at TEXT, created_at TEXT NOT NULL); | ||
CREATE INDEX report_status ON report (status, created_at); | ||
CREATE INDEX report_subject ON report (subject_uri); | ||
| ||
CREATE TABLE mute ( | ||
did TEXT NOT NULL, subject TEXT NOT NULL, created_at TEXT NOT NULL, | ||
PRIMARY KEY (did, subject)) WITHOUT ROWID; | ||
CREATE INDEX mute_subject ON mute (subject); | ||
| ||
CREATE TABLE preference ( | ||
did TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, updated_at TEXT NOT NULL, | ||
PRIMARY KEY (did, key)) WITHOUT ROWID; | ||
| ||
CREATE TABLE push_token ( | ||
did TEXT NOT NULL, token TEXT NOT NULL, platform TEXT NOT NULL, | ||
created_at TEXT NOT NULL, PRIMARY KEY (did, token)) WITHOUT ROWID; | ||
CREATE INDEX push_token_token ON push_token (token); | ||
| ||
CREATE TABLE story_view ( | ||
did TEXT NOT NULL, subject TEXT NOT NULL, created_at TEXT NOT NULL, | ||
PRIMARY KEY (did, subject)) WITHOUT ROWID; | ||
CREATE INDEX story_view_created ON story_view (created_at); | ||
| ||
CREATE TABLE space_invite ( | ||
space TEXT NOT NULL, member_did TEXT NOT NULL, author_did TEXT NOT NULL, | ||
created_at TEXT NOT NULL, PRIMARY KEY (space, member_did)) WITHOUT ROWID; | ||
CREATE INDEX space_invite_member ON space_invite (member_did, created_at); | ||
| ||
CREATE TABLE space_support ( | ||
pds_endpoint TEXT PRIMARY KEY, supported INTEGER NOT NULL, missing TEXT NOT NULL, | ||
checked_at TEXT NOT NULL); | ||
"; | ||
| ||
/// The label values that leave what carries them out of feeds: the | ||
/// imperative ones, and those whose default is to hide. | ||
pub const HIDDEN_LABELS: [&str; 9] = [ | ||
"!hide", | ||
"!takedown", | ||
"csae", | ||
"spam", | ||
"copyright", | ||
"gore", | ||
"nsfl", | ||
"dmca-violation", | ||
"doxxing", | ||
]; | ||
| ||
/// SQL that holds where the account `did` (a column or parameter) is | ||
/// shown: no admin took it down, and its host does not say it is gone. | ||
pub fn shown(did: &str) -> String { | ||
format!( | ||
"NOT EXISTS (SELECT 1 FROM takedown WHERE did = {did}) | ||
AND NOT EXISTS (SELECT 1 FROM repo WHERE did = {did} AND host_status IS NOT NULL)" | ||
) | ||
} | ||
| ||
/// SQL that holds where the subject `uri` carries no label of | ||
/// [`HIDDEN_LABELS`] that stands: one whose latest word is not a negation. | ||
pub fn unhidden(uri: &str) -> String { | ||
let vals = HIDDEN_LABELS.map(|v| format!("'{v}'")).join(","); | ||
format!( | ||
"NOT EXISTS (SELECT 1 FROM label l WHERE l.uri = {uri} AND l.val IN ({vals}) | ||
AND l.neg = 0 AND NOT EXISTS (SELECT 1 FROM label n WHERE n.uri = l.uri | ||
AND n.val = l.val AND n.neg = 1 AND n.cts > l.cts))" | ||
) | ||
} | ||
crates/grain-serve/src/index/tests.rsnew+607−0
607 lines changed: not loaded until asked for.
crates/grain-serve/src/lib.rs+2−0
| @@ -2,6 +2,8 @@ | ||
//! index, the queries, ingest, login and push join it pull by pull on | ||
//! `feature/rust`, as the plan orders them. | ||
| ||
pub mod index; | ||
| ||
use axum::http::StatusCode; | ||
use axum::{Json, Router, routing::any, routing::get}; | ||
use serde_json::{Value, json}; | ||
crates/grain-serve/Cargo.toml+3−0
| @@ -10,10 +10,13 @@ rust-version.workspace = true | ||
grain.workspace = true | ||
anyhow.workspace = true | ||
axum.workspace = true | ||
rusqlite.workspace = true | ||
serde.workspace = true | ||
serde_json.workspace = true | ||
tokio.workspace = true | ||
tracing.workspace = true | ||
tracing-subscriber.workspace = true | ||
| ||
[dev-dependencies] | ||
tempfile.workspace = true | ||
reqwest.workspace = true | ||
Cargo.lock+110−1
| @@ -375,6 +375,18 @@ dependencies = [ | ||
"windows-sys 0.61.2", | ||
] | ||
| ||
[[package]] | ||
name = "fallible-iterator" | ||
version = "0.3.0" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" | ||
| ||
[[package]] | ||
name = "fallible-streaming-iterator" | ||
version = "0.1.9" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" | ||
| ||
[[package]] | ||
name = "fastrand" | ||
version = "2.5.0" | ||
| @@ -414,6 +426,12 @@ version = "1.0.7" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" | ||
| ||
[[package]] | ||
name = "foldhash" | ||
version = "0.2.0" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | ||
| ||
[[package]] | ||
name = "form_urlencoded" | ||
version = "1.2.2" | ||
| @@ -571,6 +589,7 @@ dependencies = [ | ||
"tokio", | ||
"tracing", | ||
"tracing-subscriber", | ||
"unicode-segmentation", | ||
] | ||
| ||
[[package]] | ||
| @@ -581,7 +600,10 @@ dependencies = [ | ||
"axum", | ||
"grain", | ||
"reqwest", | ||
"rusqlite", | ||
"serde", | ||
"serde_json", | ||
"tempfile", | ||
"tokio", | ||
"tracing", | ||
"tracing-subscriber", | ||
| @@ -628,11 +650,32 @@ dependencies = [ | ||
"zerocopy", | ||
] | ||
| ||
[[package]] | ||
name = "hashbrown" | ||
version = "0.16.1" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" | ||
dependencies = [ | ||
"foldhash", | ||
] | ||
| ||
[[package]] | ||
name = "hashbrown" | ||
version = "0.17.1" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" | ||
dependencies = [ | ||
"foldhash", | ||
] | ||
| ||
[[package]] | ||
name = "hashlink" | ||
version = "0.12.2" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" | ||
dependencies = [ | ||
"hashbrown 0.17.1", | ||
] | ||
| ||
[[package]] | ||
name = "hmac" | ||
| @@ -861,7 +904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" | ||
dependencies = [ | ||
"equivalent", | ||
"hashbrown", | ||
"hashbrown 0.17.1", | ||
] | ||
| ||
[[package]] | ||
| @@ -913,6 +956,17 @@ version = "0.2.191" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" | ||
| ||
[[package]] | ||
name = "libsqlite3-sys" | ||
version = "0.38.2" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" | ||
dependencies = [ | ||
"cc", | ||
"pkg-config", | ||
"vcpkg", | ||
] | ||
| ||
[[package]] | ||
name = "linux-raw-sys" | ||
version = "0.12.1" | ||
| @@ -1034,6 +1088,12 @@ dependencies = [ | ||
"spki", | ||
] | ||
| ||
[[package]] | ||
name = "pkg-config" | ||
version = "0.3.34" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" | ||
| ||
[[package]] | ||
name = "potential_utf" | ||
version = "0.1.6" | ||
| @@ -1247,6 +1307,31 @@ dependencies = [ | ||
"windows-sys 0.52.0", | ||
] | ||
| ||
[[package]] | ||
name = "rsqlite-vfs" | ||
version = "0.1.1" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" | ||
dependencies = [ | ||
"hashbrown 0.16.1", | ||
"thiserror", | ||
] | ||
| ||
[[package]] | ||
name = "rusqlite" | ||
version = "0.40.2" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" | ||
dependencies = [ | ||
"bitflags", | ||
"fallible-iterator", | ||
"fallible-streaming-iterator", | ||
"hashlink", | ||
"libsqlite3-sys", | ||
"smallvec", | ||
"sqlite-wasm-rs", | ||
] | ||
| ||
[[package]] | ||
name = "rustc-hash" | ||
version = "2.1.3" | ||
| @@ -1477,6 +1562,18 @@ dependencies = [ | ||
"der", | ||
] | ||
| ||
[[package]] | ||
name = "sqlite-wasm-rs" | ||
version = "0.5.5" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" | ||
dependencies = [ | ||
"cc", | ||
"js-sys", | ||
"rsqlite-vfs", | ||
"wasm-bindgen", | ||
] | ||
| ||
[[package]] | ||
name = "stable_deref_trait" | ||
version = "1.2.1" | ||
| @@ -1770,6 +1867,12 @@ version = "1.0.27" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" | ||
| ||
[[package]] | ||
name = "unicode-segmentation" | ||
version = "1.13.3" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" | ||
| ||
[[package]] | ||
name = "untrusted" | ||
version = "0.9.0" | ||
| @@ -1809,6 +1912,12 @@ version = "0.1.1" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" | ||
| ||
[[package]] | ||
name = "vcpkg" | ||
version = "0.2.15" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" | ||
| ||
[[package]] | ||
name = "version_check" | ||
version = "0.9.5" | ||
Cargo.toml+2−0
| @@ -26,10 +26,12 @@ reqwest = { version = "0.12", default-features = false, features = [ | ||
"http2", | ||
"gzip", | ||
] } | ||
rusqlite = { version = "0.40", features = ["bundled"] } | ||
serde = { version = "1", features = ["derive"] } | ||
serde_json = "1" | ||
sha2 = "0.10" | ||
tempfile = "3" | ||
unicode-segmentation = "1" | ||
tokio = { version = "1", features = [ | ||
"rt", | ||
"rt-multi-thread", | ||