1use 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
42const SCHEMA_VERSION: i64 = 1;
44
45const CHANGES_KEPT: i64 = 10_000;
48
49pub const FILE: &str = "github.db";
51
52#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
61pub enum Scope {
62 Issues(String),
64 Pulls(String),
66 Checks { repo: String, commit: String },
72 Releases(String),
73 Tags(String),
74 Repository(String),
77 Milestones(String),
78 Labels(String),
79 Traffic(String),
82 Comments { repo: String, number: i64 },
84 Gists,
86 Notifications,
88 RateLimit,
90 Projects(String),
92 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 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 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 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 pub fn is_authoritative(&self) -> bool {
209 !matches!(self, Self::Issues(_) | Self::Pulls(_))
210 }
211}
212
213#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
219pub struct SyncState {
220 pub etag: Option<String>,
222 pub cursor: Option<String>,
224 pub synced_at: Option<String>,
226 pub attempted_at: Option<String>,
228 pub last_error: Option<String>,
229}
230
231impl SyncState {
232 pub fn ever_synced(&self) -> bool {
234 self.synced_at.is_some()
235 }
236
237 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct Document {
261 pub key: String,
264 pub payload: serde_json::Value,
265 pub state: Option<String>,
266 pub updated_at: Option<String>,
267 pub complete: bool,
269 pub fetched_at: String,
270}
271
272impl Document {
273 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 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 pub fn with_state(mut self, state: impl Into<String>) -> Self {
310 self.state = Some(state.into());
311 self
312 }
313}
314
315#[derive(Debug, Clone, PartialEq, Eq)]
317pub enum Changes {
318 Moved { scopes: Vec<String>, latest: i64 },
320 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 pub fn of(prj: &wsb_prj::Prj) -> Result<Self> {
341 Self::open(&prj.state_dir().join(FILE))
342 }
343
344 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn put_only(&self, scope: &Scope, payload: serde_json::Value) -> Result<bool> {
700 self.put(scope, &[Document::of(SINGLETON, payload)])
701 }
702
703 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 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 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 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
827fn 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
853fn 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 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
909pub const SINGLETON: &str = "-";
911
912pub fn gist_is_whole(gist: &GitHubGist) -> bool {
918 gist.files.is_empty() || gist.files.values().all(|file| file.content.is_some())
919}
920
921pub 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;