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}