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}