Skip to main content

sbol_db_storage/
traits.rs

1//! The storage contract: traits a persistence backend implements.
2//!
3//! Async traits use `#[async_trait]` so they are object-safe and can be held
4//! as `Arc<dyn ...>`. [`TripleSource`] is deliberately synchronous: it backs
5//! the SPARQL evaluator's synchronous `QueryableDataset`, and a backend that
6//! needs async runs it to completion internally.
7
8use std::collections::HashMap;
9use std::time::Duration;
10
11use async_trait::async_trait;
12use sbol_db_core::{
13    BlobRef, ConfigEntry, DomainError, GraphId, GraphRecord, ImportReport, JobId,
14    NeighborhoodQuery, NeighborhoodResult, NewUser, ObjectId, ObjectTerm, SbolObjectRecord,
15    SerializationFormat, Triple, User, UserId,
16};
17use sbol_db_search::{ClusterId, Signature};
18use serde_json::Value;
19
20use crate::{
21    AccelSolutions, AcceleratedQuery, BatchSequenceMatch, CorpusStatsStore, EnqueueOutcome,
22    GraphFilter, GraphWriteMode, IdGraphFilter, IdQuad, ImportInput, JobAttempt, JobLogRecord,
23    JobStatus, ListGraphsFilter, ListJobsFilter, ListObjectsFilter, NamedGraphCatalogStore, NewJob,
24    OldestQueuedAge, OntologyLoadReport, OntologyRecord, OntologyTermRecord, PatternObject,
25    PatternSubject, QueueDepthRow, RankRow, ResourceCatalogStore, SbolJob, SequenceCatalogStore,
26    SequenceMatch, SequenceSearchOptions, TermId, TermKey, TermValue, TextSearchQuery,
27    TripleChange, TripleScanPage, UpdateOutcome,
28};
29
30/// Synchronous triple-pattern reads, as required by the SPARQL evaluator.
31pub trait TripleSource: Send + Sync {
32    /// Stable, resumable keyset page over the complete quad store. Large
33    /// maintenance jobs use this instead of requesting `i64::MAX` rows. The
34    /// default supports small/test stores in one bounded page and fails closed
35    /// if another page would be required.
36    fn scan_all_page(
37        &self,
38        after: Option<&str>,
39        limit: usize,
40    ) -> Result<TripleScanPage, DomainError> {
41        if after.is_some() {
42            return Err(DomainError::Database(
43                "this triple source does not implement paginated full scans".to_owned(),
44            ));
45        }
46        let requested = i64::try_from(limit.saturating_add(1)).unwrap_or(i64::MAX);
47        let mut items = self.scan_pattern(None, None, None, None, requested)?;
48        if items.len() > limit {
49            return Err(DomainError::Database(
50                "this triple source requires a paginated full-scan implementation".to_owned(),
51            ));
52        }
53        Ok(TripleScanPage {
54            items: std::mem::take(&mut items),
55            next_cursor: None,
56        })
57    }
58
59    /// Scan triples matching a pattern. Any position may be bound or wildcarded
60    /// (`None`); `limit` caps the rows returned per call.
61    fn scan_pattern(
62        &self,
63        subject: Option<&PatternSubject>,
64        predicate: Option<&str>,
65        object: Option<&PatternObject>,
66        graph: Option<&GraphFilter>,
67        limit: i64,
68    ) -> Result<Vec<Triple>, DomainError>;
69
70    /// Distinct named graphs present in the store.
71    fn distinct_named_graphs(&self) -> Result<Vec<String>, DomainError>;
72
73    /// Every triple in one named graph (`Some`) or the default partition
74    /// (`None`), capped at `limit`.
75    fn triples_for_graph(
76        &self,
77        graph: Option<&str>,
78        limit: i64,
79    ) -> Result<Vec<Triple>, DomainError>;
80
81    /// Every triple with the given subject IRI.
82    fn triples_for_subject(&self, subject_iri: &str) -> Result<Vec<Triple>, DomainError>;
83
84    /// Whether this backend can scan by term id ([`Self::id_scan`]). An id-native
85    /// backend lets the SPARQL evaluator join on term ids and materialize terms
86    /// only for output rows; the default is `false` and the evaluator uses the
87    /// term-materializing [`Self::scan_pattern`] path.
88    fn supports_id_scan(&self) -> bool {
89        false
90    }
91
92    /// Scan a pattern returning term ids rather than materialized triples. Bound
93    /// positions are given as ids (see [`Self::term_to_id`]). Only meaningful when
94    /// [`Self::supports_id_scan`] is true.
95    fn id_scan(
96        &self,
97        _subject: Option<TermId>,
98        _predicate: Option<TermId>,
99        _object: Option<TermId>,
100        _graph: &IdGraphFilter,
101        _limit: i64,
102    ) -> Result<Vec<IdQuad>, DomainError> {
103        Err(DomainError::Database(
104            "id_scan is not supported by this backend".into(),
105        ))
106    }
107
108    /// Resolve a term to its id (the same id stored in the indexes), for binding
109    /// query constants into an id-native scan.
110    fn term_to_id(&self, _key: TermKey<'_>) -> Result<TermId, DomainError> {
111        Err(DomainError::Database(
112            "term_to_id is not supported by this backend".into(),
113        ))
114    }
115
116    /// Resolve a term id back to its value, for materializing output rows and
117    /// filter operands.
118    fn id_to_term(&self, _id: TermId) -> Result<TermValue, DomainError> {
119        Err(DomainError::Database(
120            "id_to_term is not supported by this backend".into(),
121        ))
122    }
123
124    /// Answer a recognized SynBioHub query from purpose-built indexes. Returns
125    /// `Ok(None)` when this backend has no accelerator or declines the query, so
126    /// the caller falls back to generic SPARQL evaluation.
127    fn run_accelerated(
128        &self,
129        _query: &AcceleratedQuery,
130    ) -> Result<Option<AccelSolutions>, DomainError> {
131        Ok(None)
132    }
133}
134
135/// Atomic batch application of SPARQL-update changes.
136#[async_trait]
137pub trait TripleWriter: Send + Sync {
138    /// Apply every change in one atomic unit, registering any named graph an
139    /// insert targets before writing it. Returns the inserted/deleted tally.
140    async fn apply_update(&self, changes: Vec<TripleChange>) -> Result<UpdateOutcome, DomainError>;
141}
142
143/// Derived-view object reads.
144#[async_trait]
145pub trait ObjectStore: Send + Sync {
146    async fn get_object_by_iri(&self, iri: &str) -> Result<Option<SbolObjectRecord>, DomainError>;
147    async fn get_objects_by_iris(
148        &self,
149        iris: &[&str],
150    ) -> Result<Vec<SbolObjectRecord>, DomainError>;
151    async fn list_objects(
152        &self,
153        filter: &ListObjectsFilter,
154    ) -> Result<Vec<SbolObjectRecord>, DomainError>;
155    async fn get_object_iri_by_id(&self, id: ObjectId) -> Result<Option<String>, DomainError>;
156}
157
158/// Document-graph reads and deletion.
159#[async_trait]
160pub trait GraphStore: Send + Sync {
161    async fn get_graph(&self, id: GraphId) -> Result<Option<GraphRecord>, DomainError>;
162    async fn list_graphs(&self, filter: &ListGraphsFilter)
163        -> Result<Vec<GraphRecord>, DomainError>;
164    async fn delete_graph(&self, id: GraphId) -> Result<bool, DomainError>;
165    async fn graph_exists_by_hash(&self, hash: &[u8]) -> Result<bool, DomainError>;
166    /// Resolve a document graph's surrogate id from its document IRI, for
167    /// delete-by-IRI. Returns `None` when no `sbol3`-kind graph carries it.
168    async fn graph_id_by_document_iri(
169        &self,
170        document_iri: &str,
171    ) -> Result<Option<GraphId>, DomainError>;
172}
173
174/// Ontology loading and lookup.
175#[async_trait]
176pub trait OntologyStore: Send + Sync {
177    async fn load_ontology_from_url(
178        &self,
179        prefix: &str,
180        name: &str,
181        source_url: &str,
182    ) -> Result<OntologyLoadReport, DomainError>;
183    async fn load_ontology_from_text(
184        &self,
185        prefix: &str,
186        name: &str,
187        source_url: Option<&str>,
188        text: &str,
189    ) -> Result<OntologyLoadReport, DomainError>;
190    async fn list_ontologies(&self) -> Result<Vec<OntologyRecord>, DomainError>;
191    async fn canonicalize(&self, iri: &str) -> Result<Option<String>, DomainError>;
192    async fn descendants(&self, iri: &str) -> Result<Vec<(String, i16)>, DomainError>;
193    async fn list_ontology_terms(
194        &self,
195        prefix: &str,
196        limit: i64,
197        offset: i64,
198        search: Option<&str>,
199    ) -> Result<(Vec<OntologyTermRecord>, i64), DomainError>;
200    async fn get_ontology_term(&self, iri: &str)
201        -> Result<Option<OntologyTermRecord>, DomainError>;
202}
203
204/// Graph-neighborhood traversal.
205#[async_trait]
206pub trait NeighborhoodStore: Send + Sync {
207    async fn walk(&self, query: &NeighborhoodQuery) -> Result<NeighborhoodResult, DomainError>;
208}
209
210/// Nucleotide sequence search over the derived view.
211#[async_trait]
212pub trait SequenceSearchStore: Send + Sync {
213    async fn search(
214        &self,
215        pattern: &str,
216        options: SequenceSearchOptions,
217    ) -> Result<Vec<SequenceMatch>, DomainError>;
218    async fn search_many(
219        &self,
220        patterns: &[String],
221        options: SequenceSearchOptions,
222    ) -> Result<Vec<BatchSequenceMatch>, DomainError>;
223
224    /// Candidate `(sequence_iri, elements)` pairs for the banded aligner: every
225    /// indexed DNA/RNA sequence sharing at least one canonical k-mer with
226    /// `query` (on either strand, since the seeds are canonical). This is the
227    /// k-mer prefilter feeding the alignment verify step in the application
228    /// facade; the store gathers candidates and never aligns, so rust-bio stays
229    /// out of the backends. A query shorter than the k-mer width has no seed, so
230    /// every indexed nucleotide sequence is returned as a candidate.
231    ///
232    /// The similarity-search align path draws its candidates from the MinHash/LSH
233    /// [`SketchStore`] instead; this k-mer prefilter remains the fallback for a
234    /// query too short to sketch.
235    async fn align_candidates(&self, query: &str) -> Result<Vec<(String, String)>, DomainError>;
236
237    /// The `(sequence_iri, elements)` for the DNA/RNA sequences among `iris`,
238    /// dropping any IRI that is absent or non-nucleotide. This materializes the
239    /// elements of the [`SketchStore`] candidate set so the application facade
240    /// can align them; order is unspecified.
241    async fn sequences_by_iris(
242        &self,
243        iris: &[String],
244    ) -> Result<Vec<(String, String)>, DomainError>;
245
246    /// Every DNA/RNA `(sequence_iri, elements)` in the derived view, the full set
247    /// the search-index rebuild sketches into the [`SketchStore`]. Keyed by the
248    /// Sequence object's IRI, the same key the align path materializes elements
249    /// by, so the sketch index and the align candidates stay aligned regardless
250    /// of the source SBOL version. Order is unspecified.
251    async fn all_nucleotide_sequences(&self) -> Result<Vec<(String, String)>, DomainError>;
252}
253
254/// Persistence for sequence cluster assignments backing `/similar`.
255///
256/// Clustering (greedy centroid, `vsearch --cluster_fast --id 0.8`) runs in the
257/// search layer; this store persists the resulting `(sequence, cluster)`
258/// assignments. The search-index rebuild recomputes every assignment and calls
259/// [`replace_clusters`](Self::replace_clusters) to swap the whole table in one
260/// transaction, so a read never sees a partial clustering. Answering `/similar`
261/// at query time needs only this store and [`PageRankStore`]: no aligner, hence
262/// no rust-bio, in the backends.
263#[async_trait]
264pub trait ClusterStore: Send + Sync {
265    /// The cluster a sequence IRI belongs to, or `None` when it is unclustered.
266    async fn cluster_id_of(&self, iri: &str) -> Result<Option<ClusterId>, DomainError>;
267
268    /// The other members of `iri`'s cluster, excluding `iri` itself. Empty when
269    /// `iri` is unclustered or the sole member. This is the `/similar` candidate
270    /// set, which the caller then ranks by PageRank. Order is unspecified.
271    async fn cluster_mates(&self, iri: &str) -> Result<Vec<String>, DomainError>;
272
273    /// Replace every cluster assignment with `pairs` in one transaction: after
274    /// it returns the table reflects exactly these `(sequence_iri, cluster_id)`
275    /// pairs and nothing prior.
276    async fn replace_clusters(&self, pairs: Vec<(String, ClusterId)>) -> Result<(), DomainError>;
277
278    /// Every persisted `(sequence_iri, cluster_id)` assignment, a full scan of
279    /// the cluster table. The ranked-text path builds its cluster-duplicate map
280    /// from these, so a non-centroid cluster member is demoted in free-text
281    /// search. Order is unspecified.
282    async fn all_assignments(&self) -> Result<Vec<(String, ClusterId)>, DomainError>;
283
284    /// Assign one sequence to a cluster, upserting: a later assignment for the
285    /// same IRI overwrites the earlier one. This is the incremental write path a
286    /// single new sequence takes, sparing the whole-table
287    /// [`replace_clusters`](Self::replace_clusters) a full rebuild runs.
288    ///
289    /// The default reads every assignment, splices in `(iri, cluster)`, and
290    /// writes them all back through [`replace_clusters`](Self::replace_clusters);
291    /// a backend with a native upsert overrides it.
292    async fn assign_cluster(&self, iri: &str, cluster: ClusterId) -> Result<(), DomainError> {
293        let mut all = self.all_assignments().await?;
294        all.retain(|(existing, _)| existing != iri);
295        all.push((iri.to_owned(), cluster));
296        self.replace_clusters(all).await
297    }
298
299    /// The largest cluster id in use, or `None` when no sequence is clustered.
300    /// The incremental assign path opens a fresh cluster at one past this id.
301    ///
302    /// The default scans every assignment; a backend with an aggregate query
303    /// overrides it.
304    async fn max_cluster_id(&self) -> Result<Option<ClusterId>, DomainError> {
305        Ok(self
306            .all_assignments()
307            .await?
308            .into_iter()
309            .map(|(_, cluster)| cluster)
310            .max())
311    }
312}
313
314/// Persistence for the MinHash/LSH similarity sketch index.
315///
316/// The sketch and its band hashes are computed in the search layer
317/// ([`sbol_db_search::minhash`]); this store persists a sequence's
318/// [`Signature`] and the LSH band buckets it falls into, and serves the
319/// candidate-generation query. Candidate generation is a posting-list union
320/// over band hashes ([`candidates_by_bands`](Self::candidates_by_bands)): the
321/// sequences sharing at least one band with a query, which the banded aligner
322/// then verifies. The store never sketches or aligns, so the algorithm stays in
323/// the pure search crate and rust-bio stays out of the backends.
324#[async_trait]
325pub trait SketchStore: Send + Sync {
326    /// Persist `iri`'s signature and its band buckets, replacing any prior
327    /// sketch and bands for that IRI so a re-index leaves no stale postings.
328    async fn put_sketch(
329        &self,
330        iri: &str,
331        signature: &Signature,
332        bands: &[u64],
333    ) -> Result<(), DomainError>;
334
335    /// The stored signature for `iri`, or `None` when it is unsketched.
336    async fn sketch_of(&self, iri: &str) -> Result<Option<Signature>, DomainError>;
337
338    /// The distinct sequence IRIs sharing at least one of `bands`, the LSH
339    /// candidate set for a query with those band hashes. Order is unspecified.
340    async fn candidates_by_bands(&self, bands: &[u64]) -> Result<Vec<String>, DomainError>;
341
342    /// Every stored `(sequence_iri, signature)` pair, for a full rebuild. Order
343    /// is unspecified.
344    async fn all_sketches(&self) -> Result<Vec<(String, Signature)>, DomainError>;
345
346    /// Replace every stored sketch and band posting with `entries` in one
347    /// transaction: after it returns the index reflects exactly these
348    /// `(sequence_iri, signature, band_hashes)` and nothing prior. The
349    /// search-index rebuild swaps the whole index in through this, the sketch
350    /// counterpart of [`ClusterStore::replace_clusters`] and
351    /// [`PageRankStore::replace_all_ranks`], so a rebuild never leaves a stale
352    /// posting behind.
353    async fn replace_all_sketches(
354        &self,
355        entries: Vec<(String, Signature, Vec<u64>)>,
356    ) -> Result<(), DomainError>;
357}
358
359/// Substring search over the derived object view.
360#[async_trait]
361pub trait TextSearchStore: Send + Sync {
362    /// Search objects by substring, returning one page plus the total match
363    /// count. A `limit` of 0 returns an empty page with the total only, so a
364    /// caller wanting just the count pays for no row materialization.
365    async fn search_objects(
366        &self,
367        query: &TextSearchQuery,
368    ) -> Result<(Vec<SbolObjectRecord>, i64), DomainError>;
369}
370
371/// Persistence for object PageRank scores backing the native ranked search.
372///
373/// The search-index rebuild recomputes every score and calls
374/// [`replace_all_ranks`](Self::replace_all_ranks) to swap the whole table
375/// atomically; readers point-look-up a score or scan them all to feed the text
376/// index. The store itself is ranking-free: it persists what the rebuild
377/// computes and returns it unchanged.
378#[async_trait]
379pub trait PageRankStore: Send + Sync {
380    /// The stored PageRank score for one object IRI, or `None` when the IRI
381    /// carries no score. The combine step reads a missing score as `1.0`,
382    /// SBOLExplorer's unknown-part convention.
383    async fn rank_of(&self, iri: &str) -> Result<Option<f64>, DomainError>;
384
385    /// The stored scores for a set of IRIs, keyed by IRI. IRIs with no stored
386    /// score are absent from the map rather than defaulted, so the caller can
387    /// tell "unranked" from a real score.
388    async fn ranks_for(&self, iris: &[String]) -> Result<HashMap<String, f64>, DomainError>;
389
390    /// Every stored `(iri, score)` pair, feeding a full text-index rebuild.
391    async fn all_ranks(&self) -> Result<Vec<RankRow>, DomainError>;
392
393    /// Replace the entire rank table with `ranks` in one transaction: after it
394    /// returns the table reflects exactly this write and nothing prior.
395    async fn replace_all_ranks(&self, ranks: Vec<RankRow>) -> Result<(), DomainError>;
396}
397
398/// The SynBioHub terms namespace, whose `ownedBy`/`canView` predicates carry
399/// the ownership and sharing facts an [`AclStore`] reads.
400pub const SBH_OWNED_BY: &str = "http://wiki.synbiohub.org/wiki/Terms/synbiohub#ownedBy";
401/// The SynBioHub `canView` predicate: `<owner> sbh:canView <object>`.
402pub const SBH_CAN_VIEW: &str = "http://wiki.synbiohub.org/wiki/Terms/synbiohub#canView";
403
404/// Ownership and sharing reads backing ACL-scoped queries.
405///
406/// SynBioHub records visibility as triples: a graph's contents carry
407/// `<subject> sbh:ownedBy <owner>` for each owner, and a shared object is
408/// reachable through `<owner> sbh:canView <object>`. These reads let the app's
409/// ACL layer turn a caller identity into the graphs and objects it may read;
410/// the store itself stays authorization-free.
411#[async_trait]
412pub trait AclStore: Send + Sync {
413    /// Named graphs whose contents carry `<subject> sbh:ownedBy <owner_iri>`,
414    /// i.e. the graphs `owner_iri` owns and may read. Distinct, order
415    /// unspecified.
416    async fn owned_graphs(&self, owner_iri: &str) -> Result<Vec<String>, DomainError>;
417
418    /// Objects shared with `owner_iri` through `<owner_iri> sbh:canView
419    /// <object>`. Distinct, order unspecified.
420    async fn viewable_objects(&self, owner_iri: &str) -> Result<Vec<String>, DomainError>;
421}
422
423/// Distinct named-graph IRIs among `triples`, dropping any in the default
424/// partition. Backs [`AclStore::owned_graphs`] once a backend has scanned the
425/// `sbh:ownedBy` triples.
426pub fn distinct_graph_iris(triples: Vec<Triple>) -> Vec<String> {
427    let mut seen = std::collections::BTreeSet::new();
428    for t in triples {
429        if let Some(g) = t.graph_iri {
430            seen.insert(g.into_inner());
431        }
432    }
433    seen.into_iter().collect()
434}
435
436/// Distinct object-position IRIs among `triples`. Backs
437/// [`AclStore::viewable_objects`] once a backend has scanned the `sbh:canView`
438/// triples.
439pub fn distinct_object_iris(triples: Vec<Triple>) -> Vec<String> {
440    let mut seen = std::collections::BTreeSet::new();
441    for t in triples {
442        if let ObjectTerm::Iri(o) = t.object {
443            seen.insert(o.into_inner());
444        }
445    }
446    seen.into_iter().collect()
447}
448
449/// Account persistence for the identity layer.
450///
451/// A separate trait object from [`SbolStore`]: the SBOL store stays
452/// authorization-free while the application facade holds this alongside it.
453/// Implementations map rows to [`User`] and enforce the unique `username` and
454/// `email` constraints. Password hashing lives in the application layer; these
455/// methods store and read the already-computed `password_hash`.
456#[async_trait]
457pub trait UserStore: Send + Sync {
458    /// Create an account, assigning a fresh [`UserId`], and return it.
459    async fn create_user(&self, new_user: NewUser) -> Result<User, DomainError>;
460
461    /// Resolve an account by an identifier matching either its `email` or its
462    /// `username`, mirroring SynBioHub's login lookup. `None` when neither
463    /// matches.
464    async fn find_by_email_or_username(
465        &self,
466        identifier: &str,
467    ) -> Result<Option<User>, DomainError>;
468
469    /// Fetch an account by id. `None` when no such account exists.
470    async fn get_by_id(&self, id: UserId) -> Result<Option<User>, DomainError>;
471
472    /// Every account ordered by username. Administrator projections use this
473    /// instead of backend-specific SQL or column-family scans.
474    async fn list_users(&self) -> Result<Vec<User>, DomainError>;
475
476    /// Filter and page accounts inside the backend. Implementations should not
477    /// materialize the complete account table merely to serve one Admin page.
478    async fn page_users(&self, query: &UserListQuery) -> Result<UserPage, DomainError> {
479        let needle = query
480            .text
481            .as_deref()
482            .map(str::trim)
483            .filter(|value| !value.is_empty())
484            .map(str::to_lowercase);
485        let mut users = self.list_users().await?;
486        if let Some(needle) = needle {
487            users.retain(|user| user_matches(user, &needle));
488        }
489        let total = users.len() as u64;
490        let items = users
491            .into_iter()
492            .skip(query.offset as usize)
493            .take(query.limit as usize)
494            .collect();
495        Ok(UserPage { total, items })
496    }
497
498    /// Persist the mutable profile fields (`name`, `email`, `affiliation`, and
499    /// the membership flags) of `user`, returning the stored account.
500    async fn update_user(&self, user: &User) -> Result<User, DomainError>;
501
502    /// Atomically make `id` the only administrator account.
503    ///
504    /// Implementations must first verify that the target exists, then promote
505    /// it and demote every other account as one transaction/batch. This keeps
506    /// offline recovery tooling from leaving an instance with no administrator
507    /// or a partially updated administrator set after a failure.
508    async fn set_sole_admin(&self, id: UserId) -> Result<(), DomainError>;
509
510    /// Replace an account's stored password hash, e.g. transparent rehashing of
511    /// a legacy digest to argon2 on a successful login.
512    async fn set_password_hash(&self, id: UserId, password_hash: &str) -> Result<(), DomainError>;
513
514    /// Set (`Some`) or clear (`None`) an account's outstanding reset link.
515    async fn set_reset_link(&self, id: UserId, link: Option<&str>) -> Result<(), DomainError>;
516
517    /// Atomically claim the account whose outstanding reset link equals `link`,
518    /// clearing the link so it cannot be reused. Returns the claimed account, or
519    /// `None` when no account carries that link.
520    async fn consume_reset_link(&self, link: &str) -> Result<Option<User>, DomainError>;
521
522    /// Delete the account with `id`, returning whether a row was removed
523    /// (`false` when no such account exists).
524    async fn delete_user(&self, id: UserId) -> Result<bool, DomainError>;
525
526    /// Whether any administrator account exists. First-launch setup uses this to
527    /// decide whether an instance still needs its initial administrator.
528    async fn any_admin(&self) -> Result<bool, DomainError>;
529}
530
531#[derive(Clone, Debug, Default)]
532pub struct UserListQuery {
533    pub text: Option<String>,
534    pub limit: u32,
535    pub offset: u64,
536}
537
538#[derive(Clone, Debug, Default)]
539pub struct UserPage {
540    pub total: u64,
541    pub items: Vec<User>,
542}
543
544pub fn user_matches(user: &User, needle: &str) -> bool {
545    user.username.to_lowercase().contains(needle)
546        || user.name.to_lowercase().contains(needle)
547        || user.email.to_lowercase().contains(needle)
548        || user
549            .affiliation
550            .as_deref()
551            .is_some_and(|value| value.to_lowercase().contains(needle))
552}
553
554/// API-token persistence for the identity layer.
555///
556/// Tokens are stored only as their hash; the application layer computes the
557/// hash (sha3 of the plaintext token) so a persisted row cannot be replayed.
558/// A separate trait object from [`SbolStore`], held by the application facade.
559#[async_trait]
560pub trait TokenStore: Send + Sync {
561    /// Persist a token hash for `user_id`, associating the token with the
562    /// account it authenticates.
563    async fn issue(&self, token_hash: &str, user_id: UserId) -> Result<(), DomainError>;
564
565    /// Resolve a token hash to the account it authenticates, or `None` when no
566    /// live token carries that hash.
567    async fn resolve(&self, token_hash: &str) -> Result<Option<UserId>, DomainError>;
568
569    /// Revoke the token with the given hash, returning whether a token was
570    /// removed.
571    async fn revoke(&self, token_hash: &str) -> Result<bool, DomainError>;
572}
573
574/// The full SBOL-aware store: ingest plus every derived-view read surface.
575#[async_trait]
576pub trait SbolStore:
577    ObjectStore
578    + GraphStore
579    + ResourceCatalogStore
580    + NamedGraphCatalogStore
581    + CorpusStatsStore
582    + SequenceCatalogStore
583    + OntologyStore
584    + NeighborhoodStore
585    + SequenceSearchStore
586    + TextSearchStore
587{
588    async fn import_document(&self, input: ImportInput) -> Result<ImportReport, DomainError>;
589    async fn import_documents(
590        &self,
591        inputs: Vec<ImportInput>,
592    ) -> Result<Vec<ImportReport>, DomainError>;
593    async fn graph_store_write(
594        &self,
595        graph: &str,
596        body: &str,
597        format: SerializationFormat,
598        mode: GraphWriteMode,
599    ) -> Result<usize, DomainError>;
600    async fn graph_store_clear(&self, graph: &str) -> Result<usize, DomainError>;
601    async fn graph_store_read(&self, graph: &str) -> Result<Vec<Triple>, DomainError>;
602    /// One stable keyset page from a named graph. The opaque cursor belongs to
603    /// the backend and allows HTTP exports and migration verification to avoid
604    /// both OFFSET scans and whole-graph memory limits.
605    async fn graph_store_read_page(
606        &self,
607        graph: &str,
608        after: Option<&str>,
609        limit: usize,
610    ) -> Result<TripleScanPage, DomainError>;
611    /// Every triple with the given subject IRI, for single-object RDF export.
612    async fn triples_for_subject(&self, subject_iri: &str) -> Result<Vec<Triple>, DomainError>;
613    async fn ping(&self) -> Result<(), DomainError>;
614}
615
616/// The job queue: enqueue, lease-based dequeue, lifecycle transitions, and the
617/// operator/observability read surface.
618#[async_trait]
619pub trait JobQueue: Send + Sync {
620    async fn enqueue(&self, input: NewJob) -> Result<EnqueueOutcome, DomainError>;
621    async fn append_log(
622        &self,
623        job_id: JobId,
624        attempt_no: Option<i32>,
625        level: &str,
626        message: &str,
627        fields: Value,
628    ) -> Result<JobLogRecord, DomainError>;
629    async fn list_logs(
630        &self,
631        id: JobId,
632        after_id: Option<i64>,
633        limit: u32,
634    ) -> Result<Vec<JobLogRecord>, DomainError>;
635    async fn dequeue(
636        &self,
637        queues: &[String],
638        worker_id: &str,
639        lease: Duration,
640    ) -> Result<Option<SbolJob>, DomainError>;
641    async fn renew_lease(
642        &self,
643        job_id: JobId,
644        worker_id: &str,
645        lease: Duration,
646    ) -> Result<bool, DomainError>;
647    async fn mark_succeeded(
648        &self,
649        job_id: JobId,
650        worker_id: &str,
651        result: Option<Value>,
652    ) -> Result<(), DomainError>;
653    async fn mark_failed(
654        &self,
655        job_id: JobId,
656        worker_id: &str,
657        error: &str,
658    ) -> Result<JobStatus, DomainError>;
659    async fn reap_expired_leases(&self) -> Result<u64, DomainError>;
660    async fn get(&self, id: JobId) -> Result<Option<SbolJob>, DomainError>;
661    async fn list_attempts(&self, id: JobId) -> Result<Vec<JobAttempt>, DomainError>;
662    async fn list(&self, filter: &ListJobsFilter) -> Result<Vec<SbolJob>, DomainError>;
663    async fn cancel(&self, id: JobId) -> Result<bool, DomainError>;
664    async fn current_status(&self, id: JobId) -> Result<Option<JobStatus>, DomainError>;
665    async fn queue_depth_snapshot(&self) -> Result<Vec<QueueDepthRow>, DomainError>;
666    async fn oldest_queued_age(&self) -> Result<Vec<OldestQueuedAge>, DomainError>;
667
668    /// Count jobs that reached a terminal failure (`failed` or `dead`) in the
669    /// last `within_hours`. Backed by [`Self::list`] so every backend reports
670    /// the same number without backend-specific SQL; the dashboard's
671    /// failures tile reads it.
672    async fn recent_failure_count(&self, within_hours: i64) -> Result<i64, DomainError> {
673        let since = chrono::Utc::now() - chrono::Duration::hours(within_hours.max(0));
674        let mut total = 0i64;
675        for status in [JobStatus::Failed, JobStatus::Dead] {
676            let filter = ListJobsFilter {
677                kind: None,
678                status: Some(status),
679                queue: None,
680                correlation_id: None,
681                since: Some(since),
682                limit: 10_000,
683            };
684            total += self.list(&filter).await?.len() as i64;
685        }
686        Ok(total)
687    }
688}
689
690/// Content-addressed blob storage for attachment payloads, orthogonal to the
691/// triplestore and held separately in the application facade rather than
692/// reached through [`SbolStore`]. The contract is content-addressing by the
693/// SHA-1 of the *uncompressed* bytes with gzip at rest: a blob's identity is
694/// that hash, so writing identical bytes twice yields the same [`BlobRef`] and
695/// one stored object.
696#[async_trait]
697pub trait BlobStore: Send + Sync {
698    /// Store `bytes`, returning the content address, uncompressed size, and
699    /// sniffed media type. Idempotent: storing bytes already present is a no-op
700    /// that returns the same [`BlobRef`].
701    async fn put(&self, bytes: &[u8]) -> Result<BlobRef, DomainError>;
702
703    /// Fetch the decompressed bytes for content address `sha1`, or `None` when
704    /// no blob with that hash is stored.
705    async fn get(&self, sha1: &str) -> Result<Option<Vec<u8>>, DomainError>;
706
707    /// Fetch the raw gzip bytes for `sha1` so a download can stream them under
708    /// `Content-Encoding: gzip` without recompressing, or `None` when absent.
709    async fn get_gz(&self, sha1: &str) -> Result<Option<Vec<u8>>, DomainError>;
710
711    /// Whether a blob with content address `sha1` is stored.
712    async fn exists(&self, sha1: &str) -> Result<bool, DomainError>;
713}
714
715/// Durable instance configuration, a flat key to JSON-value store.
716///
717/// This is the persistent equivalent of classic SynBioHub's mutable
718/// `config.local.json`: each section (registries, remotes, plugins, mail,
719/// theme, and the like) lives under one stable key with an arbitrary JSON
720/// value. Held separately in the application facade rather than reached through
721/// [`SbolStore`], mirroring [`PageRankStore`] and [`ClusterStore`], so the SBOL
722/// store stays free of application configuration. The application's
723/// `ConfigService` layers typed accessors and admin-gated mutation on top.
724#[async_trait]
725pub trait ConfigStore: Send + Sync {
726    /// The value stored under `key`, or `None` when the key has never been set.
727    async fn get(&self, key: &str) -> Result<Option<Value>, DomainError>;
728
729    /// Write `value` under `key`, upserting: a later write to the same key
730    /// overwrites the earlier value and refreshes its `updated_at`.
731    async fn set(&self, key: &str, value: &Value) -> Result<(), DomainError>;
732
733    /// Every stored entry. Order is unspecified.
734    async fn get_all(&self) -> Result<Vec<ConfigEntry>, DomainError>;
735
736    /// Remove the entry under `key`. Deleting an absent key is a no-op.
737    async fn delete(&self, key: &str) -> Result<(), DomainError>;
738}
739
740#[cfg(test)]
741mod blob_store_object_safety {
742    /// Proves [`BlobStore`](super::BlobStore) is object-safe, so the facade can
743    /// hold it as `Arc<dyn BlobStore>`.
744    fn _assert_obj_safe(_: &dyn super::BlobStore) {}
745
746    /// Proves [`ConfigStore`](super::ConfigStore) is object-safe, so the facade
747    /// can hold it as `Arc<dyn ConfigStore>`.
748    fn _assert_config_obj_safe(_: &dyn super::ConfigStore) {}
749}