gui: a row's presence is its subtree's, not its own
Build Debug APK / build (push) Successful in 4m51s
Build Debug APK / build (push) Successful in 4m51s
A Collection has no blob-backed artifact of its own — a podcast feed's content is entirely in its episodes, a comic's in its pages. rows_present_locally asked only about the row, found no refs, and answered 'here', so every collection in the library read as fully present whether or not a single child had downloaded. TODO.md carried this as 'aggregate sync rollup on a Collection card'; it is the same complaint as the one that started this redesign, one level up. content_ledger::subtree_content rolls each listed row up over its own subtree. One recursive CTE collects every subtree at once (DocumentStore::descendants_of, the descendant counterpart of ancestors_of) rather than walking per row — a page holding both a comic and its series would otherwise walk three hundred pages twice — and the cost stays bounded by what is on screen rather than by the library. The boolean becomes four states. Partly-here is the one it could never express, and it is where collections normally live; such a row says '3 of 10' rather than a word, since the numbers are the whole content of 'partly here'. A row with no content at all renders nothing: a notebook is not 'Not here', it simply has nothing this column is about, and a word there would be noise on every note. Also corrected on the way through: the old check counted Thumbnail refs, so a document whose cover art had not arrived read as missing its content. The rollup is content-plane only. availability.rs keeps counting thumbnails, and should — it answers 'can this be opened at all', where a missing cover is a missing artifact even though policy never governs it.
This commit is contained in:
@@ -566,14 +566,18 @@ sure what, if any, possible android equivalent of this exists.
|
||||
- **Related**: this is the same durability question as node revocation's "deliver a
|
||||
decision to a node that isn't listening", and may want the same mechanism.
|
||||
|
||||
- **Content placement UX, step 6.** Design and the finished steps 1-5, 7 are in
|
||||
`docs/content-placement-redesign.md`. What's left:
|
||||
- **Content placement UX: what's left.** Design and the finished steps 1-7 are in
|
||||
`docs/content-placement-redesign.md`. Remaining:
|
||||
- **Ensemble member rows don't show a peer's deficit.** The Sync policy tab
|
||||
does now, but only for the one node selected in its picker. A badge per member
|
||||
row would need a `GetSyncConfig` round trip each, on a screen that currently
|
||||
does no network work per row — decide whether that's a lazy per-row fetch, a
|
||||
batched one, or something gossip already carries.
|
||||
- **Presence glyphs** in the documents/media list views, off the same ledger.
|
||||
- **Presence rollups beyond the Documents list.** `content_ledger::subtree_content`
|
||||
backs the desktop Documents list now. The media library screens (music, comics,
|
||||
pictures) still use their own per-item availability, and Android's lists show
|
||||
nothing of the kind — a phone gets no answer to "how much of this album is here"
|
||||
without opening the album.
|
||||
- **Per-node default policy**: `KeepOnOpen` on desktop, `Stream` on mobile.
|
||||
`SyncPolicy::default()` is one value for every platform today; the default
|
||||
belongs to the node, set once at initialization from what kind of node it is.
|
||||
|
||||
@@ -350,8 +350,21 @@ Each step is independently useful, and the first two are most of the value.
|
||||
Android reads the assembled rows over one new FFI call rather than
|
||||
composing ensemble members, this node's override, and per-node status in
|
||||
Kotlin — that composition is how the two modalities drift.
|
||||
5. **Desktop job-bound widgets** (catch up to Android's `JobBinding`).
|
||||
6. **Presence glyphs** in list views.
|
||||
5. **Desktop job-bound widgets — done.** `job_binding` is the desktop
|
||||
counterpart of Android's `JobBinding.kt`; the Placement table's buttons and
|
||||
the Ensemble screen's Sync read the live job rather than this screen's own
|
||||
click.
|
||||
6. **Presence in list views — done (desktop).** A row's presence is now a
|
||||
rollup over its subtree rather than a boolean about the row itself, which
|
||||
is what a collection needs: a podcast feed has no content of its own, so
|
||||
asking only about the row reported "here" for every feed in the library
|
||||
whether or not a single episode had downloaded. A partly-held row says its
|
||||
numbers — "3 of 10" is the whole content of "partly here". Thumbnails left
|
||||
the count, so a document whose cover art hasn't arrived no longer reads as
|
||||
missing its content.
|
||||
|
||||
Not done: the media library screens (music, comics, pictures) still use
|
||||
their own per-item availability, and Android's lists are untouched.
|
||||
7. **Vocabulary — done.** Every rendered string says artifact: `PeerSyncCopy`,
|
||||
`ContentRowCopy`, `placement_content`/`placement_summary`, and the eviction
|
||||
dialogs. Field names stay `files_*`; no user reads them.
|
||||
|
||||
@@ -804,7 +804,8 @@ impl DocumentsView {
|
||||
};
|
||||
|
||||
let click_row = row.clone();
|
||||
let presence = PresenceLook::of_row(self.row_presence(row));
|
||||
let row_presence = self.row_presence(row);
|
||||
let presence = PresenceLook::of_row(row_presence);
|
||||
div()
|
||||
.id(("doc-row", ix))
|
||||
.flex()
|
||||
@@ -852,31 +853,53 @@ impl DocumentsView {
|
||||
.child(cell(TYPE, row.doc_type.clone()))
|
||||
.child(cell(CREATED, row.created_at.clone()))
|
||||
.child(cell(SIZE, row.size.clone()))
|
||||
.child(row_presence_cell(theme, cols, presence))
|
||||
.child(row_presence_cell(theme, cols, presence, row_presence, row))
|
||||
}
|
||||
|
||||
/// Whether this row's content is here, on its way, or not here. The
|
||||
/// stored `here` flag answers the first and last; a running fetch job for
|
||||
/// the document answers the middle one, and is read live rather than
|
||||
/// baked into the row, since a transfer starts and finishes without the
|
||||
/// row being reloaded.
|
||||
/// Whether this row's content is all here, partly here, on its way, or
|
||||
/// not here at all.
|
||||
///
|
||||
/// The stored rollup answers everything except "arriving": a running fetch
|
||||
/// job for the document is read live rather than baked into the row, since
|
||||
/// a transfer starts and finishes without the row being reloaded.
|
||||
///
|
||||
/// Arriving outranks a partial holding on purpose — a row that is 3-of-10
|
||||
/// and actively fetching should say the thing that is changing, not the
|
||||
/// thing that is about to stop being true.
|
||||
fn row_presence(&self, row: &DocRow) -> RowPresence {
|
||||
if row.here {
|
||||
if row.content.is_empty() {
|
||||
return RowPresence::NoContent;
|
||||
}
|
||||
if row.content.is_complete() {
|
||||
return RowPresence::Here;
|
||||
}
|
||||
if self.fetching.contains(row.id.as_str()) {
|
||||
return RowPresence::Arriving;
|
||||
}
|
||||
if row.content.is_partial() {
|
||||
return RowPresence::Partial;
|
||||
}
|
||||
RowPresence::NotHere
|
||||
}
|
||||
}
|
||||
|
||||
/// A row's presence indicator: a dot and a word, for this node only.
|
||||
///
|
||||
/// A partly-held row says its numbers instead of a word — "3 of 10" is the
|
||||
/// whole content of "partly here", and a collection is the row most often in
|
||||
/// that state, which is exactly where a bare word would leave the user
|
||||
/// counting children by hand.
|
||||
fn row_presence_cell(
|
||||
theme: &Theme,
|
||||
cols: &ColumnSizes,
|
||||
presence: PresenceLook,
|
||||
row_presence: RowPresence,
|
||||
row: &DocRow,
|
||||
) -> impl IntoElement {
|
||||
let label: SharedString = match row_presence {
|
||||
RowPresence::Partial => format!("{} of {}", row.content.here, row.content.total).into(),
|
||||
_ => presence.row_label().into(),
|
||||
};
|
||||
cols.size(PRESENCE, div())
|
||||
.flex()
|
||||
.flex_row()
|
||||
@@ -884,10 +907,7 @@ fn row_presence_cell(
|
||||
.gap_1()
|
||||
.child(presence.dot(theme))
|
||||
.child(
|
||||
div()
|
||||
.text_size(CELL_TEXT.size())
|
||||
.text_color(presence.text_color(theme))
|
||||
.child(presence.row_label()),
|
||||
div().text_size(CELL_TEXT.size()).text_color(presence.text_color(theme)).child(label),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -937,6 +957,8 @@ mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
use synchronicity::content_ledger::SubtreeContent;
|
||||
|
||||
fn row(id: &str, child_count: usize) -> DocRow {
|
||||
DocRow {
|
||||
id: id.to_string(),
|
||||
@@ -947,7 +969,8 @@ mod tests {
|
||||
created_at: "".into(),
|
||||
size: "".into(),
|
||||
child_count,
|
||||
here: true,
|
||||
// These cases are about tree assembly, not presence.
|
||||
content: SubtreeContent::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ use gpui::{
|
||||
prelude::*,
|
||||
};
|
||||
use synchronicity::agents::AgentId;
|
||||
use synchronicity::content_ledger::NodeHolding;
|
||||
use synchronicity::content_ledger::{NodeHolding, SubtreeContent, subtree_content};
|
||||
use synchronicity::documents::import::{
|
||||
ImportProgress, ImportTotals, create_collection_node, import_directory_tree, import_file,
|
||||
};
|
||||
@@ -74,26 +74,38 @@ struct DocRow {
|
||||
size: SharedString,
|
||||
/// Number of direct children, used to decide whether to show a twisty.
|
||||
child_count: usize,
|
||||
/// Whether this node holds the row's content. ADR 0032 §7: an ordinary
|
||||
/// list collapses presence to **this node only** — here, or not here —
|
||||
/// because that is the bit the user can act on without leaving the row.
|
||||
/// Where everything is across the ensemble is a different question, asked
|
||||
/// occasionally and deliberately, and it gets its own view.
|
||||
/// How much of this row's content this node holds, rolled up over its
|
||||
/// whole subtree. ADR 0032 §7: an ordinary list collapses presence to
|
||||
/// **this node only**, because that is the bit the user can act on without
|
||||
/// leaving the row; where everything is across the ensemble is a different
|
||||
/// question with its own view.
|
||||
///
|
||||
/// A rollup rather than a boolean about the row itself, because a
|
||||
/// collection has no content of its own — a podcast feed with no episodes
|
||||
/// downloaded and one fully mirrored both used to read as "here". See
|
||||
/// [`SubtreeContent`].
|
||||
///
|
||||
/// "Arriving" is not stored here: it comes from the live job list at
|
||||
/// render time, since it changes without the row being reloaded.
|
||||
here: bool,
|
||||
content: SubtreeContent,
|
||||
}
|
||||
|
||||
/// What a row's presence indicator says about this node.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum RowPresence {
|
||||
/// The content is on this node.
|
||||
/// Every content artifact under this row is on this node.
|
||||
Here,
|
||||
/// Some of them are, and some are not — the state a collection is usually
|
||||
/// in, and the one a per-row boolean could not express.
|
||||
Partial,
|
||||
/// A fetch for it is running now.
|
||||
Arriving,
|
||||
/// It is not here. Not an error, and not red — see [`PresenceLook`].
|
||||
/// None of them are here. Not an error, and not red — see [`PresenceLook`].
|
||||
NotHere,
|
||||
/// There is no content under this row at all — a notebook, a folder of
|
||||
/// notes. Nothing to fetch, so nothing to report; distinct from holding
|
||||
/// none of something that exists.
|
||||
NoContent,
|
||||
}
|
||||
|
||||
enum LoadState {
|
||||
@@ -1262,12 +1274,22 @@ fn import_spinner(theme: &Theme) -> impl IntoElement {
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum PresenceLook {
|
||||
Here,
|
||||
/// Some of a row's subtree is here and some is not. Green like `Here`
|
||||
/// rather than amber: a partly-downloaded album is not a fault and not
|
||||
/// something waiting on an answer, it is a normal state of a system where
|
||||
/// content arrives over time. The half-filled dot carries the difference,
|
||||
/// and the word says the numbers.
|
||||
Partial,
|
||||
NotHere,
|
||||
/// A fetch is running now. The one presence state that takes cobalt,
|
||||
/// because cobalt is what "energised" means in this app and a transfer in
|
||||
/// flight is the only presence state that *is* an event.
|
||||
Arriving,
|
||||
Waiting,
|
||||
/// The row has no content to hold at all. Renders as nothing — a notebook
|
||||
/// is not "Not here", it simply has nothing this column is about, and a
|
||||
/// word there would be noise on every note in the library.
|
||||
NoContent,
|
||||
}
|
||||
|
||||
impl PresenceLook {
|
||||
@@ -1282,24 +1304,30 @@ impl PresenceLook {
|
||||
fn of_row(presence: RowPresence) -> Self {
|
||||
match presence {
|
||||
RowPresence::Here => Self::Here,
|
||||
RowPresence::Partial => Self::Partial,
|
||||
RowPresence::Arriving => Self::Arriving,
|
||||
RowPresence::NotHere => Self::NotHere,
|
||||
RowPresence::NoContent => Self::NoContent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every look a row-level indicator can take. Measuring the presence
|
||||
/// column against all of them, rather than against the rows on screen,
|
||||
/// keeps the column from resizing when a fetch starts or finishes.
|
||||
const ALL: [Self; 4] = [Self::Here, Self::NotHere, Self::Arriving, Self::Waiting];
|
||||
const ALL: [Self; 5] =
|
||||
[Self::Here, Self::NotHere, Self::Arriving, Self::Waiting, Self::Partial];
|
||||
|
||||
/// The word for a row-level indicator, where there is no model vocabulary
|
||||
/// to defer to.
|
||||
/// to defer to. `Partial` has none of its own — its row says how many of
|
||||
/// how many, which is the whole point of it.
|
||||
fn row_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Here => "Here",
|
||||
Self::Partial => "Partly here",
|
||||
Self::Arriving => "Arriving",
|
||||
Self::NotHere => "Not here",
|
||||
Self::Waiting => "Unknown",
|
||||
Self::NoContent => "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1313,8 +1341,8 @@ impl PresenceLook {
|
||||
|
||||
fn text_color(self, theme: &Theme) -> gpui::Rgba {
|
||||
match self {
|
||||
Self::Here => theme.color_success,
|
||||
Self::NotHere => theme.text_tertiary,
|
||||
Self::Here | Self::Partial => theme.color_success,
|
||||
Self::NotHere | Self::NoContent => theme.text_tertiary,
|
||||
Self::Arriving => theme.accent,
|
||||
Self::Waiting => theme.color_warning,
|
||||
}
|
||||
@@ -1326,7 +1354,14 @@ impl PresenceLook {
|
||||
fn dot(self, theme: &Theme) -> Div {
|
||||
let dot = div().flex_none().size(icon::DOT).rounded(shape::PILL);
|
||||
match self {
|
||||
// Nothing at all, not even an outline: this row has no content, so
|
||||
// an indicator would be answering a question it isn't asked.
|
||||
Self::NoContent => div().flex_none().size(icon::DOT),
|
||||
Self::NotHere => dot.border_1().border_color(theme.text_tertiary),
|
||||
// Outlined *and* filled — a ring in the same green as `Here` with a
|
||||
// lighter centre. Reads as "on its way to full" at a glance without
|
||||
// needing a second colour, which is what a partly-held album is.
|
||||
Self::Partial => dot.border_1().border_color(theme.color_success).bg(theme.bg_card),
|
||||
_ => dot.bg(self.text_color(theme)),
|
||||
}
|
||||
}
|
||||
@@ -1424,7 +1459,8 @@ fn load_page(
|
||||
|
||||
let all = items.iter().chain(context_items.iter());
|
||||
let sizes = row_sizes(artifact_store, all.clone().map(|i| (i.id.clone(), i.child_count)))?;
|
||||
let here = rows_present_locally(store, &all.map(|i| i.id.clone()).collect::<Vec<_>>())?;
|
||||
let content = subtree_content(store, &all.map(|i| i.id.clone()).collect::<Vec<_>>())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let to_row = |item: DocumentListItem| {
|
||||
let total_bytes = sizes.get(&item.id).copied().unwrap_or(0);
|
||||
@@ -1434,7 +1470,7 @@ fn load_page(
|
||||
let title =
|
||||
if item.title.is_empty() { "Untitled Document".to_string() } else { item.title };
|
||||
DocRow {
|
||||
here: here.get(&item.id).copied().unwrap_or(true),
|
||||
content: content.get(&item.id).copied().unwrap_or_default(),
|
||||
id: item.id,
|
||||
parent_id: item.parent_id,
|
||||
title: title.into(),
|
||||
@@ -1492,10 +1528,9 @@ fn load_children(store: &Store, parent_id: &str) -> Result<Vec<DocRow>, String>
|
||||
})
|
||||
.collect::<Result<_, String>>()?;
|
||||
let sizes = row_sizes(artifact_store, child_counts.iter().cloned())?;
|
||||
let here = rows_present_locally(
|
||||
store,
|
||||
&children.iter().map(|d| d.id.to_string()).collect::<Vec<_>>(),
|
||||
)?;
|
||||
let content =
|
||||
subtree_content(store, &children.iter().map(|d| d.id.to_string()).collect::<Vec<_>>())
|
||||
.map_err(|e| e.to_string())?;
|
||||
let child_count_by_id: std::collections::HashMap<String, i64> =
|
||||
child_counts.into_iter().collect();
|
||||
|
||||
@@ -1507,7 +1542,7 @@ fn load_children(store: &Store, parent_id: &str) -> Result<Vec<DocRow>, String>
|
||||
let child_count = child_count_by_id.get(&id_str).copied().unwrap_or(0);
|
||||
let total_bytes = sizes.get(&id_str).copied().unwrap_or(0);
|
||||
Ok(DocRow {
|
||||
here: here.get(&id_str).copied().unwrap_or(true),
|
||||
content: content.get(&id_str).copied().unwrap_or_default(),
|
||||
parent_id: doc.parent_id.as_ref().map(ToString::to_string),
|
||||
id: id_str,
|
||||
title: title.into(),
|
||||
@@ -1603,26 +1638,6 @@ pub(crate) fn format_bytes(bytes: u64) -> String {
|
||||
/// would make a collection read "not here" because one chapter of it is
|
||||
/// missing, which is a different (and much less actionable) statement than
|
||||
/// the one the column makes.
|
||||
fn rows_present_locally(
|
||||
store: &Store,
|
||||
document_ids: &[String],
|
||||
) -> Result<HashMap<String, bool>, String> {
|
||||
let artifact_store = &store.artifact_store;
|
||||
let by_doc = artifact_store
|
||||
.batch(|tx| artifact_store.list_blob_refs_for_documents(tx, document_ids))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(document_ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let here = by_doc
|
||||
.get(id)
|
||||
.is_none_or(|refs| refs.iter().all(|r| store.blob_store.exists(&r.blob_hash)));
|
||||
(id.clone(), here)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn row_sizes(
|
||||
artifact_store: &synchronicity::stores::artifact::ArtifactStore,
|
||||
rows: impl Iterator<Item = (String, i64)>,
|
||||
|
||||
@@ -304,6 +304,110 @@ pub fn document_placement(
|
||||
Ok(placement)
|
||||
}
|
||||
|
||||
/// What a list row can say about its own content, rolled up over its subtree —
|
||||
/// `docs/content-placement-redesign.md` §3.4.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct SubtreeContent {
|
||||
/// Content artifacts in the subtree, the row included.
|
||||
pub total: u32,
|
||||
/// How many of them this node holds.
|
||||
pub here: u32,
|
||||
}
|
||||
|
||||
impl SubtreeContent {
|
||||
/// Nothing in this subtree has content at all — a notebook, a folder of
|
||||
/// notes. Distinct from holding none of it, and the row should say so
|
||||
/// differently: there is nothing to fetch.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.total == 0
|
||||
}
|
||||
|
||||
/// Every content artifact under this row is on this node.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.total > 0 && self.here == self.total
|
||||
}
|
||||
|
||||
/// Some but not all — the state a per-document boolean could never
|
||||
/// express, and the one a collection is usually in.
|
||||
pub fn is_partial(&self) -> bool {
|
||||
self.here > 0 && self.here < self.total
|
||||
}
|
||||
}
|
||||
|
||||
/// Roll each of `document_ids` up over its own subtree: how much content is
|
||||
/// under it, and how much of that is here.
|
||||
///
|
||||
/// # Why a row's own artifacts are not the answer
|
||||
///
|
||||
/// A `Collection` — a podcast feed, a comic, an album — has no blob-backed
|
||||
/// artifact of its own; its content is entirely in its children. Asking only
|
||||
/// about the row therefore reports "nothing missing" for every collection in
|
||||
/// the library, which is what made a feed with no episodes downloaded look
|
||||
/// identical to one fully mirrored. (TODO.md carried this as "aggregate sync
|
||||
/// rollup on a Collection card".)
|
||||
///
|
||||
/// # Cost
|
||||
///
|
||||
/// Two queries and a stat per content artifact *in the visible subtrees* —
|
||||
/// bounded by what is on screen, not by the library. One recursive CTE
|
||||
/// collects every subtree at once
|
||||
/// ([`descendants_of`](crate::stores::document::DocumentStore::descendants_of))
|
||||
/// rather than walking per row, which for a page containing both a comic and
|
||||
/// its parent collection would otherwise walk the same three hundred pages
|
||||
/// twice.
|
||||
///
|
||||
/// Content plane only, so a document whose cover art hasn't arrived does not
|
||||
/// read as missing its content — which the boolean this replaces got wrong.
|
||||
pub fn subtree_content(
|
||||
store: &Store,
|
||||
document_ids: &[String],
|
||||
) -> Result<HashMap<String, SubtreeContent>, StoreError> {
|
||||
if document_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let pairs = store.batch(|tx| store.doc_store.descendants_of(tx, document_ids))?;
|
||||
let members: Vec<String> = pairs
|
||||
.iter()
|
||||
.map(|(_, member)| member.clone())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let refs_by_doc = store
|
||||
.artifact_store
|
||||
.batch(|tx| store.artifact_store.list_blob_refs_for_documents(tx, &members))?;
|
||||
|
||||
// Each member's own tally, computed once — a document that is under two of
|
||||
// the listed rows (a comic page under both the comic and its series) must
|
||||
// not be stat'ed twice.
|
||||
let mut own: HashMap<&String, SubtreeContent> = HashMap::new();
|
||||
for (doc_id, refs) in &refs_by_doc {
|
||||
let mut tally = SubtreeContent::default();
|
||||
for (kind, blob_ref) in refs {
|
||||
if !matches!(kind, ArtifactKind::PrimaryContent | ArtifactKind::Attachment) {
|
||||
continue;
|
||||
}
|
||||
tally.total += 1;
|
||||
if store.blob_store.exists(&blob_ref.blob_hash) {
|
||||
tally.here += 1;
|
||||
}
|
||||
}
|
||||
own.insert(doc_id, tally);
|
||||
}
|
||||
|
||||
let mut rolled: HashMap<String, SubtreeContent> =
|
||||
document_ids.iter().map(|id| (id.clone(), SubtreeContent::default())).collect();
|
||||
for (root, member) in &pairs {
|
||||
let Some(tally) = own.get(member) else {
|
||||
continue;
|
||||
};
|
||||
let entry = rolled.entry(root.clone()).or_default();
|
||||
entry.total += tally.total;
|
||||
entry.here += tally.here;
|
||||
}
|
||||
Ok(rolled)
|
||||
}
|
||||
|
||||
/// Whether this node should *proactively fetch* content it doesn't have.
|
||||
/// Only `Mirror` does; `KeepOnOpen` and `Stream` are satisfied on open.
|
||||
fn wants(resolved_override: Option<bool>, policy: SyncPolicy) -> bool {
|
||||
@@ -454,6 +558,80 @@ mod tests {
|
||||
size
|
||||
}
|
||||
|
||||
// ── subtree_content ──────────────────────────────────────────────────────
|
||||
|
||||
/// The case a per-document boolean cannot express, and the one collections
|
||||
/// are usually in: a feed holding some of its episodes.
|
||||
#[rstest]
|
||||
fn a_collection_rolls_up_its_children(#[from(initialized_store)] store: TestStore) {
|
||||
let feed = add_doc(&store, DocumentType::PodcastFeed, None);
|
||||
let downloaded = add_doc(&store, DocumentType::PodcastEpisode, Some(feed.clone()));
|
||||
let not_yet = add_doc(&store, DocumentType::PodcastEpisode, Some(feed.clone()));
|
||||
attach_present(&store, &downloaded, ArtifactKind::PrimaryContent, b"episode one");
|
||||
attach(&store, ¬_yet, ArtifactKind::PrimaryContent, missing_blob_ref(4, 400));
|
||||
|
||||
let rolled = subtree_content(&store, &[feed.to_string()]).unwrap();
|
||||
let feed_row = rolled[&feed.to_string()];
|
||||
|
||||
assert_eq!((feed_row.total, feed_row.here), (2, 1));
|
||||
assert!(feed_row.is_partial());
|
||||
assert!(!feed_row.is_complete());
|
||||
assert!(!feed_row.is_empty());
|
||||
}
|
||||
|
||||
/// A row with nothing to fetch is not a row that is missing everything.
|
||||
#[rstest]
|
||||
fn a_subtree_with_no_content_reads_as_empty(#[from(initialized_store)] store: TestStore) {
|
||||
let notebook = add_doc(&store, DocumentType::Notebook, None);
|
||||
add_doc(&store, DocumentType::Note, Some(notebook.clone()));
|
||||
|
||||
let rolled = subtree_content(&store, &[notebook.to_string()]).unwrap();
|
||||
|
||||
assert!(rolled[¬ebook.to_string()].is_empty());
|
||||
assert!(!rolled[¬ebook.to_string()].is_partial());
|
||||
}
|
||||
|
||||
/// Thumbnails are catalogue-plane: a document whose cover art has not
|
||||
/// arrived is not missing its content, which the boolean this replaces got
|
||||
/// wrong.
|
||||
#[rstest]
|
||||
fn a_missing_thumbnail_does_not_make_a_row_incomplete(
|
||||
#[from(initialized_store)] store: TestStore,
|
||||
) {
|
||||
let doc = add_doc(&store, DocumentType::Pdf, None);
|
||||
attach_present(&store, &doc, ArtifactKind::PrimaryContent, b"the pdf itself");
|
||||
attach(&store, &doc, ArtifactKind::Thumbnail, missing_blob_ref(5, 500));
|
||||
|
||||
let rolled = subtree_content(&store, &[doc.to_string()]).unwrap();
|
||||
|
||||
assert!(rolled[&doc.to_string()].is_complete());
|
||||
}
|
||||
|
||||
/// A page under both a comic and its series must be counted once per row,
|
||||
/// not once per ancestor that happens to be listed alongside it.
|
||||
#[rstest]
|
||||
fn overlapping_rows_each_count_their_own_subtree(#[from(initialized_store)] store: TestStore) {
|
||||
let series = add_doc(&store, DocumentType::Collection, None);
|
||||
let comic = add_doc(&store, DocumentType::Comic, Some(series.clone()));
|
||||
let page = add_doc(&store, DocumentType::Image, Some(comic.clone()));
|
||||
attach_present(&store, &page, ArtifactKind::PrimaryContent, b"page one");
|
||||
|
||||
let rolled = subtree_content(&store, &[series.to_string(), comic.to_string()]).unwrap();
|
||||
|
||||
assert_eq!(rolled[&series.to_string()], rolled[&comic.to_string()]);
|
||||
assert_eq!(rolled[&series.to_string()].total, 1);
|
||||
}
|
||||
|
||||
/// A row asked about is always in the answer, so a caller never has to
|
||||
/// tell "no content" from "I forgot to ask".
|
||||
#[rstest]
|
||||
fn every_listed_row_gets_an_entry(#[from(initialized_store)] store: TestStore) {
|
||||
let doc = add_doc(&store, DocumentType::Note, None);
|
||||
let rolled = subtree_content(&store, &[doc.to_string()]).unwrap();
|
||||
assert_eq!(rolled.len(), 1);
|
||||
assert_eq!(rolled[&doc.to_string()], SubtreeContent::default());
|
||||
}
|
||||
|
||||
// ── document_placement ───────────────────────────────────────────────────
|
||||
|
||||
fn mark_holder(store: &TestStore, node: NodePubkey, hash: &BlobHash, at: Timestamp) {
|
||||
|
||||
@@ -263,8 +263,12 @@ fn missing_blobs(
|
||||
.list_blob_refs_for_documents(tx, document_ids)?
|
||||
.into_iter()
|
||||
.map(|(doc_id, refs)| {
|
||||
// Every blob-backed kind, thumbnails included: this answers "can
|
||||
// this document be opened at all", and a missing cover is a
|
||||
// missing artifact even though policy never governs it.
|
||||
let keys = refs
|
||||
.iter()
|
||||
.map(|(_, r)| r)
|
||||
.filter(|r| !blob_store.exists(&r.blob_hash))
|
||||
.map(|r| ContentKey::Blob(r.blob_hash.clone()))
|
||||
.collect();
|
||||
|
||||
@@ -1117,39 +1117,46 @@ impl ArtifactStore {
|
||||
Ok(by_doc)
|
||||
}
|
||||
|
||||
/// Blob pointers for every document in `document_ids`, keyed by document
|
||||
/// id string. Scoped counterpart to a per-document
|
||||
/// Blob pointers for every document in `document_ids`, with each pointer's
|
||||
/// kind, keyed by document id string. Scoped counterpart to a per-document
|
||||
/// [`list_blob_refs`](Self::list_blob_refs) loop — see
|
||||
/// [`documents_with_unfulfilled_stubs`](Self::documents_with_unfulfilled_stubs)
|
||||
/// for why this exists.
|
||||
///
|
||||
/// The kind travels because most callers want only the content plane
|
||||
/// (`PrimaryContent`/`Attachment`) and were silently counting `Thumbnail`
|
||||
/// alongside it — which made a document whose cover art hadn't arrived read
|
||||
/// as "content missing".
|
||||
pub fn list_blob_refs_for_documents(
|
||||
&self,
|
||||
tx: &Transaction,
|
||||
document_ids: &[String],
|
||||
) -> Result<std::collections::HashMap<String, Vec<BlobRef>>, ArtifactStoreError> {
|
||||
) -> Result<std::collections::HashMap<String, Vec<(ArtifactKind, BlobRef)>>, ArtifactStoreError>
|
||||
{
|
||||
if document_ids.is_empty() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
let placeholders = document_ids.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
|
||||
let sql = format!(
|
||||
"SELECT document_id, value FROM artifacts \
|
||||
"SELECT document_id, kind, value FROM artifacts \
|
||||
WHERE blob_hash IS NOT NULL AND document_id IN ({placeholders})"
|
||||
);
|
||||
let rows: Vec<(String, Vec<u8>)> = tx
|
||||
let rows: Vec<(String, String, Vec<u8>)> = tx
|
||||
.prepare(&sql)?
|
||||
.query_map(rusqlite::params_from_iter(document_ids.iter()), |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, Vec<u8>>(2)?))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let mut by_doc: std::collections::HashMap<String, Vec<BlobRef>> =
|
||||
let mut by_doc: std::collections::HashMap<String, Vec<(ArtifactKind, BlobRef)>> =
|
||||
std::collections::HashMap::new();
|
||||
for (doc_id, value_bytes) in rows {
|
||||
for (doc_id, kind, value_bytes) in rows {
|
||||
let kind = parse_kind(&kind)?;
|
||||
let blob_ref = serde_json::from_slice::<ArtifactValue>(&value_bytes)?
|
||||
.blob_ref()
|
||||
.cloned()
|
||||
.ok_or(ArtifactStoreError::InconsistentBlobPointer)?;
|
||||
by_doc.entry(doc_id).or_default().push(blob_ref);
|
||||
by_doc.entry(doc_id).or_default().push((kind, blob_ref));
|
||||
}
|
||||
Ok(by_doc)
|
||||
}
|
||||
|
||||
@@ -2041,6 +2041,44 @@ impl DocumentStore {
|
||||
/// Ordering is left to the caller: context rows are only meaningful in the
|
||||
/// position their descendants put them in, which is a tree-assembly job the
|
||||
/// store has no view of.
|
||||
/// Every `(root, member)` pair for the subtrees rooted at `ids` — each
|
||||
/// root paired with itself and with every document beneath it.
|
||||
///
|
||||
/// The descendant counterpart of [`ancestors_of`](Self::ancestors_of), and
|
||||
/// the same idiom: one recursive CTE rather than a query per level, which
|
||||
/// is what [`list_subtree_ids`](Self::list_subtree_ids) does and what makes
|
||||
/// it unusable for a list of rows.
|
||||
///
|
||||
/// Carrying the root through the recursion is the point. A caller wanting
|
||||
/// "what does each of these fifty rows contain" would otherwise run fifty
|
||||
/// walks, and a comic with three hundred pages would be walked once per
|
||||
/// ancestor that happens to be on screen too.
|
||||
pub fn descendants_of(
|
||||
&self,
|
||||
tx: &Transaction,
|
||||
ids: &[String],
|
||||
) -> Result<Vec<(String, String)>, DocumentStoreError> {
|
||||
if ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
|
||||
let sql = format!(
|
||||
"WITH RECURSIVE subtree(root, id) AS ( \
|
||||
SELECT id, id FROM documents WHERE id IN ({placeholders}) \
|
||||
UNION \
|
||||
SELECT s.root, d.id FROM documents d \
|
||||
JOIN subtree s ON d.parent_id = s.id \
|
||||
) \
|
||||
SELECT root, id FROM subtree"
|
||||
);
|
||||
Ok(tx
|
||||
.prepare(&sql)?
|
||||
.query_map(rusqlite::params_from_iter(ids.iter()), |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
pub fn ancestors_of(
|
||||
&self,
|
||||
tx: &Transaction,
|
||||
|
||||
Reference in New Issue
Block a user