~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

server: An index of grain's own, its records put by URI and CID

Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:index/schema

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",