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