gui: a row's presence is its subtree's, not its own
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:
Greg Shuflin
2026-08-25 03:03:25 -07:00
parent 0e83ac8336
commit 9768b6c014
8 changed files with 349 additions and 67 deletions
+7 -3
View File
@@ -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.
+15 -2
View File
@@ -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.
+36 -13
View File
@@ -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(),
}
}
+55 -40
View File
@@ -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)>,
+178
View File
@@ -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, &not_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[&notebook.to_string()].is_empty());
assert!(!rolled[&notebook.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) {
+4
View File
@@ -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();
+16 -9
View File
@@ -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)
}
+38
View File
@@ -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,