Skip to main content

wsb_github/
mirror.rs

1//! The machine's copy of what GitHub said.
2//!
3//! # Why a mirror rather than a cache
4//!
5//! Every list used to be fetched from the API when a page opened. That made
6//! the page as fast as the network, as available as the credential, and as
7//! honest as the last request — and when a request failed, the list rendered
8//! EMPTY. A missing token, a rate limit or a closed laptop each produced "you
9//! have no gists", which is a lie told confidently.
10//!
11//! So a copy is kept and every reader reads the copy. Reads never touch the
12//! network, never need a token, and never fail because GitHub is having a
13//! morning. What a read CAN say is how old its answer is and whether the last
14//! refresh failed, which is the honest version of the same page.
15//!
16//! # One per machine
17//!
18//! GitHub's repositories, gists, notifications and project boards belong to
19//! accounts, not to any checkout, and one board spans many repositories. So
20//! there is one mirror for every project under `$PRJ`, in `$PRJ/.state`, and
21//! every process that reads or writes GitHub through this crate shares it.
22//!
23//! # What is stored
24//!
25//! Whole payloads, as GitHub sent them. The columns beside them exist only to
26//! be queried on — state, update time — and everything a reader renders comes
27//! out of the payload.
28
29use std::collections::{BTreeMap, BTreeSet};
30use std::path::{Path, PathBuf};
31use std::time::Duration;
32
33use anyhow::{bail, Context, Result};
34use chrono::Utc;
35use r2d2::PooledConnection;
36use r2d2_sqlite::SqliteConnectionManager;
37use rusqlite::{params, OptionalExtension, Transaction};
38use serde::{Deserialize, Serialize};
39
40use crate::client::GitHubGist;
41
42/// The schema version this build reads and writes.
43const SCHEMA_VERSION: i64 = 1;
44
45/// How many changes the change log keeps. A reader further behind than this
46/// re-reads everything rather than missing what fell off the end.
47const CHANGES_KEPT: i64 = 10_000;
48
49/// The file the mirror lives in, under `$PRJ/.state`.
50pub const FILE: &str = "github.db";
51
52/// What one mirrored thing is called, and what a sync is about.
53///
54/// One vocabulary for every kind: the sync fetches, compares and records them
55/// identically, and a vocabulary per kind would mean a copy of every query
56/// that asks what is stale, what failed, or what moved.
57///
58/// The string form is what the database and every change notice carry, so it
59/// is part of the contract rather than a debugging convenience.
60#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
61pub enum Scope {
62    /// One repository's issues, as `owner/repo`.
63    Issues(String),
64    /// One repository's pull requests.
65    Pulls(String),
66    /// What CI says about one commit of one repository.
67    ///
68    /// Per commit, because the same repository is checked out at different
69    /// commits in different worktrees, and CI for the work in front of a
70    /// person is CI for the commit that worktree is on.
71    Checks { repo: String, commit: String },
72    Releases(String),
73    Tags(String),
74    /// What the repository IS: private or public, archived, its topics, its
75    /// stars.
76    Repository(String),
77    Milestones(String),
78    Labels(String),
79    /// Views and clones. Needs push access, so this is the one scope whose
80    /// refusal is ordinary.
81    Traffic(String),
82    /// Comments on one issue or pull request: `owner/repo#number`.
83    Comments { repo: String, number: i64 },
84    /// The authenticated user's gists.
85    Gists,
86    /// What GitHub thinks needs this person, across every repository.
87    Notifications,
88    /// How much API budget is left.
89    RateLimit,
90    /// Who the token belongs to, and the organisations it is a member of —
91    /// the owners whose boards are this person's without anybody naming them.
92    Viewer,
93    /// Which project boards one owner has.
94    Projects(String),
95    /// One board's cards, by the board's node id.
96    ProjectItems(String),
97}
98
99impl Scope {
100    pub fn key(&self) -> String {
101        match self {
102            Self::Issues(repo) => format!("issues:{repo}"),
103            Self::Pulls(repo) => format!("pulls:{repo}"),
104            Self::Checks { repo, commit } => format!("checks:{repo}@{commit}"),
105            Self::Releases(repo) => format!("releases:{repo}"),
106            Self::Tags(repo) => format!("tags:{repo}"),
107            Self::Repository(repo) => format!("repository:{repo}"),
108            Self::Milestones(repo) => format!("milestones:{repo}"),
109            Self::Labels(repo) => format!("labels:{repo}"),
110            Self::Traffic(repo) => format!("traffic:{repo}"),
111            Self::Comments { repo, number } => format!("comments:{repo}#{number}"),
112            Self::Gists => "gists".to_string(),
113            Self::Notifications => "notifications".to_string(),
114            Self::RateLimit => "rate_limit".to_string(),
115            Self::Viewer => "viewer".to_string(),
116            Self::Projects(login) => format!("projects:{login}"),
117            Self::ProjectItems(id) => format!("project_items:{id}"),
118        }
119    }
120
121    /// The repository this scope is about, when it is about one.
122    pub fn repository(&self) -> Option<&str> {
123        match self {
124            Self::Issues(repo)
125            | Self::Pulls(repo)
126            | Self::Checks { repo, .. }
127            | Self::Releases(repo)
128            | Self::Tags(repo)
129            | Self::Repository(repo)
130            | Self::Milestones(repo)
131            | Self::Labels(repo)
132            | Self::Traffic(repo)
133            | Self::Comments { repo, .. } => Some(repo),
134            Self::Gists | Self::Notifications | Self::RateLimit | Self::Viewer => None,
135            Self::Projects(_) | Self::ProjectItems(_) => None,
136        }
137    }
138
139    /// Read one back from what the database and a change notice carry.
140    ///
141    /// Every scope round-trips, or the process that wrote a row cannot say
142    /// what the row is about — see the round-trip test.
143    pub fn parse(key: &str) -> Option<Self> {
144        if let Some((kind, rest)) = key.split_once(':') {
145            if rest.is_empty() {
146                return None;
147            }
148            return Some(match kind {
149                "issues" => Self::Issues(rest.to_string()),
150                "pulls" => Self::Pulls(rest.to_string()),
151                "checks" => {
152                    let (repo, commit) = rest.rsplit_once('@')?;
153                    if repo.is_empty() || commit.is_empty() {
154                        return None;
155                    }
156                    Self::Checks {
157                        repo: repo.to_string(),
158                        commit: commit.to_string(),
159                    }
160                }
161                "releases" => Self::Releases(rest.to_string()),
162                "tags" => Self::Tags(rest.to_string()),
163                "repository" => Self::Repository(rest.to_string()),
164                "milestones" => Self::Milestones(rest.to_string()),
165                "labels" => Self::Labels(rest.to_string()),
166                "traffic" => Self::Traffic(rest.to_string()),
167                "comments" => {
168                    let (repo, number) = rest.rsplit_once('#')?;
169                    Self::Comments {
170                        repo: repo.to_string(),
171                        number: number.parse().ok()?,
172                    }
173                }
174                "projects" => Self::Projects(rest.to_string()),
175                "project_items" => Self::ProjectItems(rest.to_string()),
176                _ => return None,
177            });
178        }
179        match key {
180            "gists" => Some(Self::Gists),
181            "notifications" => Some(Self::Notifications),
182            "rate_limit" => Some(Self::RateLimit),
183            "viewer" => Some(Self::Viewer),
184            _ => None,
185        }
186    }
187
188    /// What a person calls it, for a message about it.
189    pub fn label(&self) -> String {
190        match self {
191            Self::Comments { repo, number } => format!("{repo}#{number} comments"),
192            Self::Checks { repo, commit } => {
193                format!("checks of {repo} at {}", &commit[..commit.len().min(12)])
194            }
195            Self::Gists => "gists".to_string(),
196            Self::Notifications => "notifications".to_string(),
197            Self::RateLimit => "the rate limit".to_string(),
198            Self::Viewer => "the token's account".to_string(),
199            Self::Projects(login) => format!("{login}'s projects"),
200            Self::ProjectItems(id) => format!("project {id}"),
201            other => other.repository().unwrap_or_default().to_string(),
202        }
203    }
204
205    /// Whether a whole answer replaces what is held.
206    ///
207    /// A LIST endpoint says what exists: a gist absent from it was deleted, a
208    /// release absent from it was removed. An INCREMENTAL one says only what
209    /// changed, so what it does not mention is still true — and clearing on
210    /// one of those empties the mirror every time nothing happened.
211    ///
212    /// Issues and pull requests are fetched with `since`, so they are
213    /// incremental. Everything else is asked for whole.
214    pub fn is_authoritative(&self) -> bool {
215        !matches!(self, Self::Issues(_) | Self::Pulls(_))
216    }
217}
218
219/// Where one scope stands: how current it is, and what went wrong last.
220///
221/// `synced_at` and `last_error` are both meaningful at once, and that pairing
222/// is the point: a scope that synced an hour ago and failed a minute ago has
223/// data worth showing AND a problem worth reporting.
224#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
225pub struct SyncState {
226    /// GitHub's ETag for the last response, replayed as `If-None-Match`.
227    pub etag: Option<String>,
228    /// The newest `updated_at` seen, for incremental fetches.
229    pub cursor: Option<String>,
230    /// When the last SUCCESSFUL sync finished.
231    pub synced_at: Option<String>,
232    /// When the last attempt was made, successful or not.
233    pub attempted_at: Option<String>,
234    pub last_error: Option<String>,
235}
236
237impl SyncState {
238    /// Whether this scope has ever been mirrored.
239    pub fn ever_synced(&self) -> bool {
240        self.synced_at.is_some()
241    }
242
243    /// What a reader is told about how current an answer is.
244    pub fn standing(&self) -> Standing {
245        Standing {
246            synced_at: self.synced_at.clone(),
247            last_error: self.last_error.clone(),
248            ever_synced: self.ever_synced(),
249        }
250    }
251}
252
253/// How current one scope's answer is, as every reader is told it.
254///
255/// "Never read" and "read, and empty" are different, and so are "read an hour
256/// ago" and "could not be read a minute ago": the three fields say which.
257#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
258pub struct Standing {
259    pub synced_at: Option<String>,
260    pub last_error: Option<String>,
261    pub ever_synced: bool,
262}
263
264/// One document, as GitHub last described it.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct Document {
267    /// What identifies it inside its scope: an issue number, a gist id, a tag
268    /// name, a project item id.
269    pub key: String,
270    pub payload: serde_json::Value,
271    pub state: Option<String>,
272    pub updated_at: Option<String>,
273    /// Whether the payload is the whole thing rather than a list summary.
274    pub complete: bool,
275    pub fetched_at: String,
276}
277
278impl Document {
279    /// One document from a value GitHub sent, with the columns a query needs
280    /// pulled out of it.
281    ///
282    /// The extraction is by field NAME, which is the same name in every kind
283    /// GitHub returns — `state` and `updated_at`. A kind that carries neither
284    /// stores neither, and is read back whole.
285    pub fn of(key: impl Into<String>, payload: serde_json::Value) -> Self {
286        let state = payload
287            .get("state")
288            .and_then(|state| state.as_str())
289            .map(str::to_string);
290        let updated_at = payload
291            .get("updated_at")
292            .or_else(|| payload.get("updatedAt"))
293            .or_else(|| payload.get("published_at"))
294            .and_then(|time| time.as_str())
295            .map(str::to_string);
296        Self {
297            key: key.into(),
298            payload,
299            state,
300            updated_at,
301            complete: true,
302            fetched_at: Utc::now().to_rfc3339(),
303        }
304    }
305
306    /// The same, for a payload that is a summary rather than the whole thing.
307    pub fn summary(key: impl Into<String>, payload: serde_json::Value) -> Self {
308        Self {
309            complete: false,
310            ..Self::of(key, payload)
311        }
312    }
313
314    /// The same, with a state this kind does not carry in its payload.
315    pub fn with_state(mut self, state: impl Into<String>) -> Self {
316        self.state = Some(state.into());
317        self
318    }
319}
320
321/// What changed since a reader last looked.
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub enum Changes {
324    /// These scopes moved, and `latest` is the change to ask after next time.
325    Moved { scopes: Vec<String>, latest: i64 },
326    /// The reader is further behind than the log reaches: anything may have
327    /// moved. Never reported as "nothing moved", which would leave a page
328    /// showing what is no longer held.
329    Lost { latest: i64 },
330}
331
332#[derive(Clone)]
333pub struct Mirror {
334    pool: r2d2::Pool<SqliteConnectionManager>,
335    path: PathBuf,
336}
337
338impl std::fmt::Debug for Mirror {
339    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340        f.debug_struct("Mirror").field("path", &self.path).finish()
341    }
342}
343
344impl Mirror {
345    /// The machine's mirror, in `$PRJ/.state`.
346    pub fn of(prj: &wsb_prj::Prj) -> Result<Self> {
347        Self::open(&prj.state_dir().join(FILE))
348    }
349
350    /// The mirror in one file, created if it does not exist.
351    ///
352    /// A file written by another version of this schema is refused: reading
353    /// one optimistically is how a page comes to render half a board.
354    pub fn open(path: &Path) -> Result<Self> {
355        if let Some(parent) = path.parent() {
356            std::fs::create_dir_all(parent)
357                .with_context(|| format!("cannot create {}", parent.display()))?;
358        }
359        let manager = SqliteConnectionManager::file(path).with_init(|connection| {
360            // Several processes share this file. WAL lets readers proceed
361            // while one writes, and the busy timeout makes a writer wait its
362            // turn rather than fail on a lock another process holds for a
363            // millisecond.
364            connection.busy_timeout(Duration::from_secs(10))?;
365            connection.pragma_update(None, "journal_mode", "WAL")?;
366            connection.pragma_update(None, "synchronous", "NORMAL")?;
367            Ok(())
368        });
369        let pool = r2d2::Pool::builder()
370            .max_size(4)
371            .build(manager)
372            .with_context(|| format!("cannot open the GitHub mirror at {}", path.display()))?;
373        let mirror = Self {
374            pool,
375            path: path.to_path_buf(),
376        };
377        mirror.migrate()?;
378        Ok(mirror)
379    }
380
381    pub fn path(&self) -> &Path {
382        &self.path
383    }
384
385    fn migrate(&self) -> Result<()> {
386        let mut conn = self.connection()?;
387        let transaction = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
388        let version: i64 = transaction.query_row("PRAGMA user_version", [], |row| row.get(0))?;
389        match version {
390            0 => {
391                transaction.execute_batch(include_str!("schema.sql"))?;
392                transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?;
393            }
394            SCHEMA_VERSION => {}
395            other => bail!(
396                "the GitHub mirror at {} is schema version {other}, and this build reads version \
397                 {SCHEMA_VERSION}; upgrade the program that reads it",
398                self.path.display()
399            ),
400        }
401        transaction.commit()?;
402        Ok(())
403    }
404
405    fn connection(&self) -> Result<PooledConnection<SqliteConnectionManager>> {
406        self.pool
407            .get()
408            .with_context(|| format!("cannot reach the GitHub mirror at {}", self.path.display()))
409    }
410
411    // ---------------------------------------------------------------- state
412
413    pub fn state(&self, scope: &Scope) -> Result<SyncState> {
414        let conn = self.connection()?;
415        let state = conn
416            .query_row(
417                "SELECT etag, cursor, synced_at, attempted_at, last_error
418                 FROM github_sync_state WHERE scope = ?1",
419                params![scope.key()],
420                |row| {
421                    Ok(SyncState {
422                        etag: row.get(0)?,
423                        cursor: row.get(1)?,
424                        synced_at: row.get(2)?,
425                        attempted_at: row.get(3)?,
426                        last_error: row.get(4)?,
427                    })
428                },
429            )
430            .optional()?;
431        Ok(state.unwrap_or_default())
432    }
433
434    /// Every scope this mirror knows about.
435    pub fn states(&self) -> Result<BTreeMap<String, SyncState>> {
436        let conn = self.connection()?;
437        let mut statement = conn.prepare(
438            "SELECT scope, etag, cursor, synced_at, attempted_at, last_error
439             FROM github_sync_state",
440        )?;
441        let rows = statement.query_map([], |row| {
442            Ok((
443                row.get::<_, String>(0)?,
444                SyncState {
445                    etag: row.get(1)?,
446                    cursor: row.get(2)?,
447                    synced_at: row.get(3)?,
448                    attempted_at: row.get(4)?,
449                    last_error: row.get(5)?,
450                },
451            ))
452        })?;
453        let mut out = BTreeMap::new();
454        for row in rows {
455            let (scope, state) = row?;
456            out.insert(scope, state);
457        }
458        Ok(out)
459    }
460
461    /// Record a sync that worked.
462    ///
463    /// Clears `last_error`: the scope is current, and a stale error beside
464    /// current data is a page reporting a problem that is over.
465    pub fn synced(&self, scope: &Scope, etag: Option<&str>, cursor: Option<&str>) -> Result<()> {
466        let now = Utc::now().to_rfc3339();
467        let conn = self.connection()?;
468        conn.execute(
469            "INSERT INTO github_sync_state (scope, etag, cursor, synced_at, attempted_at, last_error)
470             VALUES (?1, ?2, ?3, ?4, ?4, NULL)
471             ON CONFLICT(scope) DO UPDATE SET
472                 -- A conditional request that answered 304 carries no ETag of
473                 -- its own; the one we sent is still the current one.
474                 etag = COALESCE(excluded.etag, github_sync_state.etag),
475                 cursor = COALESCE(excluded.cursor, github_sync_state.cursor),
476                 synced_at = excluded.synced_at,
477                 attempted_at = excluded.attempted_at,
478                 last_error = NULL",
479            params![scope.key(), etag, cursor, now],
480        )?;
481        Ok(())
482    }
483
484    /// Record an attempt that failed.
485    ///
486    /// Leaves `synced_at`, the ETag and the cursor alone: the data this scope
487    /// holds is still the data GitHub last gave it, and the next attempt
488    /// should still be conditional.
489    pub fn failed(&self, scope: &Scope, error: &str) -> Result<()> {
490        let now = Utc::now().to_rfc3339();
491        let conn = self.connection()?;
492        conn.execute(
493            "INSERT INTO github_sync_state (scope, attempted_at, last_error)
494             VALUES (?1, ?2, ?3)
495             ON CONFLICT(scope) DO UPDATE SET
496                 attempted_at = excluded.attempted_at,
497                 last_error = excluded.last_error",
498            params![scope.key(), now, error],
499        )?;
500        Ok(())
501    }
502
503    /// Put a scope in the sweep before it has ever been read.
504    ///
505    /// What opening a comment thread does: a thread nobody has opened is not
506    /// worth a request per sweep, and one somebody opened is. Recorded as a
507    /// scope with no data and no attempt, which is exactly "not read yet".
508    pub fn hold(&self, scope: &Scope) -> Result<()> {
509        let conn = self.connection()?;
510        conn.execute(
511            "INSERT INTO github_sync_state (scope) VALUES (?1) ON CONFLICT(scope) DO NOTHING",
512            params![scope.key()],
513        )?;
514        Ok(())
515    }
516
517    /// Forget a scope entirely, data and state.
518    pub fn forget(&self, scope: &Scope) -> Result<()> {
519        let mut conn = self.connection()?;
520        let transaction = conn.transaction()?;
521        let removed = transaction.execute(
522            "DELETE FROM github_document WHERE scope = ?1",
523            params![scope.key()],
524        )?;
525        transaction.execute(
526            "DELETE FROM github_sync_state WHERE scope = ?1",
527            params![scope.key()],
528        )?;
529        if removed > 0 {
530            record_change(&transaction, scope)?;
531        }
532        transaction.commit()?;
533        Ok(())
534    }
535
536    /// Forget everything about one repository.
537    ///
538    /// For a repository no project has any more: its issues, its pull
539    /// requests, its releases and everything else go together, and a list of
540    /// kinds to delete would be a list to keep in step with every kind added
541    /// afterwards.
542    pub fn forget_repository(&self, repo: &str) -> Result<usize> {
543        let scopes: Vec<Scope> = self
544            .states()?
545            .keys()
546            .filter_map(|key| Scope::parse(key))
547            .filter(|scope| scope.repository() == Some(repo))
548            .collect();
549        for scope in &scopes {
550            self.forget(scope)?;
551        }
552        Ok(scopes.len())
553    }
554
555    // ------------------------------------------------------------ documents
556
557    /// Write what a fetch returned, and say whether anything changed.
558    ///
559    /// An AUTHORITATIVE scope replaces its contents: what the answer does not
560    /// mention no longer exists. An incremental one only adds and updates,
561    /// because its answer is "what changed", and clearing on that empties the
562    /// mirror every time nothing did.
563    pub fn put(&self, scope: &Scope, documents: &[Document]) -> Result<bool> {
564        let mut conn = self.connection()?;
565        let transaction = conn.transaction()?;
566        let mut changed = false;
567        if scope.is_authoritative() {
568            changed |= prune_scope(&transaction, scope, documents)?;
569        }
570        for document in documents {
571            changed |= put_document(&transaction, scope, document)?;
572        }
573        if changed {
574            record_change(&transaction, scope)?;
575        }
576        transaction.commit()?;
577        Ok(changed)
578    }
579
580    /// Write one document — what a create or an edit produced.
581    ///
582    /// Never prunes, whatever the scope: one document is not an answer about
583    /// what exists.
584    pub fn put_one(&self, scope: &Scope, document: Document) -> Result<bool> {
585        let mut conn = self.connection()?;
586        let transaction = conn.transaction()?;
587        let changed = put_document(&transaction, scope, &document)?;
588        if changed {
589            record_change(&transaction, scope)?;
590        }
591        transaction.commit()?;
592        Ok(changed)
593    }
594
595    /// Take one document out of a scope — what a deletion on GitHub produced.
596    pub fn remove_one(&self, scope: &Scope, key: &str) -> Result<bool> {
597        let mut conn = self.connection()?;
598        let transaction = conn.transaction()?;
599        let removed = transaction.execute(
600            "DELETE FROM github_document WHERE scope = ?1 AND key = ?2",
601            params![scope.key(), key],
602        )? > 0;
603        if removed {
604            record_change(&transaction, scope)?;
605        }
606        transaction.commit()?;
607        Ok(removed)
608    }
609
610    /// Atomically store one changed board and the complete card set GitHub
611    /// returned after that board changed.
612    ///
613    /// A removed column changes both documents at once: the board loses an
614    /// option and its cards lose that option's values. Committing those in two
615    /// transactions creates a state no GitHub answer ever had if the second
616    /// write fails, so this is one database act.
617    pub fn put_project_write(
618        &self,
619        boards_scope: &Scope,
620        board: Document,
621        items_scope: &Scope,
622        items: &[Document],
623    ) -> Result<()> {
624        if !matches!(boards_scope, Scope::Projects(_)) {
625            bail!(
626                "a project write needs a projects scope, got {}",
627                boards_scope.key()
628            );
629        }
630        match items_scope {
631            Scope::ProjectItems(project) if project == &board.key => {}
632            Scope::ProjectItems(project) => bail!(
633                "project {} cannot be written with the cards of project {project}",
634                board.key
635            ),
636            _ => bail!(
637                "a project write needs a project-items scope, got {}",
638                items_scope.key()
639            ),
640        }
641        let mut conn = self.connection()?;
642        let transaction = conn.transaction()?;
643        if put_document(&transaction, boards_scope, &board)? {
644            record_change(&transaction, boards_scope)?;
645        }
646        let mut items_changed = prune_scope(&transaction, items_scope, items)?;
647        for item in items {
648            items_changed |= put_document(&transaction, items_scope, item)?;
649        }
650        if items_changed {
651            record_change(&transaction, items_scope)?;
652        }
653        transaction.commit()?;
654        Ok(())
655    }
656
657    /// Everything one scope holds, newest movement first.
658    pub fn documents(&self, scope: &Scope) -> Result<Vec<Document>> {
659        let conn = self.connection()?;
660        let mut statement = conn.prepare(
661            "SELECT key, state, updated_at, payload_json, complete, fetched_at
662             FROM github_document WHERE scope = ?1
663             ORDER BY updated_at DESC, key",
664        )?;
665        let rows = statement.query_map(params![scope.key()], document_row)?;
666        let mut documents = Vec::new();
667        for row in rows {
668            documents.push(read_document(scope, row?)?);
669        }
670        Ok(documents)
671    }
672
673    /// Just the payloads, which is what a reader serves.
674    pub fn payloads(&self, scope: &Scope) -> Result<Vec<serde_json::Value>> {
675        Ok(self
676            .documents(scope)?
677            .into_iter()
678            .map(|document| document.payload)
679            .collect())
680    }
681
682    /// One document, when the scope holds it.
683    pub fn document(&self, scope: &Scope, key: &str) -> Result<Option<Document>> {
684        let conn = self.connection()?;
685        let row = conn
686            .query_row(
687                "SELECT key, state, updated_at, payload_json, complete, fetched_at
688                 FROM github_document WHERE scope = ?1 AND key = ?2",
689                params![scope.key(), key],
690                document_row,
691            )
692            .optional()?;
693        row.map(|row| read_document(scope, row)).transpose()
694    }
695
696    /// The single document of a scope that holds exactly one: a repository's
697    /// metadata, a commit's checks, its traffic, the rate limit.
698    pub fn only(&self, scope: &Scope) -> Result<Option<serde_json::Value>> {
699        Ok(self
700            .document(scope, SINGLETON)?
701            .map(|document| document.payload))
702    }
703
704    /// Write the single document of such a scope.
705    pub fn put_only(&self, scope: &Scope, payload: serde_json::Value) -> Result<bool> {
706        self.put(scope, &[Document::of(SINGLETON, payload)])
707    }
708
709    /// How many documents of each state one scope holds.
710    ///
711    /// Counted in SQL: an explorer asks this for every row it draws, and
712    /// reading every payload to answer would make a redraw proportional to the
713    /// whole mirror.
714    pub fn counts_by_state(&self, scope: &Scope) -> Result<BTreeMap<String, i64>> {
715        let conn = self.connection()?;
716        let mut statement = conn.prepare(
717            "SELECT COALESCE(state, ''), COUNT(*) FROM github_document
718             WHERE scope = ?1 GROUP BY 1",
719        )?;
720        let rows = statement.query_map(params![scope.key()], |row| {
721            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
722        })?;
723        let mut counts = BTreeMap::new();
724        for row in rows {
725            let (state, count) = row?;
726            counts.insert(state, count);
727        }
728        Ok(counts)
729    }
730
731    /// How many documents are open, per repository, across every scope of one
732    /// kind (`issues` or `pulls`).
733    ///
734    /// One query for a whole tree rather than one per row.
735    pub fn open_counts(&self, kind: &str) -> Result<BTreeMap<String, i64>> {
736        let conn = self.connection()?;
737        let mut statement = conn.prepare(
738            "SELECT scope, COUNT(*) FROM github_document
739             WHERE scope LIKE ?1 AND state = 'open' GROUP BY scope",
740        )?;
741        let rows = statement.query_map(params![format!("{kind}:%")], |row| {
742            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
743        })?;
744        let mut counts = BTreeMap::new();
745        for row in rows {
746            let (scope, count) = row?;
747            let repo = Scope::parse(&scope)
748                .and_then(|scope| scope.repository().map(str::to_string))
749                .ok_or_else(|| anyhow::anyhow!("the mirror holds a scope it cannot read: {scope}"))?;
750            counts.insert(repo, count);
751        }
752        Ok(counts)
753    }
754
755    // -------------------------------------------------------------- changes
756
757    /// The newest change recorded, or 0 when nothing ever changed.
758    pub fn latest_change(&self) -> Result<i64> {
759        let conn = self.connection()?;
760        Ok(conn.query_row(
761            "SELECT COALESCE(MAX(seq), 0) FROM github_change",
762            [],
763            |row| row.get(0),
764        )?)
765    }
766
767    /// Which scopes changed after change `since`, written by whichever process.
768    pub fn changes_since(&self, since: i64) -> Result<Changes> {
769        let conn = self.connection()?;
770        let (oldest, latest): (Option<i64>, i64) = conn.query_row(
771            "SELECT MIN(seq), COALESCE(MAX(seq), 0) FROM github_change",
772            [],
773            |row| Ok((row.get(0)?, row.get(1)?)),
774        )?;
775        if latest <= since {
776            return Ok(Changes::Moved {
777                scopes: Vec::new(),
778                latest,
779            });
780        }
781        if let Some(oldest) = oldest {
782            if since + 1 < oldest {
783                return Ok(Changes::Lost { latest });
784            }
785        }
786        let mut statement =
787            conn.prepare("SELECT DISTINCT scope FROM github_change WHERE seq > ?1 ORDER BY scope")?;
788        let scopes = statement
789            .query_map(params![since], |row| row.get::<_, String>(0))?
790            .collect::<Result<Vec<_>, _>>()?;
791        Ok(Changes::Moved { scopes, latest })
792    }
793}
794
795type DocumentRow = (String, Option<String>, Option<String>, String, i64, String);
796
797fn document_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<DocumentRow> {
798    Ok((
799        row.get(0)?,
800        row.get(1)?,
801        row.get(2)?,
802        row.get(3)?,
803        row.get(4)?,
804        row.get(5)?,
805    ))
806}
807
808fn read_document(scope: &Scope, row: DocumentRow) -> Result<Document> {
809    let (key, state, updated_at, payload, complete, fetched_at) = row;
810    Ok(Document {
811        key,
812        payload: serde_json::from_str(&payload)
813            .with_context(|| format!("a stored {} is not readable", scope.key()))?,
814        state,
815        updated_at,
816        complete: complete != 0,
817        fetched_at,
818    })
819}
820
821fn record_change(transaction: &Transaction<'_>, scope: &Scope) -> Result<()> {
822    transaction.execute(
823        "INSERT INTO github_change (scope, changed_at) VALUES (?1, ?2)",
824        params![scope.key(), Utc::now().to_rfc3339()],
825    )?;
826    transaction.execute(
827        "DELETE FROM github_change WHERE seq <= (SELECT MAX(seq) FROM github_change) - ?1",
828        params![CHANGES_KEPT],
829    )?;
830    Ok(())
831}
832
833/// Remove what an authoritative answer no longer mentions. `true` when
834/// anything went.
835fn prune_scope(transaction: &Transaction<'_>, scope: &Scope, documents: &[Document]) -> Result<bool> {
836    let present: BTreeSet<&str> = documents
837        .iter()
838        .map(|document| document.key.as_str())
839        .collect();
840    let held: Vec<String> = {
841        let mut statement =
842            transaction.prepare("SELECT key FROM github_document WHERE scope = ?1")?;
843        let rows = statement.query_map(params![scope.key()], |row| row.get::<_, String>(0))?;
844        rows.collect::<Result<Vec<_>, _>>()?
845    };
846    let mut removed = false;
847    for key in held {
848        if !present.contains(key.as_str()) {
849            transaction.execute(
850                "DELETE FROM github_document WHERE scope = ?1 AND key = ?2",
851                params![scope.key(), key],
852            )?;
853            removed = true;
854        }
855    }
856    Ok(removed)
857}
858
859/// Write one document. `true` when what is held is now different.
860///
861/// Compared rather than assumed: a sync after a restart has no ETag to
862/// present, so it is answered with the same content again — and recording
863/// that as a change would tell every open page to re-read on a timer.
864fn put_document(transaction: &Transaction<'_>, scope: &Scope, document: &Document) -> Result<bool> {
865    let payload = serde_json::to_string(&document.payload)
866        .with_context(|| format!("cannot store {}/{}", scope.key(), document.key))?;
867    let held: Option<(Option<String>, Option<String>, String, i64)> = transaction
868        .query_row(
869            "SELECT state, updated_at, payload_json, complete FROM github_document
870             WHERE scope = ?1 AND key = ?2",
871            params![scope.key(), document.key],
872            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
873        )
874        .optional()?;
875    let changed = match &held {
876        None => true,
877        Some((state, updated_at, held_payload, complete)) => {
878            // A summary never replaces a whole payload, so over a whole one
879            // it can change only the columns beside it.
880            let payload_moves = document.complete || *complete == 0;
881            *state != document.state
882                || *updated_at != document.updated_at
883                || (payload_moves && *held_payload != payload)
884        }
885    };
886    transaction.execute(
887        "INSERT INTO github_document
888             (scope, key, state, updated_at, payload_json, complete, fetched_at)
889         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
890         ON CONFLICT(scope, key) DO UPDATE SET
891             state = excluded.state,
892             updated_at = excluded.updated_at,
893             -- A summary must not overwrite a payload already known to be
894             -- whole: list endpoints omit what an editor opens.
895             payload_json = CASE
896                 WHEN excluded.complete = 1 OR github_document.complete = 0
897                 THEN excluded.payload_json
898                 ELSE github_document.payload_json
899             END,
900             complete = MAX(excluded.complete, github_document.complete),
901             fetched_at = excluded.fetched_at",
902        params![
903            scope.key(),
904            document.key,
905            document.state,
906            document.updated_at,
907            payload,
908            document.complete as i64,
909            document.fetched_at
910        ],
911    )?;
912    Ok(changed)
913}
914
915/// The key a scope that holds exactly one document stores it under.
916pub const SINGLETON: &str = "-";
917
918/// Whether a gist payload carries file contents rather than just their names.
919///
920/// GitHub's list endpoint returns files with `content` absent; the single-gist
921/// endpoint fills them in. A gist with no files at all carries nothing either
922/// way, and is complete as it stands.
923pub fn gist_is_whole(gist: &GitHubGist) -> bool {
924    gist.files.is_empty() || gist.files.values().all(|file| file.content.is_some())
925}
926
927/// The newest `updated_at` in a set, which becomes the next fetch's `since`.
928///
929/// GitHub's `since` is inclusive, so the item that set the mark comes back
930/// with the next fetch and is written over itself. One row rewritten beats one
931/// update missed.
932pub fn newest_update<'a>(documents: impl IntoIterator<Item = &'a Document>) -> Option<String> {
933    documents
934        .into_iter()
935        .filter_map(|document| document.updated_at.clone())
936        .max()
937}
938
939#[cfg(test)]
940#[path = "mirror_tests.rs"]
941mod tests;