Skip to main content

io_pimdir/client/
reader.rs

1//! [`PimdirReader`]: the read role (spec §8), and the pending-action
2//! overlay a frontend reads through (spec §15.4).
3//!
4//! A store has one owner, any number of producers, and any number of
5//! readers. The first two are handles that write; this one is not, and
6//! says so by carrying no write at all rather than by a connection
7//! refusing one. [`PimdirStore`](crate::client::PimdirStore) holds one and dereferences to it, so
8//! the projection is a single implementation whichever role reads it.
9//!
10//! A reader built with [`with_pending`] folds the queue's pending
11//! actions over the committed items before returning them, which is what
12//! makes a frontend's own staged write visible before the owner applies
13//! it. The fold covers the actions that address an existing item; a
14//! queued create has no public id yet and is reported apart, by
15//! [`pending_creates`].
16//!
17//! [`with_pending`]: PimdirReader::with_pending
18//! [`pending_creates`]: PimdirReader::pending_creates
19
20use alloc::{
21    string::{String, ToString},
22    vec::Vec,
23};
24use core::cmp::Ordering;
25use std::{
26    collections::BTreeMap,
27    path::{Path, PathBuf},
28};
29
30use io_replica::{
31    collection::ReplicaCollectionId,
32    hub::{ReplicaSourceBinding, ReplicaSourceId},
33    object::ReplicaHash,
34    placement::{ReplicaLevel, ReplicaLinkId},
35};
36use rusqlite::{Connection, OpenFlags, OptionalExtension, named_params};
37
38use crate::{
39    client::{
40        PimdirBlobs, PimdirCollection, PimdirConflict, PimdirError, PimdirItem, PimdirParkedAction,
41        PimdirPendingAction, PimdirPlacement, binding_from_row, check_rename_cascades,
42        check_version_agreement, collection_row, conflict_row, load_pending_actions,
43        read_hash_algo, read_item_from_row, rows,
44    },
45    codec::{self, PimdirAction},
46    hash::{PimdirHashAlgo, PimdirHasher},
47    sql,
48};
49
50/// A pimdir store opened to read: the projection every role shares, and
51/// no way to write it.
52///
53/// A reader owns nothing and takes no lock (spec §8), so any number of
54/// them run against a store an owner is syncing, and none of them waits.
55/// [`PimdirStore`](crate::client::PimdirStore) dereferences to one, which is what keeps the owner's
56/// reads and a frontend's the same reads.
57///
58/// Built with [`with_pending`](Self::with_pending) it also overlays the
59/// queue (spec §15.4), so a producer sees what it staged before the
60/// owner applies it.
61pub struct PimdirReader {
62    pub(super) conn: Connection,
63    /// The store directory, which the collector locks and the blob tree hangs
64    /// off.
65    pub(super) dir: PathBuf,
66    pub(super) blobs: PathBuf,
67    /// The hash this store names its objects by (spec §5), read back from
68    /// `store_meta.hash_algo` so every body a consumer hashes lands under
69    /// the name the store already uses.
70    pub(super) hash: PimdirHashAlgo,
71    /// Whether the item reads fold the pending queue over the committed
72    /// rows (spec §15.4). Chosen when the reader is built, never per
73    /// call, so one handle cannot answer two ways about one collection.
74    overlay: bool,
75}
76
77impl PimdirReader {
78    /// Opens an **existing** store rooted at `dir` to read.
79    ///
80    /// The database is opened with `SQLITE_OPEN_READ_ONLY`: nothing is
81    /// created, so a missing database errors, one no owner has stamped
82    /// yet is [`PimdirError::Uncreated`], and any other schema version is
83    /// refused with [`PimdirError::Version`].
84    ///
85    /// No lock is taken, so this never waits on a sync in flight and
86    /// never keeps one out.
87    pub fn open(dir: impl AsRef<Path>) -> Result<Self, PimdirError> {
88        let dir = dir.as_ref();
89        let flags = OpenFlags::SQLITE_OPEN_READ_ONLY
90            | OpenFlags::SQLITE_OPEN_URI
91            | OpenFlags::SQLITE_OPEN_NO_MUTEX;
92        let conn = Connection::open_with_flags(dir.join("pimdir.db"), flags)?;
93        conn.execute_batch("PRAGMA busy_timeout = 30000;")?;
94
95        let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
96        match version {
97            version if version == sql::VERSION => {}
98            // NOTE: an unstamped database is one no owner has opened yet, not
99            // a version this crate cannot read, and the two want different
100            // answers from whoever is holding this handle.
101            0 => return Err(PimdirError::Uncreated),
102            found => return Err(PimdirError::Version { found }),
103        }
104        check_version_agreement(&conn, version)?;
105        check_rename_cascades(&conn)?;
106        let hash = read_hash_algo(&conn, None)?;
107
108        Ok(Self::over(
109            conn,
110            dir.to_path_buf(),
111            dir.join("objects"),
112            hash,
113        ))
114    }
115
116    /// Wraps an already-opened connection, the constructor
117    /// [`PimdirStore`] builds its own reader through.
118    pub(super) fn over(
119        conn: Connection,
120        dir: PathBuf,
121        blobs: PathBuf,
122        hash: PimdirHashAlgo,
123    ) -> Self {
124        Self {
125            conn,
126            dir,
127            blobs,
128            hash,
129            overlay: false,
130        }
131    }
132
133    /// Reads through the queue's pending actions as well as the committed
134    /// rows (spec §15.4), so an action this process staged is visible
135    /// before the store's owner applies it.
136    ///
137    /// The fold covers the actions that address an existing item:
138    /// `set-flags` and `update` restate it, `remove` and `move` take it
139    /// out of the collection, and `move` and `copy` bring it into the
140    /// target. All of them keep the item's public id, a `seq` following
141    /// the link id store-wide (spec §9.1), so nothing here invents an
142    /// identifier.
143    ///
144    /// A queued create is not folded in: it has no `seq` until the owner
145    /// applies it, and it is a request to create an item rather than one.
146    /// [`pending_creates`](Self::pending_creates) reports those.
147    ///
148    /// A parked row is never folded either: its error says it will not be
149    /// applied without an operator, and reading it as pending would
150    /// promise otherwise.
151    ///
152    /// A page keeps its meaning: it comes back short only where the
153    /// collection ends, staged removals or not, so a caller pages the way
154    /// it always did. The cost is that a read consults the queue, which is
155    /// a handful of small statements over rows a sync drains, not a scan.
156    pub fn with_pending(mut self) -> Self {
157        self.overlay = true;
158        self
159    }
160
161    /// Whether this reader folds the pending queue over its item reads.
162    pub fn overlays_pending(&self) -> bool {
163        self.overlay
164    }
165}
166
167/// The client read surface: what a consumer projects into an envelope, a
168/// vCard or an event, and the queue and generation reads beside it.
169impl PimdirReader {
170    /// The hash this store names its objects by (spec §5).
171    pub fn hash_algo(&self) -> PimdirHashAlgo {
172        self.hash
173    }
174
175    /// A blob handle over this store's object directory, bound to the hash the
176    /// store names its bodies by.
177    ///
178    /// Independent of the SQLite connection, so a body can be read while
179    /// the store is mutably borrowed servicing a sync.
180    pub fn blobs(&self) -> PimdirBlobs {
181        PimdirBlobs {
182            root: self.blobs.clone(),
183            hash: self.hash,
184        }
185    }
186
187    /// The content hash of a whole body, under this store's algorithm.
188    pub fn hash(&self, bytes: &[u8]) -> ReplicaHash {
189        self.hash.hash(bytes)
190    }
191
192    /// An incremental hasher for a body streamed into the blob store
193    /// rather than held whole in memory, paired with
194    /// [`PimdirBlobs::writer`].
195    pub fn hasher(&self) -> PimdirHasher {
196        self.hash.hasher()
197    }
198
199    /// The account a collection is grouped under.
200    ///
201    /// The outer `Option` is whether the collection exists, the inner one
202    /// whether it is grouped: `Ok(None)` for an unknown collection,
203    /// `Ok(Some(None))` for one in a single-account store.
204    pub fn collection_account(
205        &self,
206        collection: &str,
207    ) -> Result<Option<Option<String>>, PimdirError> {
208        Ok(self
209            .conn
210            .query_row(
211                sql::LOAD_ACCOUNT,
212                named_params! { ":collection": collection },
213                |r| r.get::<_, Option<String>>(0),
214            )
215            .optional()?)
216    }
217
218    /// The declared media type of a collection, or `None` if the store
219    /// has never seen it. An empty string means the collection exists but
220    /// was created lazily by a sync before any
221    /// [`ensure_collection`](crate::client::PimdirStore::ensure_collection) declared its kind.
222    pub fn collection_kind(&self, collection: &str) -> Result<Option<String>, PimdirError> {
223        Ok(self
224            .conn
225            .query_row(
226                sql::LOAD_KIND,
227                named_params! { ":collection": collection },
228                |r| r.get::<_, String>(0),
229            )
230            .optional()?)
231    }
232
233    /// Lists every collection in the store (client read surface).
234    ///
235    /// Ordered by `sort_order` then `id`, unordered collections last. A
236    /// direct getter: it observes the shared truth and never mutates, and
237    /// writes go through io-replica's [`write`](io_replica::client::ReplicaStorage::write).
238    pub fn list_collections(&self) -> Result<Vec<PimdirCollection>, PimdirError> {
239        Ok(rows(&self.conn, sql::LIST_COLLECTIONS, [], collection_row)?)
240    }
241
242    /// Lists one account's collections, the filter axis of a merged view
243    /// (spec §9.2).
244    ///
245    /// `None` selects the collections of a single-account store, matching on
246    /// `IS` so a `NULL` account matches itself; `=` would match nothing.
247    pub fn list_collections_by_account(
248        &self,
249        account: Option<&str>,
250    ) -> Result<Vec<PimdirCollection>, PimdirError> {
251        Ok(rows(
252            &self.conn,
253            sql::LIST_COLLECTIONS_BY_ACCOUNT,
254            named_params! { ":account": account },
255            collection_row,
256        )?)
257    }
258
259    /// The accounts owning at least one collection.
260    ///
261    /// Not a configured roster: a store learns an account only through
262    /// its collections (spec §9.2), so one with none yet does not appear
263    /// here and a consumer holding the real roster reads its own config.
264    pub fn list_accounts(&self) -> Result<Vec<String>, PimdirError> {
265        Ok(rows(&self.conn, sql::LIST_ACCOUNTS, [], |r| r.get(0))?)
266    }
267
268    /// Every live placement of one identity, with the collection and account it
269    /// sits in (spec §9.2).
270    ///
271    /// The store reports where a link id occurs and takes no position on
272    /// whether the placements are one thing. A mail view lists them, two
273    /// receipts of a newsletter having two read states; a contact view
274    /// may offer to merge them. Both read these rows.
275    pub fn link_placements(&self, link_id: &str) -> Result<Vec<PimdirPlacement>, PimdirError> {
276        Ok(rows(
277            &self.conn,
278            sql::LIST_LINK_PLACEMENTS,
279            named_params! { ":link_id": link_id },
280            |r| {
281                Ok(PimdirPlacement {
282                    collection: r.get(0)?,
283                    account: r.get(1)?,
284                    seq: r.get(2)?,
285                    link_id: ReplicaLinkId(link_id.to_string()),
286                    object: r.get::<_, Option<String>>(3)?.map(ReplicaHash),
287                    flags: codec::flags_from_json(r.get::<_, Option<String>>(4)?.as_deref()),
288                    level: codec::level_from_int(r.get(5)?),
289                })
290            },
291        )?)
292    }
293
294    /// Every live placement of one body, by content hash: the dedup axis
295    /// rather than the identity one, so it pairs placements two servers
296    /// gave different link ids.
297    pub fn object_placements(&self, hash: &str) -> Result<Vec<PimdirPlacement>, PimdirError> {
298        Ok(rows(
299            &self.conn,
300            sql::LIST_OBJECT_PLACEMENTS,
301            named_params! { ":hash": hash },
302            |r| {
303                Ok(PimdirPlacement {
304                    collection: r.get(0)?,
305                    account: r.get(1)?,
306                    seq: r.get(2)?,
307                    link_id: ReplicaLinkId(r.get(3)?),
308                    object: Some(ReplicaHash(hash.to_string())),
309                    flags: codec::flags_from_json(r.get::<_, Option<String>>(4)?.as_deref()),
310                    level: codec::level_from_int(r.get(5)?),
311                })
312            },
313        )?)
314    }
315
316    /// A keyset page of a collection's live items (client read surface).
317    ///
318    /// `after` is the exclusive lower bound on `link_id`, `None` starting
319    /// from the beginning; at most `limit` items come back ordered by
320    /// `link_id`, so the last item's [`link_id`](PimdirItem::link_id) is
321    /// the next page's cursor. Tombstones are excluded, and each item
322    /// carries its `level`, so a body's absence shows without probing the
323    /// blobs.
324    pub fn list_items(
325        &self,
326        collection: &str,
327        after: Option<&str>,
328        limit: usize,
329    ) -> Result<Vec<PimdirItem>, PimdirError> {
330        let after = after.unwrap_or("");
331        self.overlaid(
332            collection,
333            limit,
334            |limit| {
335                Ok(rows(
336                    &self.conn,
337                    sql::LIST_ITEMS_PAGE,
338                    named_params! {
339                        ":collection": collection,
340                        ":after": after,
341                        ":limit": limit as i64,
342                    },
343                    read_item_from_row,
344                )?)
345            },
346            |item| item.link_id.0.as_str() > after,
347            |left, right| left.link_id.0.cmp(&right.link_id.0),
348        )
349    }
350
351    /// A keyset page of a collection's live items in the kind's own
352    /// ascending order (spec §9.3): A to Z for contacts, earliest first
353    /// for mail and calendars.
354    ///
355    /// `after` is the previous page's last `(sort_key, seq)`, `None`
356    /// starting from the beginning. The pair is the cursor because a sort
357    /// key is not unique and `seq`, unique per collection, is what makes
358    /// the page total: no item is skipped or repeated across a boundary.
359    pub fn list_items_page_asc(
360        &self,
361        collection: &str,
362        after: Option<(&str, i64)>,
363        limit: usize,
364    ) -> Result<Vec<PimdirItem>, PimdirError> {
365        // NOTE: no real key sorts before an unknown one ascending, so the
366        // empty string with seq 0 is the true beginning, not a sentinel.
367        let (key, seq) = after.unwrap_or(("", 0));
368        self.sorted_page(
369            sql::LIST_ITEMS_PAGE_ASC,
370            collection,
371            Some((key, seq)),
372            limit,
373            false,
374        )
375    }
376
377    /// The same page descending: newest first for mail and calendars, Z
378    /// to A for contacts.
379    ///
380    /// `None` starts from the end, which the statement expresses by
381    /// binding a key above every representable one, so a caller never
382    /// invents that sentinel itself.
383    pub fn list_items_page_desc(
384        &self,
385        collection: &str,
386        after: Option<(&str, i64)>,
387        limit: usize,
388    ) -> Result<Vec<PimdirItem>, PimdirError> {
389        self.sorted_page(sql::LIST_ITEMS_PAGE_DESC, collection, after, limit, true)
390    }
391
392    fn sorted_page(
393        &self,
394        statement: &str,
395        collection: &str,
396        after: Option<(&str, i64)>,
397        limit: usize,
398        descending: bool,
399    ) -> Result<Vec<PimdirItem>, PimdirError> {
400        let after = after.map(|(key, seq)| (key.to_string(), seq));
401        self.overlaid(
402            collection,
403            limit,
404            |limit| {
405                Ok(rows(
406                    &self.conn,
407                    statement,
408                    named_params! {
409                        ":collection": collection,
410                        ":after_key": after.as_ref().map(|(key, _)| key.as_str()),
411                        ":after_seq": after.as_ref().map(|(_, seq)| *seq).unwrap_or_default(),
412                        ":limit": limit as i64,
413                    },
414                    read_item_from_row,
415                )?)
416            },
417            |item| {
418                let here = (item.sort_key.as_str(), item.seq);
419                match &after {
420                    None => true,
421                    Some((key, seq)) if descending => here < (key.as_str(), *seq),
422                    Some((key, seq)) => here > (key.as_str(), *seq),
423                }
424            },
425            |left, right| {
426                let order =
427                    (left.sort_key.as_str(), left.seq).cmp(&(right.sort_key.as_str(), right.seq));
428                if descending { order.reverse() } else { order }
429            },
430        )
431    }
432
433    /// One live item by its public id `(collection, seq)`, or `None`. A
434    /// tombstoned item reads as `None`, and the returned item carries its
435    /// internal `link_id` for the caller to edit by.
436    pub fn get_item(&self, collection: &str, seq: i64) -> Result<Option<PimdirItem>, PimdirError> {
437        let item = self.committed_item(collection, seq)?;
438        if !self.overlay {
439            return Ok(item);
440        }
441
442        let pending = self.pending(collection)?;
443        let item = match item {
444            Some(item) => Some(item),
445            // NOTE: absent here and arriving there is one item, not none:
446            // a staged move or copy is read from the collection whose row
447            // still holds it.
448            None => match pending.arrivals.get(&seq) {
449                Some(from) => self.committed_item(from, seq)?,
450                None => None,
451            },
452        };
453        Ok(item.and_then(|item| fold(item, pending.edits.get(&seq))))
454    }
455
456    /// Resolves an item's public id (`seq`) from its internal `link_id`,
457    /// the inverse of [`get_item`](Self::get_item), for a consumer that
458    /// just staged an add and wants the id it now shows under.
459    pub fn seq_for_link(
460        &self,
461        collection: &str,
462        link_id: &str,
463    ) -> Result<Option<i64>, PimdirError> {
464        Ok(self
465            .conn
466            .query_row(
467                sql::SEQ_BY_LINK,
468                named_params! { ":collection": collection, ":link_id": link_id },
469                |row| row.get(0),
470            )
471            .optional()?)
472    }
473
474    /// Every source's binding of one item, keyed by source: the handle it is
475    /// bound to, the base the last sync agreed on, and the conflict its own
476    /// sync is stuck on (spec §13).
477    ///
478    /// The same shape a hub carries per item, read for one item rather than a
479    /// collection: an operator asking why a placement stopped moving is asking
480    /// about exactly these columns, and nothing else exposes them.
481    pub fn item_bindings(
482        &self,
483        collection: &str,
484        link_id: &str,
485    ) -> Result<BTreeMap<ReplicaSourceId, ReplicaSourceBinding>, PimdirError> {
486        Ok(rows(
487            &self.conn,
488            sql::ITEM_BINDINGS,
489            named_params! { ":collection": collection, ":link_id": link_id },
490            binding_from_row,
491        )?
492        .into_iter()
493        .map(|(_, source, binding)| (source, binding))
494        .collect())
495    }
496
497    /// The bindings waiting for a decision, across one account's
498    /// collections, ordered by collection then link id then source.
499    ///
500    /// `None` lists a single-account store whole, the account grouping
501    /// nothing there. Each row carries the three bodies the divergence
502    /// is between, so a resolver holding no credentials reads base,
503    /// local and remote from the store alone (spec §13).
504    ///
505    /// The question a sync answers at the end of every run, and the one
506    /// a listing command asks directly. Both are served by the partial
507    /// index over the flag, so a store with nothing outstanding pays for
508    /// an empty index rather than for a pass over every collection.
509    pub fn list_conflicts(
510        &self,
511        account: Option<&str>,
512    ) -> Result<Vec<PimdirConflict>, PimdirError> {
513        Ok(rows(
514            &self.conn,
515            sql::LIST_CONFLICTED_BINDINGS,
516            named_params! { ":account": account },
517            conflict_row,
518        )?)
519    }
520
521    /// The distinct source names the store has synced against, across all
522    /// collections. A client attributes its writes with this: a store
523    /// synced as a single source has exactly one, so the app writes as it
524    /// without configuration.
525    pub fn distinct_sources(&self) -> Result<Vec<String>, PimdirError> {
526        Ok(rows(&self.conn, sql::LIST_SOURCES, [], |r| r.get(0))?)
527    }
528
529    /// A collection's live (non-tombstone) item count (client read surface).
530    pub fn count_items(&self, collection: &str) -> Result<u64, PimdirError> {
531        let count: i64 = self.conn.query_row(
532            sql::COUNT_ITEMS,
533            named_params! { ":collection": collection },
534            |r| r.get(0),
535        )?;
536        let mut count = count.max(0) as u64;
537        if !self.overlay {
538            return Ok(count);
539        }
540
541        let pending = self.pending(collection)?;
542        for (seq, edits) in &pending.edits {
543            let Some(item) = self.committed_item(collection, *seq)? else {
544                continue;
545            };
546            if fold(item, Some(edits)).is_none() {
547                count -= 1;
548            }
549        }
550        Ok(count + self.arrived(&pending)?.len() as u64)
551    }
552
553    /// A keyset page of a collection's retained items.
554    ///
555    /// `after` is the exclusive lower bound on the public `seq`, `None`
556    /// starting from the beginning; at most `limit` items come back
557    /// ordered by `seq`, so the last item's [`seq`](PimdirItem::seq) is
558    /// the next page's cursor. The only read that returns retained items:
559    /// a caller presents them as a trash view, never merged into the live
560    /// listing.
561    pub fn list_retained(
562        &self,
563        collection: &ReplicaCollectionId,
564        after: Option<i64>,
565        limit: usize,
566    ) -> Result<Vec<PimdirItem>, PimdirError> {
567        Ok(rows(
568            &self.conn,
569            sql::LIST_RETAINED_PAGE,
570            named_params! {
571                ":collection": collection.0,
572                ":after": after.unwrap_or(0),
573                ":limit": limit as i64,
574            },
575            read_item_from_row,
576        )?)
577    }
578
579    /// A collection's retained item count, the counterpart of
580    /// [`count_items`](Self::count_items).
581    pub fn count_retained(&self, collection: &ReplicaCollectionId) -> Result<i64, PimdirError> {
582        Ok(self.conn.query_row(
583            sql::COUNT_RETAINED,
584            named_params! { ":collection": collection.0 },
585            |r| r.get(0),
586        )?)
587    }
588
589    /// The bytes retention is holding across the whole store, each distinct body
590    /// counted once.
591    ///
592    /// An upper bound on what a purge would reclaim: a body a live item
593    /// also points at keeps that reference and survives the sweep.
594    /// Reported so an operator can price a retention duration.
595    pub fn retained_bytes(&self) -> Result<u64, PimdirError> {
596        let bytes: i64 = self.conn.query_row(sql::RETAINED_BYTES, [], |r| r.get(0))?;
597        Ok(bytes.max(0) as u64)
598    }
599
600    /// A collection's handle-space epoch (spec §12), or `None` when the
601    /// store has never seen it. Starts at 1, bumped only by
602    /// [`write_rekeyed`](crate::client::PimdirSourceStore::write_rekeyed), so a frontend
603    /// derives an IMAP UIDVALIDITY from it alone.
604    pub fn generation(&self, collection: &str) -> Result<Option<i64>, PimdirError> {
605        Ok(self
606            .conn
607            .query_row(
608                sql::LOAD_GENERATION,
609                named_params! { ":collection": collection },
610                |r| r.get(0),
611            )
612            .optional()?)
613    }
614
615    /// The collections with pending (non-parked) queue work, for the owner's
616    /// drain loop.
617    pub fn queued_collections(&self) -> Result<Vec<String>, PimdirError> {
618        Ok(rows(&self.conn, sql::LIST_QUEUED_COLLECTIONS, [], |r| {
619            r.get(0)
620        })?)
621    }
622
623    /// A collection's pending (non-parked) actions in append order,
624    /// decoded (spec §15.4): a frontend overlays them on its item
625    /// projection for read-your-writes. An undecodable payload errors,
626    /// and the owner's next drain parks such a row.
627    pub fn pending_actions(
628        &self,
629        collection: &str,
630    ) -> Result<Vec<PimdirPendingAction>, PimdirError> {
631        load_pending_actions(&self.conn, collection)
632    }
633
634    /// Every parked action across the store, in append order, for status
635    /// surfaces and operator repair. Parked rows are skipped by the drain
636    /// and never silently deleted.
637    pub fn parked_actions(&self) -> Result<Vec<PimdirParkedAction>, PimdirError> {
638        Ok(rows(&self.conn, sql::LOAD_PARKED_ACTIONS, [], |r| {
639            Ok(PimdirParkedAction {
640                id: r.get(0)?,
641                created_at: r.get(1)?,
642                producer: r.get(2)?,
643                collection: r.get(3)?,
644                action: r.get(4)?,
645                payload: r.get(5)?,
646                attempts: r.get(6)?,
647                error: r.get(7)?,
648            })
649        })?)
650    }
651}
652
653/// What the queue's pending actions change about one collection (spec
654/// §15.4): the raw material of the overlay, folded once per read.
655///
656/// Built from the whole pending queue rather than one collection's rows,
657/// because a `move` or a `copy` is enqueued against the collection the
658/// item leaves and names the one it enters, so what arrives in a
659/// collection is written down elsewhere.
660#[derive(Debug, Default)]
661struct PimdirPending {
662    /// Actions restating or removing an item the collection already
663    /// holds, by public id, in append order.
664    edits: BTreeMap<i64, Vec<PimdirAction>>,
665    /// Items another collection's pending `move` or `copy` brings in,
666    /// each mapped to the collection its row is still read from.
667    arrivals: BTreeMap<i64, String>,
668    /// Queued creates targeting the collection. Counted rather than
669    /// listed: a create has no public id until the owner applies it.
670    creates: usize,
671}
672
673impl PimdirPending {
674    /// How many rows the fold can drop from a page: the items an action
675    /// takes out of the collection, by removing them or by moving them
676    /// away.
677    fn removals(&self) -> usize {
678        self.edits
679            .values()
680            .filter(|actions| {
681                actions
682                    .iter()
683                    .any(|action| matches!(action, PimdirAction::Remove { .. }))
684            })
685            .count()
686    }
687}
688
689impl PimdirReader {
690    /// One live item as the committed rows hold it, the queue ignored.
691    fn committed_item(
692        &self,
693        collection: &str,
694        seq: i64,
695    ) -> Result<Option<PimdirItem>, PimdirError> {
696        Ok(self
697            .conn
698            .query_row(
699                sql::GET_ITEM,
700                named_params! { ":collection": collection, ":seq": seq },
701                read_item_from_row,
702            )
703            .optional()?)
704    }
705
706    /// Folds the store's pending queue into what it changes about one
707    /// collection.
708    ///
709    /// The rows are walked in global append order, which is what makes a
710    /// later action win over an earlier one on the same item whichever
711    /// collection each was enqueued against.
712    fn pending(&self, collection: &str) -> Result<PimdirPending, PimdirError> {
713        let mut queued = Vec::new();
714        for from in self.queued_collections()? {
715            for action in load_pending_actions(&self.conn, &from)? {
716                queued.push((from.clone(), action));
717            }
718        }
719        queued.sort_by_key(|(_, action)| action.id);
720
721        let mut pending = PimdirPending::default();
722        for (from, action) in queued {
723            let here = from == collection;
724            match &action.action {
725                PimdirAction::Add { .. } if here => pending.creates += 1,
726                PimdirAction::SetFlags { seq, .. }
727                | PimdirAction::Update { seq, .. }
728                | PimdirAction::Remove { seq }
729                    if here =>
730                {
731                    pending.edits.entry(*seq).or_default().push(action.action);
732                }
733                PimdirAction::Move { seq, to } => {
734                    if here && to.0 != collection {
735                        pending
736                            .edits
737                            .entry(*seq)
738                            .or_default()
739                            .push(PimdirAction::Remove { seq: *seq });
740                    }
741                    if !here && to.0 == collection {
742                        pending.arrivals.insert(*seq, from);
743                    }
744                }
745                PimdirAction::Copy { seq, to } if !here && to.0 == collection => {
746                    pending.arrivals.insert(*seq, from);
747                }
748                _ => {}
749            }
750        }
751        Ok(pending)
752    }
753
754    /// The items pending moves and copies bring into the collection,
755    /// read from where their rows still sit and folded like any other.
756    fn arrived(&self, pending: &PimdirPending) -> Result<Vec<PimdirItem>, PimdirError> {
757        let mut items = Vec::new();
758        for (seq, from) in &pending.arrivals {
759            let Some(item) = self.committed_item(from, *seq)? else {
760                continue;
761            };
762            if let Some(item) = fold(item, pending.edits.get(seq)) {
763                items.push(item);
764            }
765        }
766        Ok(items)
767    }
768
769    /// Folds the overlay into one page: the committed items restated or
770    /// dropped, the arrivals that fall inside the page's window merged
771    /// in, and the whole re-ordered and cut back to the limit.
772    ///
773    /// Cutting after the merge is what keeps paging total: the page's
774    /// last item is still the next page's cursor, and an arrival past it
775    /// comes back on that next page rather than being lost here.
776    fn overlaid(
777        &self,
778        collection: &str,
779        limit: usize,
780        fetch: impl Fn(usize) -> Result<Vec<PimdirItem>, PimdirError>,
781        inside: impl Fn(&PimdirItem) -> bool,
782        order: impl Fn(&PimdirItem, &PimdirItem) -> Ordering,
783    ) -> Result<Vec<PimdirItem>, PimdirError> {
784        if !self.overlay {
785            return fetch(limit);
786        }
787
788        let pending = self.pending(collection)?;
789        // NOTE: a staged removal drops a row the statement returned, so a
790        // page asked for exactly `limit` rows would come back short in the
791        // middle of a collection, and a caller paging until a short page
792        // stops early and never sees the rest. At most one row per removing
793        // action can go, so over-reading by that many makes a page short
794        // only where the collection really ends.
795        let page = fetch(limit + pending.removals())?;
796        let mut items: Vec<PimdirItem> = page
797            .into_iter()
798            .filter_map(|item| {
799                let edits = pending.edits.get(&item.seq);
800                fold(item, edits)
801            })
802            .collect();
803
804        for item in self.arrived(&pending)? {
805            if inside(&item) && !items.iter().any(|held| held.seq == item.seq) {
806                items.push(item);
807            }
808        }
809
810        items.sort_by(order);
811        items.truncate(limit);
812        Ok(items)
813    }
814
815    /// The queued creates targeting a collection, in append order (spec
816    /// §15.4).
817    ///
818    /// Reported apart from the items because a create has no public id
819    /// until the owner applies it, so there is nothing to address it by
820    /// and no envelope to put it in. A consumer surfaces them its own
821    /// way: a count under a listing, a queue view of its own, or the
822    /// operator CLI's.
823    pub fn pending_creates(
824        &self,
825        collection: &str,
826    ) -> Result<Vec<PimdirPendingAction>, PimdirError> {
827        Ok(self
828            .pending_actions(collection)?
829            .into_iter()
830            .filter(|queued| matches!(queued.action, PimdirAction::Add { .. }))
831            .collect())
832    }
833
834    /// How many creates the collection has queued, the count a listing
835    /// reports so a staged item reads as queued rather than as lost.
836    pub fn count_pending_creates(&self, collection: &str) -> Result<usize, PimdirError> {
837        Ok(self.pending_creates(collection)?.len())
838    }
839}
840
841/// Folds an item's pending actions into it, `None` when they take it out
842/// of the collection.
843///
844/// `set-flags` is absolute rather than a delta (spec §15.3), so the last
845/// one wins outright; `update` repoints the body, which a producer wrote
846/// before enqueueing, so the item reads as `Full`.
847fn fold(mut item: PimdirItem, actions: Option<&Vec<PimdirAction>>) -> Option<PimdirItem> {
848    for action in actions.into_iter().flatten() {
849        match action {
850            PimdirAction::SetFlags { flags, .. } => item.flags = flags.clone(),
851            PimdirAction::Update { object, meta, .. } => {
852                item.object = Some(object.clone());
853                if meta.is_some() {
854                    item.meta = meta.clone();
855                }
856                item.level = ReplicaLevel::Full;
857            }
858            PimdirAction::Remove { .. } => return None,
859            _ => {}
860        }
861    }
862    Some(item)
863}