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 Viewer,
93 Projects(String),
95 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 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 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 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 pub fn is_authoritative(&self) -> bool {
215 !matches!(self, Self::Issues(_) | Self::Pulls(_))
216 }
217}
218
219#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
225pub struct SyncState {
226 pub etag: Option<String>,
228 pub cursor: Option<String>,
230 pub synced_at: Option<String>,
232 pub attempted_at: Option<String>,
234 pub last_error: Option<String>,
235}
236
237impl SyncState {
238 pub fn ever_synced(&self) -> bool {
240 self.synced_at.is_some()
241 }
242
243 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct Document {
267 pub key: String,
270 pub payload: serde_json::Value,
271 pub state: Option<String>,
272 pub updated_at: Option<String>,
273 pub complete: bool,
275 pub fetched_at: String,
276}
277
278impl Document {
279 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 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 pub fn with_state(mut self, state: impl Into<String>) -> Self {
316 self.state = Some(state.into());
317 self
318 }
319}
320
321#[derive(Debug, Clone, PartialEq, Eq)]
323pub enum Changes {
324 Moved { scopes: Vec<String>, latest: i64 },
326 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 pub fn of(prj: &wsb_prj::Prj) -> Result<Self> {
347 Self::open(&prj.state_dir().join(FILE))
348 }
349
350 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn put_only(&self, scope: &Scope, payload: serde_json::Value) -> Result<bool> {
706 self.put(scope, &[Document::of(SINGLETON, payload)])
707 }
708
709 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 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 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 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
833fn 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
859fn 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 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
915pub const SINGLETON: &str = "-";
917
918pub fn gist_is_whole(gist: &GitHubGist) -> bool {
924 gist.files.is_empty() || gist.files.values().all(|file| file.content.is_some())
925}
926
927pub 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;