Skip to main content

io_pimdir/
client.rs

1//! [`PimdirStore`]: the std store that services [`io_replica`]'s storage seam.
2//!
3//! It persists a [`ReplicaHub`] per collection, one shared item plus a
4//! base per source, and splits by whether an operation has a side at all:
5//! [`PimdirStore`] is the store itself (the client reads, retention, the
6//! queue), and [`PimdirSourceStore`], which [`for_source`] yields,
7//! services [`ReplicaStorage`] for one source. A single-source store is
8//! the N=1 case. Freshly probed placements have no link id to key an item
9//! on yet, so they are held in memory as a residual until a `Meta`
10//! upgrade resolves it.
11//!
12//! [`for_source`]: PimdirStore::for_source
13//!
14//! [`ReplicaStorage`]: io_replica::client::ReplicaStorage
15
16use alloc::{
17    format,
18    string::{String, ToString},
19    vec,
20    vec::Vec,
21};
22use core::sync::atomic::{AtomicU64, Ordering};
23use std::{
24    collections::{BTreeMap, BTreeSet, HashMap},
25    fmt, fs,
26    io::{self, ErrorKind, Write},
27    ops::{Deref, DerefMut},
28    path::{Path, PathBuf},
29    sync::Arc,
30};
31
32use io_replica::{
33    change::{ReplicaDropReason, ReplicaWriteOp},
34    client::ReplicaStorage,
35    collection::{ReplicaCheckpoint, ReplicaCollectionId},
36    coroutine::{ReplicaArg, ReplicaCoroutine, ReplicaCoroutineState, ReplicaYield},
37    hub::{ReplicaHub, ReplicaHubConflict, ReplicaHubItem, ReplicaSourceBinding, ReplicaSourceId},
38    mutate::{ReplicaMutate, ReplicaMutation},
39    object::{ReplicaHash, ReplicaObject},
40    placement::{
41        ReplicaBase, ReplicaFlags, ReplicaHandle, ReplicaLevel, ReplicaLinkId, ReplicaMeta,
42        ReplicaPlacement, ReplicaSortKey, ReplicaStatus,
43    },
44    storage::{ReplicaLoadScope, ReplicaLoaded},
45};
46use rusqlite::{
47    Connection, ErrorCode, OpenFlags, OptionalExtension, Params, Row, TransactionBehavior,
48    named_params, params, types::ToSql,
49};
50
51use crate::{
52    client::{lock::PimdirLock, reader::PimdirReader},
53    codec::{self, PimdirAction, PimdirActionError},
54    hash::{PimdirHashAlgo, PimdirHasher},
55    sql,
56};
57
58pub mod diagnostics;
59pub mod reader;
60
61mod lock;
62
63/// A pimdir store held as its owner: the write surface, over the read
64/// surface every role shares.
65///
66/// It carries what only an owner may do, none of which consults a
67/// source: retention and purge, the sweep and its repairs, and the queue
68/// rows a drain or a cancellation removes. The sync seam does consult
69/// one, and lives on [`PimdirSourceStore`], which
70/// [`for_source`](Self::for_source) yields. Reading is not an owner's
71/// privilege, so the reads live on [`PimdirReader`] and this handle
72/// dereferences to one.
73pub struct PimdirStore {
74    reader: PimdirReader,
75    /// The store's exclusive owner lock (spec §8), held for this handle's
76    /// lifetime; `None` on a handle opened through the deprecated
77    /// read-only constructor. Several handles of one process share one
78    /// lock.
79    _lock: Option<Arc<PimdirLock>>,
80    /// The account every collection this handle creates belongs to (spec §9.2);
81    /// `None` in a single-account store. Set with
82    /// [`for_account`](PimdirStore::for_account).
83    account: Option<String>,
84}
85
86impl Deref for PimdirStore {
87    type Target = PimdirReader;
88
89    fn deref(&self) -> &Self::Target {
90        &self.reader
91    }
92}
93
94impl DerefMut for PimdirStore {
95    fn deref_mut(&mut self) -> &mut Self::Target {
96        &mut self.reader
97    }
98}
99
100/// A pimdir store acting as one source (`"left"`, `"right"`, `"phone"`, …):
101/// the sync seam, where every operation means "as this side".
102///
103/// The underlying database and blobs are shared: several sources of one
104/// store are several handles over the same files. Dereferences to the
105/// [`PimdirStore`] it was made from, so the source-less surface stays
106/// reachable through it.
107pub struct PimdirSourceStore {
108    store: PimdirStore,
109    source: ReplicaSourceId,
110    /// Unlinked probed placements, awaiting the `Meta` upgrade that gives
111    /// them a link id; empty at rest between syncs.
112    ///
113    /// Keyed rather than listed: a first sync probes a whole collection
114    /// before linking any of it, so the residual grows to the collection
115    /// size while every insertion, drop and lookup searches it.
116    residual: HashMap<(ReplicaCollectionId, ReplicaHandle), ReplicaPlacement>,
117}
118
119/// A collection as seen by a client read (`list_collections`): its
120/// identity and presentation, kind-agnostic. The sync bindings and
121/// per-source state are not exposed here: a reader observes the shared
122/// truth only.
123#[derive(Clone, Debug, Eq, PartialEq)]
124pub struct PimdirCollection {
125    /// The stable collection id (the mailbox name for a mail store).
126    pub id: String,
127    /// The account this collection is grouped under (spec §9.2), `None`
128    /// in a single-account store. It groups and nothing more: no
129    /// identifier is scoped by it.
130    pub account: Option<String>,
131    /// The declared IANA media type (`message/rfc822`, `text/vcard`, …),
132    /// or the empty string when a sync created the collection before a
133    /// kind was set.
134    pub kind: String,
135    /// The display name.
136    pub name: String,
137    /// The parent collection id, for a hierarchy.
138    pub parent: Option<String>,
139    /// A presentation colour hint.
140    pub color: Option<String>,
141    /// A free-text description.
142    pub description: Option<String>,
143    /// An explicit sort key; `None` sorts after the ordered ones.
144    pub sort_order: Option<i64>,
145    /// The handle-space epoch (spec §12): starts at 1, bumped by the
146    /// owner only on a rekey, so a frontend derives epoch-dependent
147    /// protocol values (an IMAP UIDVALIDITY) from the store alone.
148    pub generation: i64,
149}
150
151/// Where one identity or one body sits, as the multiplicity reads report
152/// it (spec §9.2): one row per live placement, carrying the collection
153/// and account it occurs in.
154///
155/// A fact, not a verdict. The same vCard `UID` in two accounts' address
156/// books is two of these; whether that is one person shown twice or two
157/// people is the consumer's call.
158#[derive(Clone, Debug, Eq, PartialEq)]
159pub struct PimdirPlacement {
160    /// The collection the placement sits in.
161    pub collection: String,
162    /// The account that collection is grouped under, `None` when ungrouped.
163    pub account: Option<String>,
164    /// The item's public id, shared by every placement of one link id.
165    pub seq: i64,
166    /// The cross-collection identity.
167    pub link_id: ReplicaLinkId,
168    /// The body this placement points at, absent until hydrated.
169    pub object: Option<ReplicaHash>,
170    /// The placement's flag set.
171    pub flags: ReplicaFlags,
172    /// The detail tier the item is hydrated to.
173    pub level: ReplicaLevel,
174}
175
176/// One binding whose own sync is stuck on an unresolved content conflict
177/// (spec §13), as the conflict listing reports it: what the binding is,
178/// and the three bodies a resolver merges.
179///
180/// The whole divergence, off one row. Base is what the two sides last
181/// agreed on, `object` is the local side, `conflict_object` is the remote
182/// side at `conflict_revision`, and a resolver reading all three from the
183/// store needs no credentials and no round trip.
184#[derive(Clone, Debug, Eq, PartialEq)]
185pub struct PimdirConflict {
186    /// The collection the conflicted binding sits in.
187    pub collection: String,
188    /// The item's cross-source identity.
189    pub link_id: ReplicaLinkId,
190    /// The source that diverged from its own remote. One source can be
191    /// conflicted while another holding the same item is in sync, which
192    /// is why a conflict is named by this and not by the item alone.
193    pub source: ReplicaSourceId,
194    /// The item's handle on that source, what a resolver pushes back to.
195    pub handle: ReplicaHandle,
196    /// The remote revision observed when the divergence was recorded;
197    /// `None` when the remote reports none. A resolution computed
198    /// against it is stale once it moves.
199    pub conflict_revision: Option<String>,
200    /// The body the last sync agreed on, the merge's common ancestor;
201    /// `None` when the base carried no body.
202    pub base_object: Option<ReplicaHash>,
203    /// The local side of the divergence, the item's own body.
204    pub object: Option<ReplicaHash>,
205    /// The remote side at `conflict_revision`; `None` until the upgrade
206    /// pass supplies it, which is a conflict that is visible and listable
207    /// and not yet resolvable.
208    pub conflict_object: Option<ReplicaHash>,
209}
210
211/// Maps a `LIST_CONFLICTED_BINDINGS`-shaped row.
212fn conflict_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<PimdirConflict> {
213    Ok(PimdirConflict {
214        collection: r.get(0)?,
215        link_id: ReplicaLinkId(r.get(1)?),
216        source: ReplicaSourceId(r.get(2)?),
217        handle: ReplicaHandle(r.get(3)?),
218        conflict_revision: r.get(4)?,
219        base_object: r.get::<_, Option<String>>(5)?.map(ReplicaHash),
220        object: r.get::<_, Option<String>>(6)?.map(ReplicaHash),
221        conflict_object: r.get::<_, Option<String>>(7)?.map(ReplicaHash),
222    })
223}
224
225/// Maps a `LIST_COLLECTIONS`-shaped row.
226fn collection_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<PimdirCollection> {
227    Ok(PimdirCollection {
228        id: r.get(0)?,
229        account: r.get(1)?,
230        kind: r.get(2)?,
231        name: r.get(3)?,
232        parent: r.get(4)?,
233        color: r.get(5)?,
234        description: r.get(6)?,
235        sort_order: r.get(7)?,
236        generation: r.get(8)?,
237    })
238}
239
240/// One live item as seen by a client read (`list_items`/`get_item`): the
241/// shared truth a domain projects (an envelope, a vCard, an event),
242/// kind-agnostic. The `meta` is the raw stored summary, parsed by the
243/// reader against its own schema, and the `level` makes the read
244/// availability-aware: below `Full` the body is not local.
245#[derive(Clone, Debug, Eq, PartialEq)]
246pub struct PimdirItem {
247    /// The message's public id (`items.seq`): a small, stable,
248    /// store-global integer, the same across every mailbox the message is
249    /// filed in, that a consumer shows and passes back instead of the
250    /// long internal `link_id`.
251    pub seq: i64,
252    /// The cross-source link id (`Message-ID` for mail, UID for a vCard, …).
253    /// Internal: a consumer keys reads and edits by `seq`, not this.
254    pub link_id: ReplicaLinkId,
255    /// The item's flag set.
256    pub flags: ReplicaFlags,
257    /// The raw per-domain summary blob, verbatim; `None` when never projected.
258    pub meta: Option<ReplicaMeta>,
259    /// The kind's ordering key (spec §9.3): a normalised RFC 3339 instant
260    /// for mail and calendars, a normalised display name for contacts.
261    /// Empty means unknown, which sorts before every real key ascending
262    /// and after every one descending.
263    pub sort_key: String,
264    /// The content-addressed body hash; `None` until a `Full` hydrate.
265    pub object: Option<ReplicaHash>,
266    /// The detail tier the item is hydrated to.
267    pub level: ReplicaLevel,
268    /// What retention holds about the row, `None` while it is live. The
269    /// trash view is the only read that fills it.
270    pub retention: Option<PimdirRetention>,
271}
272
273/// What retention holds about an item no source binds any more (spec §11), on
274/// the row the trash view reads (`list_retained`).
275///
276/// Only that read fills it: a live item carries `None`, and the two reads
277/// are otherwise the same row, which is why they are the same type.
278#[derive(Clone, Debug, Eq, PartialEq)]
279pub struct PimdirRetention {
280    /// The RFC 3339 instant the last binding vanished, not when a server
281    /// deleted the item, which is unknowable. A revive clears it, so
282    /// restore-then-redelete restarts the purge clock.
283    pub at: String,
284    /// The source whose removal retired the item; diagnostic only.
285    pub by: Option<String>,
286    /// The body's size in bytes, `None` alongside an absent `object`:
287    /// what lets a caller price a purge without a second query.
288    pub size: Option<u64>,
289}
290
291/// What a purge retired.
292///
293/// Rows, not bytes: a purge releases the references a retained item held
294/// and nothing more. The bodies they kept are reclaimed by the collector,
295/// which is what reports the bytes ([`PimdirStore::collect_garbage`]).
296#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
297pub struct PimdirPurgeReport {
298    /// Retained items deleted.
299    pub items: usize,
300}
301
302/// What a collection reclaimed.
303#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
304pub struct PimdirGcReport {
305    /// Object rows dropped: bodies nothing references any more.
306    pub objects: usize,
307    /// Blob files unlinked: those rows' bodies and the orphans a crash
308    /// left, together.
309    pub blobs: usize,
310    /// The bytes those files freed.
311    pub bytes: u64,
312}
313
314/// One pending (non-parked) queue row, in append order (spec §15.4):
315/// what a frontend overlays on its item projection for read-your-writes,
316/// and what the owner's drain applies.
317#[derive(Clone, Debug, Eq, PartialEq)]
318pub struct PimdirPendingAction {
319    /// The row's global append id (`queue.id`).
320    pub id: i64,
321    /// The producer-supplied RFC 3339 enqueue timestamp.
322    pub created_at: String,
323    /// The enqueuing process, diagnostic only.
324    pub producer: String,
325    /// The decoded action.
326    pub action: PimdirAction,
327    /// Apply attempts so far.
328    pub attempts: i64,
329}
330
331/// One parked queue row: an action the owner judged permanently
332/// unappliable, recorded and skipped instead of blocking its collection's
333/// queue. Left for operators, never silently deleted (spec §15.2). The
334/// payload stays raw, since being undecodable may be why it parked.
335#[derive(Clone, Debug, Eq, PartialEq)]
336pub struct PimdirParkedAction {
337    /// The row's global append id (`queue.id`).
338    pub id: i64,
339    /// The producer-supplied RFC 3339 enqueue timestamp.
340    pub created_at: String,
341    /// The enqueuing process, diagnostic only.
342    pub producer: String,
343    /// The target collection.
344    pub collection: String,
345    /// The raw action kind.
346    pub action: String,
347    /// The raw versioned JSON payload.
348    pub payload: String,
349    /// Apply attempts before parking.
350    pub attempts: i64,
351    /// The failure that parked the row.
352    pub error: String,
353}
354
355/// What a [`drain_collection`](PimdirSourceStore::drain_collection) pass did.
356#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
357pub struct PimdirDrainReport {
358    /// Actions applied to the store and deleted from the queue.
359    pub applied: usize,
360    /// Actions parked with an error, left queryable.
361    pub parked: usize,
362    /// Actions this owner could not perform, left pending for one that
363    /// can (spec §15.2). Not a failure: parking would claim the action is
364    /// permanently unappliable, which is a different statement.
365    pub skipped: usize,
366}
367
368impl PimdirStore {
369    /// Opens (creating if absent) the store rooted at `dir`, as its owner.
370    ///
371    /// The handle takes the store's exclusive advisory lock (spec §8) and
372    /// holds it until it drops, so a store has one owner process at a
373    /// time; one already owned elsewhere is [`PimdirError::Owned`]
374    /// immediately, never a wait. Several handles of one process share
375    /// that lock: one per source, or one per account, is still one owner.
376    ///
377    /// A fresh database is created at the current schema version. A store
378    /// stamped with a higher `user_version` than this crate services is
379    /// refused with [`PimdirError::Version`] rather than half-read: the
380    /// spec is a draft, so such a store is recreated, never migrated.
381    pub fn open(dir: impl AsRef<Path>) -> Result<Self, PimdirError> {
382        Self::open_with_hash(dir, None)
383    }
384
385    /// Opens (creating if absent) the store rooted at `dir`, declaring the hash
386    /// its objects are named by (spec §5).
387    ///
388    /// A store records its algorithm once, at creation, in
389    /// `store_meta.hash_algo`: every blob is a file named by it, so it
390    /// cannot change afterwards. `hash` therefore applies to a store this
391    /// call creates, and an existing store whose algorithm differs is
392    /// refused with [`PimdirError::HashAlgo`] rather than opened into a
393    /// handle that would hash bodies to names it does not use. `None`
394    /// adopts what the store records, creating with
395    /// [`PimdirHashAlgo::default`].
396    ///
397    /// A consumer hashes through [`hash`](PimdirReader::hash) or
398    /// [`hasher`](PimdirReader::hasher) rather than choosing an algorithm of its
399    /// own, which is what keeps two implementations of one store naming
400    /// the same body the same way.
401    pub fn open_with_hash(
402        dir: impl AsRef<Path>,
403        hash: Option<PimdirHashAlgo>,
404    ) -> Result<Self, PimdirError> {
405        let dir = dir.as_ref();
406        fs::create_dir_all(dir)?;
407        let blobs = dir.join("objects");
408        fs::create_dir_all(&blobs)?;
409
410        // NOTE: before the connection, so a store this process may not own is
411        // refused before anything is opened, created or migrated in it.
412        let lock = PimdirLock::own(dir)?;
413
414        let mut conn = Connection::open(dir.join("pimdir.db"))?;
415        // NOTE: `busy_timeout` lets several handles of one store wait out
416        // each other's write transaction instead of failing with
417        // `SQLITE_BUSY`: §8's single-owner process opening `"left"` and
418        // `"right"`, or a sync fanning work across same-source handles.
419        // 30s absorbs a burst of large writes contending on the lock.
420        conn.execute_batch(
421            "PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 30000;",
422        )?;
423        init_schema(&mut conn, hash.unwrap_or_default())?;
424        let hash = read_hash_algo(&conn, hash)?;
425
426        Ok(Self {
427            reader: PimdirReader::over(conn, dir.to_path_buf(), blobs, hash),
428            _lock: Some(lock),
429            account: None,
430        })
431    }
432
433    /// Opens an **existing** store rooted at `dir` read-only.
434    ///
435    /// The database is opened with `SQLITE_OPEN_READ_ONLY`: nothing is
436    /// created, so a missing database errors, one no owner has stamped
437    /// yet is [`PimdirError::Uncreated`], and any other schema version is
438    /// refused with [`PimdirError::Version`]. The returned handle exposes
439    /// the full read surface; any write through it fails at the SQLite
440    /// layer.
441    ///
442    /// A reader owns nothing and takes no lock: any number of them may
443    /// run against a store an owner holds.
444    #[deprecated(
445        since = "0.3.0",
446        note = "use `PimdirReader::open`, which carries the reads and no write at all"
447    )]
448    pub fn open_read_only(dir: impl AsRef<Path>) -> Result<Self, PimdirError> {
449        let dir = dir.as_ref();
450        let flags = OpenFlags::SQLITE_OPEN_READ_ONLY
451            | OpenFlags::SQLITE_OPEN_URI
452            | OpenFlags::SQLITE_OPEN_NO_MUTEX;
453        let conn = Connection::open_with_flags(dir.join("pimdir.db"), flags)?;
454        conn.execute_batch("PRAGMA busy_timeout = 30000;")?;
455
456        let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
457        match version {
458            version if version == sql::VERSION => {}
459            // NOTE: an unstamped database is one no owner has opened yet, not
460            // a version this crate cannot read, and the two want different
461            // answers from whoever is holding this handle.
462            0 => return Err(PimdirError::Uncreated),
463            found => return Err(PimdirError::Version { found }),
464        }
465        check_version_agreement(&conn, version)?;
466        check_rename_cascades(&conn)?;
467        let hash = read_hash_algo(&conn, None)?;
468
469        Ok(Self {
470            reader: PimdirReader::over(conn, dir.to_path_buf(), dir.join("objects"), hash),
471            _lock: None,
472            account: None,
473        })
474    }
475
476    /// Binds this handle to an account, so every collection it creates is
477    /// grouped under it (spec §9.2).
478    ///
479    /// A single-account store never calls this and its collections carry
480    /// a `NULL` account, which is what every by-account read matches when
481    /// given `None`. A multi-account owner opens one handle per account,
482    /// the way it already opens one per source; §8's single-owner rule is
483    /// unchanged by how many a process holds.
484    ///
485    /// The account groups and nothing more: it partitions no identifier,
486    /// so two accounts holding one link id still share a `seq`, and one
487    /// body reaching both is still stored once. Where an identity or a
488    /// body occurs is reported by
489    /// [`link_placements`](PimdirReader::link_placements) and
490    /// [`object_placements`](PimdirReader::object_placements).
491    pub fn for_account(mut self, account: impl Into<String>) -> Self {
492        self.account = Some(account.into());
493        self
494    }
495
496    /// The account this handle writes under, `None` in a single-account store.
497    pub fn account(&self) -> Option<&str> {
498        self.account.as_deref()
499    }
500
501    /// Binds this handle to a source, yielding the sync seam: `load` projects
502    /// the hub for that side and `write` folds its decisions back.
503    ///
504    /// A source is a side this store syncs with (`"left"`, `"right"`,
505    /// `"phone"`, …), so it is only ever named by an operation acting as
506    /// one. Everything else, the reads, retention and the queue, stays on
507    /// the source-less handle and is still reachable through this one.
508    pub fn for_source(self, source: impl Into<String>) -> PimdirSourceStore {
509        PimdirSourceStore {
510            store: self,
511            source: ReplicaSourceId(source.into()),
512            residual: HashMap::new(),
513        }
514    }
515
516    /// Loads a collection's full [`ReplicaHub`]: every source's items and
517    /// bindings, not only this handle's source.
518    ///
519    /// [`load`](ReplicaStorage::load) projects the hub for one source; a
520    /// multi-source consumer reads the whole hub to project each side and
521    /// to spot items held by a single source.
522    pub fn load_hub(&self, collection: &str) -> Result<ReplicaHub, PimdirError> {
523        Ok(load_hub(&self.conn, collection)?)
524    }
525
526    /// Declares a collection's media type (`kind`), creating the collection if
527    /// absent and updating its kind otherwise.
528    ///
529    /// The kind is an [IANA media
530    /// type](https://www.iana.org/assignments/media-types)
531    /// (`message/rfc822`, `text/vcard`, `text/calendar`, …), static
532    /// consumer configuration rather than something the sync engine
533    /// derives, so a consumer sets it out of band from the
534    /// [`ReplicaStorage`] seam. That is what makes the store
535    /// self-describing (§4.3) and lets one store hold several kinds. The
536    /// lazy creation inside [`write`](ReplicaStorage::write) uses
537    /// `ON CONFLICT DO NOTHING`, so it never clobbers a kind set here.
538    ///
539    /// The collection is grouped under this handle's account
540    /// ([`for_account`](Self::for_account)); an existing row keeps the
541    /// account it had, and only the kind is updated.
542    pub fn ensure_collection(&self, collection: &str, kind: &str) -> Result<(), PimdirError> {
543        self.conn.execute(
544            sql::SET_COLLECTION_KIND,
545            named_params! {
546                ":collection": collection,
547                ":account": self.account.as_deref(),
548                ":kind": kind,
549            },
550        )?;
551        Ok(())
552    }
553
554    /// Regroups a collection under `account`, or out of one with `None`.
555    ///
556    /// Safe at any time: the account partitions no identifier (spec §9.2), so
557    /// the move leaves the collection's `seq`s, link ids and objects alone.
558    pub fn set_collection_account(
559        &self,
560        collection: &str,
561        account: Option<&str>,
562    ) -> Result<(), PimdirError> {
563        self.conn.execute(
564            sql::SET_COLLECTION_ACCOUNT,
565            named_params! { ":collection": collection, ":account": account },
566        )?;
567        Ok(())
568    }
569
570    /// Restates one item's ordering key (spec §9.3).
571    ///
572    /// For a re-projection: a store written before its kind had a
573    /// sort-key convention, one whose convention changed, or a consumer
574    /// deriving the key from the `meta` it wrote itself. Not part of the
575    /// ordinary write path, which preserves a key by never naming it.
576    pub fn set_sort_key(
577        &self,
578        collection: &str,
579        link_id: &str,
580        sort_key: &str,
581    ) -> Result<(), PimdirError> {
582        self.conn.execute(
583            sql::SET_SORT_KEY,
584            named_params! {
585                ":collection": collection,
586                ":link_id": link_id,
587                ":sort_key": sort_key,
588            },
589        )?;
590        Ok(())
591    }
592
593    /// Gives a collection a new id, carrying its whole contents with it.
594    ///
595    /// Every foreign key onto `collections(id)` is `ON UPDATE CASCADE`,
596    /// so the items, bindings, sources, queue rows and child collections
597    /// follow in the same statement (spec §14). This is the only safe way
598    /// to change an id: recreating the collection under the new one takes
599    /// every item and binding with it through `ON DELETE CASCADE`,
600    /// turning a rename into a full re-download and discarding any staged
601    /// local change.
602    ///
603    /// Two things make an id change: a server renaming the collection,
604    /// and an owner renaming an account whose id it namespaced its
605    /// collection ids with. An account rename is one call per collection;
606    /// run them in one transaction and the account moves atomically.
607    pub fn rename_collection(&self, collection: &str, new_id: &str) -> Result<(), PimdirError> {
608        self.conn.execute(
609            sql::RENAME_COLLECTION,
610            named_params! { ":collection": collection, ":new_id": new_id },
611        )?;
612        Ok(())
613    }
614}
615
616/// The retention surface (spec §11): the trash a store keeps instead of
617/// losing items, and the only operations that truly destroy one.
618///
619/// An item whose last source binding vanished is retained, not deleted:
620/// hidden from the sync seam and from the live client reads, but kept
621/// whole, body included. It comes back by revival, its link id
622/// reappearing from a source or a client `add`, or not at all until a
623/// purge reclaims it. Retention is unconditional; when to reclaim is the
624/// owner's schedule, which is why every purge takes its boundary from the
625/// caller.
626impl PimdirStore {
627    /// Purges one retained item by its public id, returning whether there was
628    /// one to purge.
629    ///
630    /// The row goes, its bindings cascade, and the body it released is
631    /// unlinked by the ordinary sweep once nothing else references it: a
632    /// purge collects nothing itself. A live item is never reached, the
633    /// statement being guarded on the retention stamp, so an operator
634    /// emptying the trash cannot destroy synced data.
635    pub fn purge(
636        &mut self,
637        collection: &ReplicaCollectionId,
638        seq: i64,
639    ) -> Result<bool, PimdirError> {
640        let tx = self
641            .conn
642            .transaction_with_behavior(TransactionBehavior::Immediate)
643            .map_err(busy_or_sql)?;
644
645        // NOTE: the delete reports the hashes the row pinned, so the
646        // release rides the statement that caused it rather than a read
647        // visiting the same row first. Nothing to return means there was
648        // no retained item under that id, which is how a live one is
649        // refused too.
650        let pinned: Option<(Option<String>, Option<String>)> = tx
651            .prepare(sql::PURGE_ITEM)?
652            .query_row(
653                named_params! { ":collection": collection.0, ":seq": seq },
654                |row| Ok((row.get(0)?, row.get(1)?)),
655            )
656            .optional()?;
657        let Some((object, conflict_object)) = pinned else {
658            return Ok(false);
659        };
660
661        release_pins(&tx, [object, conflict_object].into_iter().flatten())?;
662        tx.commit().map_err(busy_or_sql)?;
663        Ok(true)
664    }
665
666    /// The scheduled sweep: purges every item retired **strictly before**
667    /// `cutoff` (RFC 3339), store-wide, reporting how many it retired.
668    ///
669    /// The boundary is the caller's, not the store's clock: an owner
670    /// computes it from its own retention duration, so the store holds no
671    /// policy and the sweep stays deterministic. An item retained exactly
672    /// at `cutoff` is kept, and a cutoff of now reproduces the
673    /// terminal-delete behaviour of a store that never retained, which is
674    /// why there is no on/off switch.
675    pub fn purge_retained_before(
676        &mut self,
677        cutoff: &str,
678    ) -> Result<PimdirPurgeReport, PimdirError> {
679        let tx = self
680            .conn
681            .transaction_with_behavior(TransactionBehavior::Immediate)
682            .map_err(busy_or_sql)?;
683
684        // NOTE: one pass. The delete reports what each row it takes was
685        // pinning, so the count and the pins to release both come off the
686        // statement that did the work.
687        let pinned: Vec<(Option<String>, Option<String>)> = rows(
688            &tx,
689            sql::PURGE_RETAINED_BEFORE,
690            named_params! { ":cutoff": cutoff },
691            |row| Ok((row.get(0)?, row.get(1)?)),
692        )?;
693        let items = pinned.len();
694        release_pins(
695            &tx,
696            pinned
697                .into_iter()
698                .flat_map(|(object, conflict)| [object, conflict])
699                .flatten(),
700        )?;
701        tx.commit().map_err(busy_or_sql)?;
702
703        Ok(PimdirPurgeReport { items })
704    }
705}
706
707/// Reclamation and repair (spec §5, §7): the two things a store does not
708/// do to itself.
709///
710/// No write collects. An object at refcount zero is unreferenced rather
711/// than deleted, and stays until a collector runs, which is what lets a
712/// consumer store a body in one batch and attach it in a later one (spec
713/// §14). Repair is the other half: a refcount is maintained
714/// incrementally, so recomputing it from the pointers that justify it is
715/// how a drift is settled rather than reported for ever.
716impl PimdirStore {
717    /// Reclaims what nothing references: the object rows at refcount zero, the
718    /// bodies they held, and any orphan blob a crash left behind.
719    ///
720    /// Takes the store's staging lock exclusively, so no producer is
721    /// between a blob write and the queue row that pins it, and runs on
722    /// an owning handle, which already holds the store against other
723    /// owners. Those two let the sweep take a body the moment nothing
724    /// references it, with no grace window standing in for a lock.
725    ///
726    /// The rows go inside a transaction and the files after it, in the
727    /// order a crash can afford: a body without its row is an orphan the
728    /// next collection takes, where a row without its body fails a read.
729    pub fn collect_garbage(&mut self) -> Result<PimdirGcReport, PimdirError> {
730        let _staging = PimdirLock::collect(&self.dir)?;
731
732        let tx = self
733            .conn
734            .transaction_with_behavior(TransactionBehavior::Immediate)
735            .map_err(busy_or_sql)?;
736        let objects = tx.execute(sql::DELETE_GARBAGE_OBJECTS, [])?;
737        tx.commit().map_err(busy_or_sql)?;
738
739        // NOTE: one pass over the tree rather than an unlink per
740        // collected row plus a pass for the orphans: a body whose row the
741        // transaction above dropped is an orphan by now. Asked per file
742        // on the primary key rather than read whole into a set, since a
743        // store holds hundreds of thousands of hashes and the question is
744        // always about one file.
745        let mut report = PimdirGcReport {
746            objects,
747            ..Default::default()
748        };
749        let mut exists = self.conn.prepare(sql::OBJECT_EXISTS)?;
750        for blob in self.blobs().files()? {
751            if exists.exists(named_params! { ":hash": blob.hash })? {
752                continue;
753            }
754            fs::remove_file(&blob.path)?;
755            report.blobs += 1;
756            report.bytes += blob.size;
757        }
758        drop(exists);
759
760        Ok(report)
761    }
762
763    /// Recomputes every object's refcount from the five columns that pin one
764    /// (spec §7), returning how many rows disagreed and were corrected.
765    ///
766    /// The counterpart of the incremental maintenance every write does: a
767    /// count that drifted, from a bug here or a foreign writer, is
768    /// otherwise reported for ever. A whole-store pass, so it belongs to
769    /// a repair verb rather than to a write.
770    pub fn recompute_refcounts(&self) -> Result<usize, PimdirError> {
771        Ok(self.conn.execute(sql::RECOMPUTE_REFCOUNTS, [])?)
772    }
773
774    /// Deletes the bindings whose item is gone, returning how many, and leaves
775    /// every other dangling row alone.
776    ///
777    /// A binding with no item is unreachable: nothing reads it and no
778    /// sync projects it. The other dangling rows a check reports are not
779    /// like that, an item whose object row is missing being still the
780    /// item and a queue row whose body is missing still an intent, so
781    /// deleting them would destroy data rather than repair it.
782    pub fn clear_dangling_bindings(&self) -> Result<usize, PimdirError> {
783        Ok(self.conn.execute(sql::DELETE_DANGLING_BINDINGS, [])?)
784    }
785}
786
787/// Runs one statement and collects every row through `map`.
788///
789/// A `Transaction` derefs to a `Connection`, so a read inside a write
790/// batch uses this too.
791fn rows<T>(
792    conn: &Connection,
793    sql: &str,
794    params: impl Params,
795    map: impl FnMut(&Row) -> rusqlite::Result<T>,
796) -> rusqlite::Result<Vec<T>> {
797    conn.prepare(sql)?.query_map(params, map)?.collect()
798}
799
800/// Releases the object references a retained row (or a queue row) held, so the
801/// ordinary sweep can reclaim a body nothing points at any more.
802fn release_pins(
803    conn: &Connection,
804    hashes: impl Iterator<Item = String>,
805) -> Result<(), PimdirError> {
806    // NOTE: one statement rather than one per hash: a purge sweeping
807    // fifty thousand retained items releases two pins each, and a point
808    // update per pin is a hundred thousand statements to express a set
809    // operation.
810    let hashes: Vec<String> = hashes.collect();
811    if hashes.is_empty() {
812        return Ok(());
813    }
814    conn.execute(
815        sql::RELEASE_PINS,
816        named_params! { ":hashes": serde_json::to_string(&hashes)? },
817    )?;
818    Ok(())
819}
820
821/// The action-queue owner surface (spec §15) and collection generations
822/// (spec §12): the single owning process drains producer-requested
823/// mutations into the store, and marks a rebuild for readers.
824impl PimdirStore {
825    /// Cancels one queue row (spec §15.5) as the store's owner, holding
826    /// that role only for the length of the call.
827    ///
828    /// Cancelling is an owner write, and it is the only retraction a
829    /// queued create has: the kinds that address an existing item are
830    /// retracted by their inverse instead, `set-flags` being absolute
831    /// rather than a delta. A consumer that is otherwise a reader and a
832    /// producer needs the role for this one statement, so it takes it
833    /// here rather than by holding a handle that could also drain the
834    /// queue or sweep the objects.
835    ///
836    /// The store must exist: this never creates one, so a mistyped path
837    /// is [`PimdirError::Uncreated`] rather than an empty store. A store
838    /// another process owns is [`PimdirError::Owned`] at once, never a
839    /// wait, and the caller reports it as a sync being in flight: the
840    /// action is still queued, and may have been applied in the meantime.
841    pub fn cancel_action(dir: impl AsRef<Path>, id: i64) -> Result<bool, PimdirError> {
842        let dir = dir.as_ref();
843        if !dir.join("pimdir.db").is_file() {
844            return Err(PimdirError::Uncreated);
845        }
846
847        Self::open(dir)?.drop_action(id)
848    }
849
850    /// Removes one queue row by request rather than by application, pending or
851    /// parked, returning whether there was a row to remove (spec §15.5).
852    ///
853    /// One verb for the two ways a row leaves the queue unapplied: a
854    /// producer cancelling a queued action, and an owner acknowledging an
855    /// intent it performed out of band, which the drain could only skip.
856    /// The row's body pin is released in the same transaction, so a blob
857    /// nothing else references falls to the ordinary sweep.
858    pub fn drop_action(&mut self, id: i64) -> Result<bool, PimdirError> {
859        let tx = self
860            .conn
861            .transaction_with_behavior(TransactionBehavior::Immediate)
862            .map_err(busy_or_sql)?;
863
864        let hash: Option<Option<String>> = tx
865            .query_row(sql::LOAD_ACTION_ROW, named_params! { ":id": id }, |r| {
866                r.get(1)
867            })
868            .optional()?;
869        let Some(hash) = hash else {
870            return Ok(false);
871        };
872
873        tx.execute(sql::CANCEL_ACTION, named_params! { ":id": id })?;
874        release_pins(&tx, hash.into_iter())?;
875        tx.commit().map_err(busy_or_sql)?;
876        Ok(true)
877    }
878
879    /// Records a failed apply an owner performed itself (spec §15.2).
880    ///
881    /// `None` is the transient case: the attempt counter advances and the
882    /// row stays pending for the next drain. `Some(error)` is the
883    /// permanent one: the row parks with the failure, visible to
884    /// operators instead of blocking its collection. An unknown id is a
885    /// no-op, since the row may have been applied or cancelled meanwhile.
886    pub fn fail_action(&self, id: i64, error: Option<&str>) -> Result<(), PimdirError> {
887        let Some(error) = error else {
888            self.conn
889                .execute(sql::BUMP_ATTEMPTS, named_params! { ":id": id })?;
890            return Ok(());
891        };
892
893        let attempts: Option<i64> = self
894            .conn
895            .query_row(sql::LOAD_ACTION_ROW, named_params! { ":id": id }, |r| {
896                r.get(0)
897            })
898            .optional()?;
899        if let Some(attempts) = attempts {
900            self.conn.execute(
901                sql::PARK_ACTION,
902                named_params! { ":id": id, ":attempts": attempts + 1, ":error": error },
903            )?;
904        }
905        Ok(())
906    }
907}
908
909/// The sync seam and what only a side can mean: the source-bound writes,
910/// and the drain that stages a producer's queued mutation for that side.
911impl PimdirSourceStore {
912    /// The source this handle acts as.
913    pub fn source(&self) -> &str {
914        &self.source.0
915    }
916
917    /// Binds this handle to an account, so every collection it creates is
918    /// grouped under it (spec §9.2); see
919    /// [`PimdirStore::for_account`], which this defers to so the two
920    /// bindings can be given in either order.
921    pub fn for_account(mut self, account: impl Into<String>) -> Self {
922        self.store = self.store.for_account(account);
923        self
924    }
925
926    /// Applies a handle-space rebuild's write batch and bumps the collection's
927    /// generation **in the same transaction**, returning the new generation.
928    ///
929    /// The owner drives io-replica's rekey coroutine and routes its
930    /// rebuild writes here rather than to
931    /// [`write`](ReplicaStorage::write), so "the ids you cached are void"
932    /// commits atomically with the rebuild that voided them. Ordinary
933    /// syncs, full resyncs and content changes never bump.
934    pub fn write_rekeyed(
935        &mut self,
936        collection: &str,
937        ops: Vec<ReplicaWriteOp>,
938    ) -> Result<i64, PimdirError> {
939        // NOTE: as in `write`, the bodies land before the transaction opens.
940        stage_blobs(&self.store.reader.blobs, &ops)?;
941
942        let tx = self
943            .store
944            .reader
945            .conn
946            .transaction_with_behavior(TransactionBehavior::Immediate)
947            .map_err(busy_or_sql)?;
948        apply_ops(
949            &tx,
950            &self.store.reader.blobs,
951            &self.source,
952            self.store.account.as_deref(),
953            &mut self.residual,
954            ops,
955        )?;
956        tx.execute(
957            sql::ENSURE_COLLECTION,
958            named_params! { ":collection": collection, ":account": self.store.account.as_deref() },
959        )?;
960        let generation: i64 = tx.query_row(
961            sql::BUMP_GENERATION,
962            named_params! { ":collection": collection },
963            |r| r.get(0),
964        )?;
965        tx.commit().map_err(busy_or_sql)?;
966        Ok(generation)
967    }
968
969    /// Drains a collection's pending actions in append order (spec §15.2).
970    ///
971    /// Each action is applied as the store mutation it names, resolving
972    /// its public `seq` to the internal link id and folding the
973    /// corresponding io-replica mutation through the store's own write
974    /// machinery, and its row is deleted in the same transaction, so
975    /// application is exactly-once and never partially visible. An action
976    /// the owner judges permanently unappliable is parked with its error
977    /// and skipped; a transient failure increments the row's `attempts`
978    /// and stops the pass, preserving apply order for the retry.
979    ///
980    /// An action whose kind this store defines no semantics for is
981    /// skipped: left pending, never parked, never blocking the actions
982    /// behind it. That is what lets one queue carry store mutations any
983    /// owner applies beside capability-bound intents only a specific
984    /// owner can perform; that owner reads the row through
985    /// [`pending_actions`](PimdirReader::pending_actions), performs it,
986    /// and acknowledges it with
987    /// [`drop_action`](PimdirStore::drop_action).
988    pub fn drain_collection(&mut self, collection: &str) -> Result<PimdirDrainReport, PimdirError> {
989        let pending: Vec<QueueRow> = rows(
990            &self.store.reader.conn,
991            sql::LOAD_PENDING_ACTIONS,
992            named_params! { ":collection": collection },
993            |r| {
994                Ok(QueueRow {
995                    id: r.get(0)?,
996                    action: r.get(3)?,
997                    payload: r.get(4)?,
998                    object_hash: r.get(5)?,
999                })
1000            },
1001        )?;
1002
1003        let mut report = PimdirDrainReport::default();
1004        for row in pending {
1005            let action = match codec::action_from_payload(&row.action, &row.payload) {
1006                Ok(action) => action,
1007                Err(err) => {
1008                    self.fail_action(row.id, Some(&err.to_string()))?;
1009                    report.parked += 1;
1010                    continue;
1011                }
1012            };
1013            if matches!(action, PimdirAction::Unknown { .. }) {
1014                report.skipped += 1;
1015                continue;
1016            }
1017            match self.apply_queued(collection, &row, &action) {
1018                Ok(None) => report.applied += 1,
1019                Ok(Some(PimdirRefusal::Skip)) => report.skipped += 1,
1020                Ok(Some(PimdirRefusal::Park(reason))) => {
1021                    self.fail_action(row.id, Some(&reason))?;
1022                    report.parked += 1;
1023                }
1024                Err(err) => {
1025                    self.store
1026                        .conn
1027                        .execute(sql::BUMP_ATTEMPTS, named_params! { ":id": row.id })?;
1028                    return Err(err);
1029                }
1030            }
1031        }
1032        Ok(report)
1033    }
1034
1035    /// Applies one queued action and deletes its row in one transaction,
1036    /// releasing the row's object pin as the applied item takes its own.
1037    /// Returns `None` when applied, and the [`PimdirRefusal`] otherwise,
1038    /// rolling the transaction back so the row is as it was.
1039    fn apply_queued(
1040        &mut self,
1041        collection: &str,
1042        row: &QueueRow,
1043        action: &PimdirAction,
1044    ) -> Result<Option<PimdirRefusal>, PimdirError> {
1045        let tx = self
1046            .store
1047            .reader
1048            .conn
1049            .transaction_with_behavior(TransactionBehavior::Immediate)
1050            .map_err(busy_or_sql)?;
1051
1052        // NOTE: claim the row before doing its work. The pending rows
1053        // were read outside any transaction, so another owner may have
1054        // applied this one already and `add` or `copy` would land twice.
1055        // A claim that deletes nothing means exactly that.
1056        let claimed = tx
1057            .prepare(sql::CLAIM_ACTION)?
1058            .query_row(named_params! { ":id": row.id }, |r| r.get::<_, i64>(0))
1059            .optional()?;
1060        if claimed.is_none() {
1061            return Ok(None);
1062        }
1063
1064        let ops = match stage_action(&tx, &self.source, collection, row.id, action)? {
1065            Ok(ops) => ops,
1066            // NOTE: dropping the transaction rolls the attempt back, so a
1067            // skipped row is left exactly as it was found: still pending,
1068            // its attempts untouched, for the owner that can apply it.
1069            Err(refusal) => return Ok(Some(refusal)),
1070        };
1071        apply_ops(
1072            &tx,
1073            &self.store.reader.blobs,
1074            &self.source,
1075            self.store.account.as_deref(),
1076            &mut self.residual,
1077            ops,
1078        )?;
1079        // NOTE: the pin hand-over: the queue row's reference, taken at
1080        // enqueue, is released as the row goes, while the applied item's
1081        // own was just taken by `apply_ops`, both in this transaction, so
1082        // a queued body is never sweepable in between.
1083        if let Some(hash) = &row.object_hash {
1084            tx.execute(
1085                sql::ADJUST_REFCOUNT,
1086                named_params! { ":delta": -1, ":hash": hash },
1087            )?;
1088        }
1089        tx.commit().map_err(busy_or_sql)?;
1090        Ok(None)
1091    }
1092}
1093
1094impl ReplicaStorage for PimdirSourceStore {
1095    type Error = PimdirError;
1096
1097    fn load(
1098        &self,
1099        collection: &ReplicaCollectionId,
1100        scope: &ReplicaLoadScope,
1101    ) -> Result<ReplicaLoaded, Self::Error> {
1102        // NOTE: the scope narrows the hub read, and the projection only
1103        // produces placements for what was read. A handle scope cannot
1104        // narrow the query, the hub being keyed by link id, so a handle
1105        // is resolved through its binding first and one no binding holds
1106        // contributes nothing.
1107        let hub = match scope {
1108            ReplicaLoadScope::All => load_hub(&self.store.reader.conn, &collection.0)?,
1109            ReplicaLoadScope::Links(links) => {
1110                let links: Vec<String> = links.iter().map(|l| l.0.clone()).collect();
1111                load_hub_by_link(&self.store.reader.conn, &collection.0, &links)?
1112            }
1113            ReplicaLoadScope::Handles(handles) => {
1114                let mut links = Vec::new();
1115                for handle in handles {
1116                    let link = self
1117                        .store
1118                        .reader
1119                        .conn
1120                        .query_row(
1121                            sql::LINK_FOR_HANDLE,
1122                            named_params! {
1123                                ":collection": collection.0,
1124                                ":source": self.source.0,
1125                                ":handle": handle.0,
1126                            },
1127                            |r| r.get::<_, String>(0),
1128                        )
1129                        .optional()?;
1130                    links.extend(link);
1131                }
1132                load_hub_by_link(&self.store.reader.conn, &collection.0, &links)?
1133            }
1134        };
1135
1136        let mut placements = hub.project(collection, &self.source);
1137        placements.extend(
1138            self.residual
1139                .values()
1140                .filter(|p| &p.collection == collection)
1141                .cloned(),
1142        );
1143
1144        let checkpoint = self
1145            .store
1146            .reader
1147            .conn
1148            .query_row(
1149                sql::LOAD_CHECKPOINT,
1150                named_params! { ":collection": collection.0, ":source": self.source.0 },
1151                |r| r.get::<_, Option<Vec<u8>>>(0),
1152            )
1153            .optional()?
1154            .flatten()
1155            .map(ReplicaCheckpoint);
1156
1157        Ok(ReplicaLoaded {
1158            placements,
1159            checkpoint,
1160        })
1161    }
1162
1163    fn lookup_objects(
1164        &self,
1165        links: &[ReplicaLinkId],
1166    ) -> Result<BTreeMap<ReplicaLinkId, ReplicaHash>, Self::Error> {
1167        let ids: Vec<&str> = links.iter().map(|l| l.0.as_str()).collect();
1168        let json = serde_json::to_string(&ids)?;
1169
1170        let found = rows(
1171            &self.store.reader.conn,
1172            sql::LOOKUP_OBJECTS,
1173            named_params! { ":links": json, ":account": self.store.account.as_deref() },
1174            |r| {
1175                Ok((
1176                    ReplicaLinkId(r.get::<_, String>(0)?),
1177                    ReplicaHash(r.get::<_, String>(1)?),
1178                ))
1179            },
1180        )?;
1181        let mut map: BTreeMap<ReplicaLinkId, ReplicaHash> = found.into_iter().collect();
1182
1183        // NOTE: a body hydrated on a not-yet-linked residual placement.
1184        for placement in self.residual.values() {
1185            if let (Some(link), Some(object)) = (&placement.link_id, &placement.object) {
1186                if links.contains(link) {
1187                    map.entry(link.clone()).or_insert_with(|| object.clone());
1188                }
1189            }
1190        }
1191
1192        Ok(map)
1193    }
1194
1195    fn write(&mut self, ops: Vec<ReplicaWriteOp>) -> Result<(), Self::Error> {
1196        // NOTE: bodies first, outside the transaction, so the writer lock
1197        // is never held across a file write and two `fsync`s (spec §14).
1198        stage_blobs(&self.store.reader.blobs, &ops)?;
1199
1200        // NOTE: BEGIN IMMEDIATE takes the single writer lock up front
1201        // (§8), so a writer that cannot get it within `busy_timeout`
1202        // fails fast with `Busy` rather than deep inside the batch on a
1203        // deferred lock upgrade.
1204        let tx = self
1205            .store
1206            .reader
1207            .conn
1208            .transaction_with_behavior(TransactionBehavior::Immediate)
1209            .map_err(busy_or_sql)?;
1210        apply_ops(
1211            &tx,
1212            &self.store.reader.blobs,
1213            &self.source,
1214            self.store.account.as_deref(),
1215            &mut self.residual,
1216            ops,
1217        )?;
1218        tx.commit().map_err(busy_or_sql)?;
1219        Ok(())
1220    }
1221}
1222
1223impl Deref for PimdirSourceStore {
1224    type Target = PimdirStore;
1225
1226    fn deref(&self) -> &Self::Target {
1227        &self.store
1228    }
1229}
1230
1231impl DerefMut for PimdirSourceStore {
1232    fn deref_mut(&mut self) -> &mut Self::Target {
1233        &mut self.store
1234    }
1235}
1236
1237/// One raw pending queue row, as the drain loads it: the payload
1238/// undecoded, so a malformed one parks instead of failing the pass.
1239struct QueueRow {
1240    id: i64,
1241    action: String,
1242    payload: String,
1243    object_hash: Option<String>,
1244}
1245
1246/// Loads a collection's pending actions in append order, decoding each payload
1247/// strictly. Shared by [`PimdirStore::pending_actions`] and
1248/// [`PimdirProducer::pending_actions`].
1249fn load_pending_actions(
1250    conn: &Connection,
1251    collection: &str,
1252) -> Result<Vec<PimdirPendingAction>, PimdirError> {
1253    let pending = rows(
1254        conn,
1255        sql::LOAD_PENDING_ACTIONS,
1256        named_params! { ":collection": collection },
1257        |r| {
1258            Ok((
1259                r.get::<_, i64>(0)?,
1260                r.get::<_, String>(1)?,
1261                r.get::<_, String>(2)?,
1262                r.get::<_, String>(3)?,
1263                r.get::<_, String>(4)?,
1264                r.get::<_, i64>(6)?,
1265            ))
1266        },
1267    )?;
1268
1269    let mut actions = Vec::new();
1270    for (id, created_at, producer, kind, payload, attempts) in pending {
1271        actions.push(PimdirPendingAction {
1272            id,
1273            created_at,
1274            producer,
1275            action: codec::action_from_payload(&kind, &payload)?,
1276            attempts,
1277        });
1278    }
1279    Ok(actions)
1280}
1281
1282/// Why a queued action was not staged.
1283///
1284/// The distinction is the whole difference between a row that is broken
1285/// and one that is simply not this owner's to apply, and it is the spec's
1286/// (§15.3): an action the owner cannot apply **at all** parks, one it
1287/// cannot apply **here** is left pending for whoever can.
1288enum PimdirRefusal {
1289    /// It will never stage: a payload that does not decode, an item that
1290    /// is gone, an identity already taken. The row parks with this
1291    /// reason, and no later drain retries it.
1292    Park(String),
1293    /// It cannot stage against this source, which holds no binding for
1294    /// the item it names. The row stays pending, unmarked, so the source
1295    /// that does hold one still applies it.
1296    Skip,
1297}
1298
1299/// Stages the io-replica write ops one queued action folds into the store
1300/// (spec §15.3), inside the drain transaction. The inner `Err` is why it
1301/// was refused; an empty op list is a no-op success, a `remove` of an
1302/// already-absent item.
1303///
1304/// Existing items are addressed by `seq`, resolved to their link id and
1305/// then to this source's projected placement, and the matching
1306/// [`ReplicaMutation`] is pumped through the real [`ReplicaMutate`]
1307/// coroutine, so the staging semantics stay the engine's. An `add` is
1308/// staged directly as the `Created` placement the engine's `Add` stages,
1309/// minus the body bytes: the producer wrote the blob at enqueue.
1310fn stage_action(
1311    tx: &Connection,
1312    source: &ReplicaSourceId,
1313    collection: &str,
1314    row_id: i64,
1315    action: &PimdirAction,
1316) -> Result<Result<Vec<ReplicaWriteOp>, PimdirRefusal>, PimdirError> {
1317    let collection_id = ReplicaCollectionId(collection.to_string());
1318
1319    if let PimdirAction::Add {
1320        link_id,
1321        flags,
1322        object,
1323        meta,
1324        handle,
1325    } = action
1326    {
1327        let link = link_id
1328            .clone()
1329            .or_else(|| object.as_ref().map(|hash| ReplicaLinkId(hash.0.clone())));
1330        let Some(link) = link else {
1331            return Ok(Err(PimdirRefusal::Park(
1332                "add carries neither link_id nor object".to_string(),
1333            )));
1334        };
1335        // NOTE: the same collision rule as the engine's Add mutation: a
1336        // live item blocks the create, a tombstone does not, the delete
1337        // being in flight. Asked of the one row that could collide, since
1338        // this runs once per drained action and loading the collection
1339        // would make a drain of N actions cost N passes over the mailbox.
1340        let live = tx
1341            .query_row(
1342                sql::LIVE_ITEM_FOR_LINK,
1343                named_params! { ":collection": collection, ":link_id": link.0 },
1344                |r| r.get::<_, i64>(0),
1345            )
1346            .optional()?;
1347        if live.is_some() {
1348            return Ok(Err(PimdirRefusal::Park(format!(
1349                "link id already present: {}",
1350                link.0
1351            ))));
1352        }
1353        let level = match (object, meta) {
1354            (Some(_), _) => ReplicaLevel::Full,
1355            (None, Some(_)) => ReplicaLevel::Meta,
1356            (None, None) => ReplicaLevel::Probed,
1357        };
1358        let create = ReplicaPlacement {
1359            collection: collection_id,
1360            handle: handle
1361                .clone()
1362                .unwrap_or_else(|| ReplicaHandle(format!("queue-{row_id}"))),
1363            link_id: Some(link),
1364            object: object.clone(),
1365            level,
1366            meta: meta.clone(),
1367            // NOTE: a queue producer is not a connector, so it derives no
1368            // sort key; the sync pushing this create resolves one.
1369            sort_key: ReplicaSortKey::default(),
1370            flags: flags.clone(),
1371            status: ReplicaStatus::Created,
1372            conflict_revision: None,
1373            conflict_object: None,
1374            base: None,
1375            origin: None,
1376        };
1377        return Ok(Ok(vec![ReplicaWriteOp::UpsertPlacement(create)]));
1378    }
1379
1380    let (seq, removes) = match action {
1381        PimdirAction::SetFlags { seq, .. }
1382        | PimdirAction::Move { seq, .. }
1383        | PimdirAction::Copy { seq, .. }
1384        | PimdirAction::Update { seq, .. } => (*seq, false),
1385        PimdirAction::Remove { seq } => (*seq, true),
1386        PimdirAction::Add { .. } => unreachable!("add staged above"),
1387        PimdirAction::Unknown { .. } => unreachable!("unknown kinds are skipped, never staged"),
1388    };
1389    let item = tx
1390        .query_row(
1391            sql::GET_ITEM,
1392            named_params! { ":collection": collection, ":seq": seq },
1393            read_item_from_row,
1394        )
1395        .optional()?;
1396    let Some(item) = item else {
1397        // NOTE: a remove of an already-absent item is success, not an
1398        // error (spec §15.3); anything else addressing a gone item parks.
1399        return if removes {
1400            Ok(Ok(Vec::new()))
1401        } else {
1402            Ok(Err(PimdirRefusal::Park(format!("unknown seq: {seq}"))))
1403        };
1404    };
1405
1406    // NOTE: the binding's own primary key answers this, so it is a seek.
1407    let handle = tx
1408        .query_row(
1409            sql::HANDLE_FOR_LINK,
1410            named_params! {
1411                ":collection": collection,
1412                ":link_id": item.link_id.0,
1413                ":source": source.0,
1414            },
1415            |r| r.get::<_, String>(0),
1416        )
1417        .optional()?;
1418    let handle = match handle {
1419        Some(handle) => ReplicaHandle(handle),
1420        None if removes => return Ok(Ok(Vec::new())),
1421        None => return Ok(Err(PimdirRefusal::Skip)),
1422    };
1423
1424    let mutation = match action {
1425        PimdirAction::SetFlags { flags, .. } => ReplicaMutation::SetFlags {
1426            handle,
1427            flags: flags.clone(),
1428        },
1429        PimdirAction::Remove { .. } => ReplicaMutation::Remove(handle),
1430        PimdirAction::Move { to, .. } => ReplicaMutation::Move {
1431            handle,
1432            target: to.clone(),
1433            placeholder: ReplicaHandle(format!("queue-{row_id}")),
1434        },
1435        PimdirAction::Copy { to, .. } => ReplicaMutation::Copy {
1436            handle,
1437            target: to.clone(),
1438            placeholder: ReplicaHandle(format!("queue-{row_id}")),
1439        },
1440        PimdirAction::Update { object, meta, .. } => ReplicaMutation::Edit {
1441            handle,
1442            // NOTE: the size only rides the StoreObject op, stripped
1443            // below; the object row was indexed with its real size at
1444            // enqueue.
1445            object: ReplicaObject {
1446                hash: object.clone(),
1447                size: 0,
1448            },
1449            body: Vec::new(),
1450            meta: meta.clone(),
1451            // NOTE: as above, a queued update carries no key.
1452            sort_key: None,
1453        },
1454        PimdirAction::Add { .. } => unreachable!("add staged above"),
1455        PimdirAction::Unknown { .. } => unreachable!("unknown kinds are skipped, never staged"),
1456    };
1457
1458    // NOTE: the mutation reads one placement, so the hub is read for the
1459    // one identity it names rather than for the collection.
1460    let mut mutate = ReplicaMutate::new(collection_id.clone(), mutation);
1461    let _ = mutate.resume(None);
1462    let placements = load_hub_by_link(tx, collection, core::slice::from_ref(&item.link_id.0))?
1463        .project(&collection_id, source);
1464    let loaded = ReplicaLoaded {
1465        placements,
1466        checkpoint: None,
1467    };
1468    match mutate.resume(Some(ReplicaArg::Load(loaded))) {
1469        ReplicaCoroutineState::Yielded(ReplicaYield::WantsWrite(ops)) => {
1470            // NOTE: the body already sits in the blob store and its
1471            // object row was upserted and pinned at enqueue, so
1472            // re-storing would clobber the recorded size.
1473            let ops = ops
1474                .into_iter()
1475                .filter(|op| !matches!(op, ReplicaWriteOp::StoreObject { .. }))
1476                .collect();
1477            Ok(Ok(ops))
1478        }
1479        ReplicaCoroutineState::Complete(Err(err)) => Ok(Err(PimdirRefusal::Park(err.to_string()))),
1480        state => Ok(Err(PimdirRefusal::Park(format!(
1481            "unexpected mutate state: {state:?}"
1482        )))),
1483    }
1484}
1485
1486/// A pimdir store opened as a producer (spec §8): a process that is not
1487/// the owner but legitimately originates mutations (a submission daemon,
1488/// a server frontend). Its only write is the single enqueue transaction
1489/// of spec §15.1: `ensure_collection`, at most one object upsert pinning
1490/// a body it already wrote durably through [`PimdirBlobs::writer`], and
1491/// one queue insert. It never touches items, bindings or sources, and
1492/// never creates the schema: it requires a store the owner has already
1493/// opened at the current version.
1494///
1495/// This coexists with the store's single-writer serialisation: the guard
1496/// is the per-transaction `BEGIN IMMEDIATE` plus the busy timeout, and
1497/// the spec sanctions the producer's short append transaction beside the
1498/// owner's batches, the two serialising on the write lock.
1499pub struct PimdirProducer {
1500    conn: Connection,
1501    /// The store's shared staging lock (spec §8), held for this handle's
1502    /// lifetime so a body written before an enqueue and the row pinning
1503    /// it are one window a collector cannot run inside.
1504    _lock: PimdirLock,
1505    producer: String,
1506    /// The hash the store names its objects by (spec §5), so a producer
1507    /// staging a body names it the way the owner will look it up.
1508    hash: PimdirHashAlgo,
1509    /// The account collections this producer creates are grouped under
1510    /// (spec §9.2); `None` in a single-account store.
1511    account: Option<String>,
1512}
1513
1514impl PimdirProducer {
1515    /// Opens the store rooted at `dir` as producer `producer` (a diagnostic
1516    /// process name recorded on each row).
1517    ///
1518    /// The database must exist at the current schema version: a producer
1519    /// never creates a store, so a missing database errors and a version
1520    /// mismatch is [`PimdirError::Version`].
1521    ///
1522    /// A producer is not an owner: it takes the store's shared lock (spec
1523    /// §8), so several run at once and none keeps the owner out. What the
1524    /// lock buys is the window a collector must not run inside, between
1525    /// the blob write and the queue row that pins it, so a producer
1526    /// handle is opened for the staging it is about to do and dropped
1527    /// when that is done.
1528    pub fn open(dir: impl AsRef<Path>, producer: impl Into<String>) -> Result<Self, PimdirError> {
1529        let dir = dir.as_ref();
1530        let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
1531            | OpenFlags::SQLITE_OPEN_URI
1532            | OpenFlags::SQLITE_OPEN_NO_MUTEX;
1533        let conn = Connection::open_with_flags(dir.join("pimdir.db"), flags)?;
1534        conn.execute_batch(
1535            "PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 30000;",
1536        )?;
1537
1538        let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
1539        match version {
1540            version if version == sql::VERSION => {}
1541            // NOTE: an unstamped database is one no owner has opened yet, not
1542            // a version this crate cannot read, and the two want different
1543            // answers from whoever is holding this handle.
1544            0 => return Err(PimdirError::Uncreated),
1545            found => return Err(PimdirError::Version { found }),
1546        }
1547        check_version_agreement(&conn, version)?;
1548        check_rename_cascades(&conn)?;
1549        let hash = read_hash_algo(&conn, None)?;
1550
1551        Ok(Self {
1552            conn,
1553            _lock: PimdirLock::stage(dir)?,
1554            producer: producer.into(),
1555            hash,
1556            account: None,
1557        })
1558    }
1559
1560    /// The hash this store names its objects by (spec §5).
1561    pub fn hash_algo(&self) -> PimdirHashAlgo {
1562        self.hash
1563    }
1564
1565    /// The content hash of a whole body, under this store's algorithm:
1566    /// what a producer names the blob it writes before enqueueing the
1567    /// action referencing it (spec §15.1).
1568    pub fn hash(&self, bytes: &[u8]) -> ReplicaHash {
1569        self.hash.hash(bytes)
1570    }
1571
1572    /// An incremental hasher for a body streamed into the blob store.
1573    pub fn hasher(&self) -> PimdirHasher {
1574        self.hash.hasher()
1575    }
1576
1577    /// Binds this producer to an account, so a collection its enqueue
1578    /// creates is grouped under it (spec §9.2). Mirrors
1579    /// [`PimdirStore::for_account`].
1580    pub fn for_account(mut self, account: impl Into<String>) -> Self {
1581        self.account = Some(account.into());
1582        self
1583    }
1584
1585    /// Appends one action to a collection's queue (spec §15.1), returning the
1586    /// row's append id.
1587    ///
1588    /// Runs exactly the producer transaction, `BEGIN IMMEDIATE` and
1589    /// short: `ensure_collection`, at most one object upsert when the
1590    /// payload references a body, and one queue insert pinning that
1591    /// body's hash against garbage collection. When the action carries an
1592    /// object the caller has already written its blob durably through
1593    /// [`PimdirBlobs::writer`] and passes the byte size the commit
1594    /// returned; `None` reuses an object the store already indexes.
1595    /// `created_at` is the caller's RFC 3339 timestamp. When the owner
1596    /// applies the action is the owner's business.
1597    pub fn enqueue(
1598        &mut self,
1599        collection: &str,
1600        action: &PimdirAction,
1601        object_size: Option<u64>,
1602        created_at: &str,
1603    ) -> Result<i64, PimdirError> {
1604        let hash = action.object_hash().cloned();
1605
1606        let tx = self
1607            .conn
1608            .transaction_with_behavior(TransactionBehavior::Immediate)
1609            .map_err(busy_or_sql)?;
1610        tx.execute(
1611            sql::ENSURE_COLLECTION,
1612            named_params! { ":collection": collection, ":account": self.account.as_deref() },
1613        )?;
1614        if let (Some(hash), Some(size)) = (&hash, object_size) {
1615            tx.execute(
1616                sql::STORE_OBJECT,
1617                named_params! { ":hash": hash.0, ":size": size as i64 },
1618            )?;
1619        }
1620        tx.execute(
1621            sql::ENQUEUE_ACTION,
1622            named_params! {
1623                ":created_at": created_at,
1624                ":producer": self.producer,
1625                ":collection": collection,
1626                ":action": action.kind(),
1627                ":payload": codec::action_to_payload(action),
1628                ":object_hash": hash.as_ref().map(|h| h.0.as_str()),
1629            },
1630        )?;
1631        // NOTE: the pin (+1): the queue row now references the body, so
1632        // garbage collection never sweeps it between enqueue and apply,
1633        // and the drain releases it as the row is deleted.
1634        if let Some(hash) = &hash {
1635            tx.execute(
1636                sql::ADJUST_REFCOUNT,
1637                named_params! { ":delta": 1, ":hash": hash.0 },
1638            )?;
1639        }
1640        let id = tx.last_insert_rowid();
1641        tx.commit().map_err(busy_or_sql)?;
1642        Ok(id)
1643    }
1644
1645    /// The collection's pending (non-parked) actions in append order, the
1646    /// producer's read-your-writes overlay (spec §15.4): a just-enqueued
1647    /// action shows here before the owner has applied it.
1648    pub fn pending_actions(
1649        &self,
1650        collection: &str,
1651    ) -> Result<Vec<PimdirPendingAction>, PimdirError> {
1652        load_pending_actions(&self.conn, collection)
1653    }
1654}
1655
1656/// A read-only handle to a pimdir store's content-addressed blob directory,
1657/// independent of the SQLite [`Connection`].
1658///
1659/// A body can be read through it while the [`PimdirStore`] is mutably
1660/// borrowed to service a sync, a remote reading a stored body back to
1661/// re-upload it. Cheap to clone: it wraps only the `objects/` path.
1662#[derive(Clone, Debug)]
1663pub struct PimdirBlobs {
1664    root: PathBuf,
1665    hash: PimdirHashAlgo,
1666}
1667
1668impl PimdirBlobs {
1669    /// Opens the blob handle for the store rooted at `dir`, naming bodies with
1670    /// `hash`.
1671    ///
1672    /// The algorithm is the store's, not a choice made here: it is what
1673    /// the files are named by. [`PimdirReader::blobs`] hands one out
1674    /// already bound to the store it came from.
1675    pub fn open(dir: impl AsRef<Path>, hash: PimdirHashAlgo) -> Self {
1676        Self {
1677            root: dir.as_ref().join("objects"),
1678            hash,
1679        }
1680    }
1681
1682    /// The hash bodies here are named by.
1683    pub fn hash_algo(&self) -> PimdirHashAlgo {
1684        self.hash
1685    }
1686
1687    /// The content hash of a whole body, under this store's algorithm.
1688    pub fn hash(&self, bytes: &[u8]) -> ReplicaHash {
1689        self.hash.hash(bytes)
1690    }
1691
1692    /// An incremental hasher, for a body streamed through
1693    /// [`writer`](Self::writer) rather than held whole in memory.
1694    pub fn hasher(&self) -> PimdirHasher {
1695        self.hash.hasher()
1696    }
1697
1698    /// Where a body under `hash` lives: `objects/<name[0:2]>/<name[2:4]>/<name>`
1699    /// (spec §5).
1700    ///
1701    /// Public because the format invites a consumer to stream a body
1702    /// straight to this path and index it with a byteless `StoreObject`
1703    /// afterwards (spec §14), and deriving the sharding itself would be a
1704    /// second implementation of a rule whose point is that one store's
1705    /// writers agree on it.
1706    pub fn path(&self, hash: &ReplicaHash) -> PathBuf {
1707        blob_path(&self.root, &hash.0)
1708    }
1709
1710    /// Reads the body stored under `hash` from the sharded layout, or `None`
1711    /// when absent.
1712    pub fn get(&self, hash: &ReplicaHash) -> io::Result<Option<Vec<u8>>> {
1713        match fs::read(blob_path(&self.root, &hash.0)) {
1714            Ok(bytes) => Ok(Some(bytes)),
1715            Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
1716            Err(err) => Err(err),
1717        }
1718    }
1719
1720    /// Opens a stored object as a readable stream, or `None` when absent:
1721    /// the append side of bounded-memory transfer, so a body is uploaded
1722    /// without being read whole into memory. The file's metadata gives
1723    /// the octet length IMAP `APPEND` needs up front.
1724    pub fn reader(&self, hash: &ReplicaHash) -> io::Result<Option<fs::File>> {
1725        match fs::File::open(blob_path(&self.root, &hash.0)) {
1726            Ok(file) => Ok(Some(file)),
1727            Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
1728            Err(err) => Err(err),
1729        }
1730    }
1731
1732    /// Opens a streaming writer for a new object: bytes go to a temporary
1733    /// file and reach their content-addressed path only on
1734    /// [`commit`](PimdirBlobWriter::commit), once the hash is known. The
1735    /// caller hashes the bytes as it writes them.
1736    pub fn writer(&self) -> io::Result<PimdirBlobWriter> {
1737        fs::create_dir_all(&self.root)?;
1738        let seq = TMP_SEQ.fetch_add(1, Ordering::Relaxed);
1739        let tmp = self.root.join(format!(".tmp-{}-{seq}", std::process::id()));
1740        let file = fs::File::create(&tmp)?;
1741        Ok(PimdirBlobWriter {
1742            root: self.root.clone(),
1743            tmp,
1744            file: Some(file),
1745            written: 0,
1746        })
1747    }
1748
1749    /// Every body the blob tree holds, walking the two-level sharding.
1750    ///
1751    /// The files, not the index: what a collector and a consistency check
1752    /// compare the object rows against, the difference either way being a
1753    /// defect. A half-written body is skipped, a temp file belonging to a
1754    /// writer that has not committed.
1755    pub fn files(&self) -> io::Result<Vec<PimdirBlobFile>> {
1756        let mut files = Vec::new();
1757        if self.root.is_dir() {
1758            walk_blobs(&self.root, &mut files)?;
1759        }
1760        Ok(files)
1761    }
1762}
1763
1764/// One body as it sits in the blob tree.
1765#[derive(Clone, Debug, Eq, PartialEq)]
1766pub struct PimdirBlobFile {
1767    /// The hash its filename claims, unverified: checking it against the
1768    /// bytes is what `pimdir check` is for.
1769    pub hash: String,
1770    /// Where it sits.
1771    pub path: PathBuf,
1772    /// Its size on disk.
1773    pub size: u64,
1774}
1775
1776/// Recurses one directory of the blob tree.
1777fn walk_blobs(dir: &Path, files: &mut Vec<PimdirBlobFile>) -> io::Result<()> {
1778    for entry in fs::read_dir(dir)? {
1779        let entry = entry?;
1780        let name = entry.file_name().to_string_lossy().to_string();
1781        if name.starts_with('.') {
1782            continue;
1783        }
1784
1785        let metadata = entry.metadata()?;
1786        if metadata.is_dir() {
1787            walk_blobs(&entry.path(), files)?;
1788        } else if metadata.is_file() {
1789            files.push(PimdirBlobFile {
1790                hash: name,
1791                path: entry.path(),
1792                size: metadata.len(),
1793            });
1794        }
1795    }
1796
1797    Ok(())
1798}
1799
1800/// A unique-per-write temp-file discriminator, so concurrent writers of
1801/// one store do not collide on the staging file.
1802static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
1803
1804/// A streaming writer for one new blob (see [`PimdirBlobs::writer`]).
1805///
1806/// A [`Write`] sink over a temporary file; [`commit`](Self::commit)
1807/// fsyncs and renames it into the content-addressed path once the caller
1808/// knows the hash. Dropped without a commit, it removes the temp.
1809pub struct PimdirBlobWriter {
1810    root: PathBuf,
1811    tmp: PathBuf,
1812    file: Option<fs::File>,
1813    written: u64,
1814}
1815
1816impl PimdirBlobWriter {
1817    /// Finalises the object under `hash`: fsync, then atomically rename
1818    /// the temp file into its sharded content-addressed path. A body
1819    /// already present keeps the stored copy and drops the temp. Returns
1820    /// the object's byte size.
1821    pub fn commit(mut self, hash: &ReplicaHash) -> io::Result<u64> {
1822        let file = self.file.take().expect("writer open");
1823        file.sync_all()?;
1824        drop(file);
1825
1826        let path = blob_path(&self.root, &hash.0);
1827        if path.exists() {
1828            let _ = fs::remove_file(&self.tmp);
1829            return Ok(self.written);
1830        }
1831        if let Some(parent) = path.parent() {
1832            fs::create_dir_all(parent)?;
1833        }
1834        fs::rename(&self.tmp, &path)?;
1835        if let Some(parent) = path.parent() {
1836            sync_dir(parent)?;
1837        }
1838        Ok(self.written)
1839    }
1840}
1841
1842impl Write for PimdirBlobWriter {
1843    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1844        let file = self.file.as_mut().expect("writer open");
1845        let n = file.write(buf)?;
1846        self.written += n as u64;
1847        Ok(n)
1848    }
1849
1850    fn flush(&mut self) -> io::Result<()> {
1851        self.file.as_mut().expect("writer open").flush()
1852    }
1853}
1854
1855impl Drop for PimdirBlobWriter {
1856    fn drop(&mut self) {
1857        if self.file.is_some() {
1858            let _ = fs::remove_file(&self.tmp);
1859        }
1860    }
1861}
1862
1863/// Applies a write batch's ops inside the caller's transaction: blob and
1864/// object writes, checkpoint upserts, and placement ops folded per
1865/// collection through the hub. The fold absorbs, then persists only what
1866/// changed, diffing the loaded hub against the absorbed one and adjusting
1867/// object refcounts by the per-hash change alone, never a
1868/// whole-collection rewrite or a global recompute.
1869///
1870/// Shared by the seam's [`write`](ReplicaStorage::write), the rekey write
1871/// ([`PimdirStore::write_rekeyed`]) and the queue drain
1872/// ([`PimdirStore::drain_collection`]), each wrapping the same folding in
1873/// its own transaction shape.
1874fn apply_ops(
1875    tx: &Connection,
1876    blobs: &Path,
1877    source: &ReplicaSourceId,
1878    account: Option<&str>,
1879    residual: &mut HashMap<(ReplicaCollectionId, ReplicaHandle), ReplicaPlacement>,
1880    ops: Vec<ReplicaWriteOp>,
1881) -> Result<(), PimdirError> {
1882    let mut hub_ops: BTreeMap<String, Vec<ReplicaWriteOp>> = BTreeMap::new();
1883    // NOTE: the handles this batch replaces rather than removes.
1884    let mut superseded: BTreeMap<String, BTreeSet<ReplicaHandle>> = BTreeMap::new();
1885
1886    for op in ops {
1887        match op {
1888            ReplicaWriteOp::StoreObject { object, body } => {
1889                // NOTE: a byteless op indexes an object the consumer
1890                // already streamed into the blob store. Inline bytes are
1891                // normally staged by `stage_blobs` before this
1892                // transaction opened, and re-offered here as the floor
1893                // for a caller that could not stage ahead; the write is
1894                // idempotent, so that costs one `exists` check.
1895                if let Some(body) = body {
1896                    write_blob(blobs, &object.hash.0, &body)?;
1897                }
1898                tx.execute(
1899                    sql::STORE_OBJECT,
1900                    named_params! { ":hash": object.hash.0, ":size": object.size as i64 },
1901                )?;
1902            }
1903            ReplicaWriteOp::SetCheckpoint {
1904                collection,
1905                checkpoint,
1906            } => {
1907                tx.execute(
1908                    sql::ENSURE_COLLECTION,
1909                    named_params! { ":collection": collection.0, ":account": account },
1910                )?;
1911                tx.execute(
1912                    sql::UPSERT_CHECKPOINT,
1913                    named_params! {
1914                        ":collection": collection.0,
1915                        ":source": source.0,
1916                        ":checkpoint": checkpoint.0,
1917                    },
1918                )?;
1919            }
1920            ReplicaWriteOp::UpsertPlacement(placement) => {
1921                if placement.link_id.is_some() {
1922                    drop_residual(residual, &placement.collection, &placement.handle);
1923                    hub_ops
1924                        .entry(placement.collection.0.clone())
1925                        .or_default()
1926                        .push(ReplicaWriteOp::UpsertPlacement(placement));
1927                } else {
1928                    // NOTE: not yet linked, so it stages in the residual
1929                    // until a Meta upgrade resolves its link id.
1930                    let key = (placement.collection.clone(), placement.handle.clone());
1931                    residual.insert(key, placement);
1932                }
1933            }
1934            ReplicaWriteOp::DropPlacement {
1935                collection,
1936                handle,
1937                reason,
1938            } => {
1939                drop_residual(residual, &collection, &handle);
1940                // NOTE: a superseded handle is one the batch is
1941                // replacing, so its binding may legitimately be repointed
1942                // at whatever the same batch upserts. Recorded here
1943                // because the hub diff cannot tell that from a source
1944                // reporting one identity under a second handle, and
1945                // refuses the second (§10, §12).
1946                if reason == ReplicaDropReason::Superseded {
1947                    superseded
1948                        .entry(collection.0.clone())
1949                        .or_default()
1950                        .insert(handle.clone());
1951                }
1952                hub_ops.entry(collection.0.clone()).or_default().push(
1953                    ReplicaWriteOp::DropPlacement {
1954                        collection,
1955                        handle,
1956                        reason,
1957                    },
1958                );
1959            }
1960        }
1961    }
1962
1963    for (collection, ops) in hub_ops {
1964        refuse_colliding_upserts(&collection, source, &ops)?;
1965        let links = batch_links(tx, &collection, source, &ops)?;
1966        let old_hub = load_hub_by_link(tx, &collection, &links)?;
1967        let mut new_hub = old_hub.clone();
1968        new_hub.absorb(source, &ops);
1969        let superseded = superseded.remove(&collection).unwrap_or_default();
1970        save_hub_diff(
1971            tx,
1972            &collection,
1973            source,
1974            account,
1975            &old_hub,
1976            &new_hub,
1977            &superseded,
1978        )?;
1979        adjust_refcounts(tx, &object_refs(&old_hub), &object_refs(&new_hub))?;
1980    }
1981
1982    Ok(())
1983}
1984
1985/// Refuses a batch carrying two placements of one collection under one
1986/// link id and two handles, before any of it is folded.
1987///
1988/// The hub is keyed by link id, so absorbing both would keep whichever
1989/// the batch names last and drop the other with no statement failing.
1990/// The engine mints a key for the second copy it reads from a source
1991/// (spec §9), but a handle-space rebuild re-resolves every identity from
1992/// the new spine and mints none, so a collection that genuinely holds a
1993/// duplicate hands this store two placements resolving to one key. It is
1994/// the collision [`save_bindings_diff`] refuses against a stored binding,
1995/// one write earlier and against the batch itself.
1996fn refuse_colliding_upserts(
1997    collection: &str,
1998    source: &ReplicaSourceId,
1999    ops: &[ReplicaWriteOp],
2000) -> Result<(), PimdirError> {
2001    let mut claimed: BTreeMap<&ReplicaLinkId, &ReplicaHandle> = BTreeMap::new();
2002
2003    for op in ops {
2004        let ReplicaWriteOp::UpsertPlacement(placement) = op else {
2005            continue;
2006        };
2007        let Some(link) = placement.link_id.as_ref() else {
2008            continue;
2009        };
2010        match claimed.insert(link, &placement.handle) {
2011            Some(bound) if *bound != placement.handle => {
2012                return Err(PimdirError::Rebind {
2013                    collection: collection.into(),
2014                    link_id: link.0.clone(),
2015                    source: source.0.clone(),
2016                    bound: bound.0.clone(),
2017                    incoming: placement.handle.0.clone(),
2018                });
2019            }
2020            _ => {}
2021        }
2022    }
2023
2024    Ok(())
2025}
2026
2027/// Creates the schema in a fresh database (spec §6), advancing
2028/// `user_version` and seeding `store_meta.version` in agreement (spec
2029/// §4.2) in one transaction. A store stamped higher than
2030/// [`sql::VERSION`] is refused: the spec is a draft with a single schema
2031/// version, so such a store is recreated, never migrated.
2032fn init_schema(conn: &mut Connection, hash: PimdirHashAlgo) -> Result<(), PimdirError> {
2033    let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
2034    if version > sql::VERSION {
2035        return Err(PimdirError::Version { found: version });
2036    }
2037    if version == sql::VERSION {
2038        check_version_agreement(conn, version)?;
2039        check_rename_cascades(conn)?;
2040        return reconcile_draft_shape(conn);
2041    }
2042
2043    let tx = conn
2044        .transaction_with_behavior(TransactionBehavior::Immediate)
2045        .map_err(busy_or_sql)?;
2046    tx.execute_batch(sql::MIGRATION_0001)?;
2047    // NOTE: the canonical script is pure DDL, so `store_meta`'s one row
2048    // is seeded here. The timestamp is SQLite's own, in the RFC 3339 form
2049    // the column is declared to hold, which keeps the crate free of a
2050    // clock.
2051    tx.execute(
2052        "INSERT OR IGNORE INTO store_meta(id, version, hash_algo, created_at) \
2053         VALUES(1, ?1, ?2, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
2054        params![sql::VERSION, hash.as_str()],
2055    )?;
2056    tx.pragma_update(None, "user_version", sql::VERSION)?;
2057    tx.commit().map_err(busy_or_sql)?;
2058
2059    Ok(())
2060}
2061
2062/// Refuses a store whose foreign keys predate the `ON UPDATE CASCADE`
2063/// every key onto a renamed row now carries (spec §14).
2064///
2065/// The half of the draft allowance (spec §6) that reconciliation cannot
2066/// reach: a column can be added in place, a foreign-key action cannot.
2067/// §6's other branch is to refuse the store and have the operator
2068/// recreate it, which costs a resync of a derived cache.
2069///
2070/// Without the cascade SQLite refuses a rename one dependent row down, so
2071/// such a store can never follow a server-side collection rename;
2072/// catching it on open says so once rather than when a rename fails.
2073fn check_rename_cascades(conn: &Connection) -> Result<(), PimdirError> {
2074    /// The tables whose foreign key onto a renamable parent must cascade,
2075    /// with that parent. `bindings` hangs off `items(collection,
2076    /// link_id)`, which the first cascade updates.
2077    const CASCADING: [(&str, &str); 5] = [
2078        ("collections", "collections"),
2079        ("sources", "collections"),
2080        ("items", "collections"),
2081        ("bindings", "items"),
2082        ("queue", "collections"),
2083    ];
2084
2085    for (table, parent) in CASCADING {
2086        let mut stmt = conn.prepare(&format!(
2087            "SELECT on_update FROM pragma_foreign_key_list('{table}') WHERE \"table\" = '{parent}'"
2088        ))?;
2089        let mut rows = stmt.query([])?;
2090        while let Some(row) = rows.next()? {
2091            let on_update: String = row.get(0)?;
2092            if on_update != "CASCADE" {
2093                return Err(PimdirError::Unreconcilable { table });
2094            }
2095        }
2096    }
2097
2098    Ok(())
2099}
2100
2101/// Adds columns folded into version 1 after a store was already created
2102/// at version 1 (spec §6, the `draft` allowance).
2103///
2104/// While the spec is a draft, version 1 is not frozen: a schema change
2105/// may be folded into `0001_init.sql` rather than added as version 2. The
2106/// cost is that such a store is not detectably out of date, its
2107/// `user_version` already matching, so the missing column would surface
2108/// later as a query error. §6 requires an implementation to reconcile the
2109/// shape on open or refuse the store; this reconciles.
2110///
2111/// `ALTER TABLE … ADD COLUMN` is cheap, and guarding on `PRAGMA
2112/// table_info` makes it a no-op for a current store. Only nullable
2113/// columns or ones carrying a default can be folded in this way. A draft
2114/// may also fold a column back *out*, which the same guard reverses into
2115/// a `DROP COLUMN`, so a store carrying one the format has retired stops
2116/// carrying it.
2117///
2118/// This disappears when the spec leaves `draft`: from the first frozen
2119/// version onwards, a shape change is a numbered migration.
2120fn reconcile_draft_shape(conn: &mut Connection) -> Result<(), PimdirError> {
2121    /// Columns folded into version 1 after it was first published, as
2122    /// `(table, column, declaration)`. Each must be nullable or carry a
2123    /// default, or it could not be added to a populated table.
2124    const FOLDED_IN: [(&str, &str, &str); 9] = [
2125        ("bindings", "conflicted", "INTEGER NOT NULL DEFAULT 0"),
2126        ("bindings", "conflict_revision", "TEXT"),
2127        (
2128            "bindings",
2129            "conflict_object",
2130            "TEXT REFERENCES objects(hash)",
2131        ),
2132        ("bindings", "shared_object", "TEXT"),
2133        ("items", "retained_at", "TEXT"),
2134        ("items", "retained_by", "TEXT"),
2135        ("collections", "account", "TEXT"),
2136        ("items", "sort_key", "TEXT NOT NULL DEFAULT ''"),
2137        ("bindings", "base_present", "INTEGER NOT NULL DEFAULT 0"),
2138    ];
2139
2140    /// Columns a later draft folded back out, as `(table, column)`.
2141    ///
2142    /// `bindings.ambiguous_handles` held the handles a source held one
2143    /// identity under; the second copy is an item of its own now (spec
2144    /// §9), so the column has nothing to hold and the store records no
2145    /// trace of an incoming handle. A store written with it keeps rows
2146    /// stating a rule the crate no longer has.
2147    ///
2148    /// Dropped in place rather than through the table rebuild §6
2149    /// prescribes for a constraint: no index, key, foreign key or check
2150    /// names this column, so `ALTER TABLE` expresses the change whole,
2151    /// and rebuilding would mean a second copy of the canonical
2152    /// `bindings` DDL for the reconciliation to drift from.
2153    const FOLDED_OUT: [(&str, &str); 1] = [("bindings", "ambiguous_handles")];
2154
2155    let mut missing = Vec::new();
2156    for (table, column, decl) in FOLDED_IN {
2157        if !has_column(conn, table, column)? {
2158            missing.push((table, column, decl));
2159        }
2160    }
2161
2162    let mut stale = Vec::new();
2163    for (table, column) in FOLDED_OUT {
2164        if has_column(conn, table, column)? {
2165            stale.push((table, column));
2166        }
2167    }
2168
2169    let mut reshaped = Vec::new();
2170    for (index, columns) in sql::RESHAPED_INDEXES {
2171        if index_columns(conn, index)?.is_some_and(|held| held != *columns) {
2172            reshaped.push(*index);
2173        }
2174    }
2175
2176    let backfill_shared = missing
2177        .iter()
2178        .any(|(table, column, _)| (*table, *column) == ("bindings", "shared_object"));
2179
2180    let tx = conn
2181        .transaction_with_behavior(TransactionBehavior::Immediate)
2182        .map_err(busy_or_sql)?;
2183    for (table, column, decl) in missing {
2184        tx.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column} {decl}"))?;
2185    }
2186    // NOTE: the one folded-in column whose empty value is not what the
2187    // rows already say. Left NULL it reads as "never folded", the sync
2188    // base stands in for it, and a binding with a pending push sits
2189    // behind the shared body by definition, so the first absorb after
2190    // the upgrade files the source's own next edit as a divergence.
2191    if backfill_shared {
2192        tx.execute_batch(sql::BACKFILL_SHARED_OBJECT)?;
2193    }
2194    for (table, column) in stale {
2195        tx.execute_batch(&format!("ALTER TABLE {table} DROP COLUMN {column}"))?;
2196    }
2197    // NOTE: before the batch below, which cannot replace it: an index
2198    // whose columns moved keeps its name, so `CREATE INDEX IF NOT EXISTS`
2199    // sees one already there and leaves the old plan in place.
2200    for index in reshaped {
2201        tx.execute_batch(&format!("DROP INDEX IF EXISTS {index}"))?;
2202    }
2203    // NOTE: unconditionally, unlike the columns: most of these index
2204    // columns that were always there, and what changed is that a
2205    // statement now needs them. A store keeping the old plans would scan
2206    // where the schema says it seeks.
2207    tx.execute_batch(sql::ENSURE_INDEXES)?;
2208    tx.commit().map_err(busy_or_sql)?;
2209    Ok(())
2210}
2211
2212/// The algorithm the store records, checked against the one the caller
2213/// declared.
2214///
2215/// A store names every blob by its hash, so a handle computing a
2216/// different one writes bodies no reader finds and dedups against
2217/// nothing. The failure is silent by nature, which is why it is caught on
2218/// open rather than left to surface as a cache that never hits.
2219fn read_hash_algo(
2220    conn: &Connection,
2221    declared: Option<PimdirHashAlgo>,
2222) -> Result<PimdirHashAlgo, PimdirError> {
2223    let stored: Option<String> = conn
2224        .query_row("SELECT hash_algo FROM store_meta WHERE id = 1", [], |row| {
2225            row.get(0)
2226        })
2227        .optional()?;
2228
2229    let Some(stored) = stored else {
2230        return Ok(declared.unwrap_or_default());
2231    };
2232    let Some(algo) = PimdirHashAlgo::parse(&stored) else {
2233        return Err(PimdirError::HashAlgo {
2234            found: stored,
2235            declared: declared.map(|a| a.as_str()),
2236        });
2237    };
2238    match declared {
2239        Some(declared) if declared != algo => Err(PimdirError::HashAlgo {
2240            found: stored,
2241            declared: Some(declared.as_str()),
2242        }),
2243        _ => Ok(algo),
2244    }
2245}
2246
2247/// The two schema stamps a store carries, which spec §4.2 requires to
2248/// agree: `PRAGMA user_version` and `store_meta.version`. A store where
2249/// they differ is corrupt, so it is refused rather than read at the
2250/// version one of them names.
2251///
2252/// A store whose `store_meta` row is absent is left alone: the row is
2253/// seeded by whoever created the schema, and refusing here would turn a
2254/// missing stamp into an unopenable store the crate could repair.
2255fn check_version_agreement(conn: &Connection, user_version: i64) -> Result<(), PimdirError> {
2256    let stamped: Option<i64> = conn
2257        .query_row("SELECT version FROM store_meta WHERE id = 1", [], |row| {
2258            row.get(0)
2259        })
2260        .optional()?;
2261
2262    match stamped {
2263        Some(store_meta) if store_meta != user_version => Err(PimdirError::VersionMismatch {
2264            user_version,
2265            store_meta,
2266        }),
2267        _ => Ok(()),
2268    }
2269}
2270
2271/// Whether `table` already has `column`, via `PRAGMA table_info`.
2272fn has_column(conn: &Connection, table: &str, column: &str) -> rusqlite::Result<bool> {
2273    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
2274    let mut rows = stmt.query([])?;
2275    while let Some(row) = rows.next()? {
2276        let name: String = row.get(1)?;
2277        if name == column {
2278            return Ok(true);
2279        }
2280    }
2281    Ok(false)
2282}
2283
2284/// The columns `index` holds, in order, or `None` when the store has no
2285/// such index.
2286///
2287/// `PRAGMA index_info` reports the columns and not the partial predicate,
2288/// which is all a reshape check needs: the column order decides whether a
2289/// read seeks or sorts.
2290fn index_columns(conn: &Connection, index: &str) -> rusqlite::Result<Option<Vec<String>>> {
2291    let columns = rows(conn, &format!("PRAGMA index_info({index})"), [], |row| {
2292        row.get::<_, String>(2)
2293    })?;
2294
2295    Ok((!columns.is_empty()).then_some(columns))
2296}
2297
2298/// Removes any residual placement matching `(collection, handle)`.
2299fn drop_residual(
2300    residual: &mut HashMap<(ReplicaCollectionId, ReplicaHandle), ReplicaPlacement>,
2301    collection: &ReplicaCollectionId,
2302    handle: &ReplicaHandle,
2303) {
2304    residual.remove(&(collection.clone(), handle.clone()));
2305}
2306
2307/// Loads a collection's [`ReplicaHub`] (items + per-source bindings + policy).
2308fn load_hub(conn: &Connection, collection: &str) -> rusqlite::Result<ReplicaHub> {
2309    read_hub(conn, collection, None)
2310}
2311
2312/// The link ids one write batch touches: the ones its upserts carry, plus
2313/// the ones its drops resolve to, a drop naming a handle where the shared
2314/// item is keyed by link id.
2315///
2316/// A handle no binding holds resolves to nothing and is left out: there
2317/// is no item to fold the drop into.
2318fn batch_links(
2319    conn: &Connection,
2320    collection: &str,
2321    source: &ReplicaSourceId,
2322    ops: &[ReplicaWriteOp],
2323) -> rusqlite::Result<Vec<String>> {
2324    let mut links: BTreeSet<String> = BTreeSet::new();
2325
2326    for op in ops {
2327        match op {
2328            ReplicaWriteOp::UpsertPlacement(placement) => {
2329                if let Some(link) = &placement.link_id {
2330                    links.insert(link.0.clone());
2331                }
2332            }
2333            ReplicaWriteOp::DropPlacement { handle, .. } => {
2334                let link = conn
2335                    .query_row(
2336                        sql::LINK_FOR_HANDLE,
2337                        named_params! {
2338                            ":collection": collection,
2339                            ":source": source.0,
2340                            ":handle": handle.0,
2341                        },
2342                        |r| r.get::<_, String>(0),
2343                    )
2344                    .optional()?;
2345                links.extend(link);
2346            }
2347            _ => {}
2348        }
2349    }
2350
2351    Ok(links.into_iter().collect())
2352}
2353
2354/// The hub narrowed to `links`, which is what a write folds its batch into.
2355///
2356/// The batch only produces writes for the items it names, so the rest of
2357/// the collection would be read, cloned and diffed to conclude that
2358/// nothing changed: one flag on one message would cost the size of the
2359/// mailbox. Both sides of the diff are narrowed the same way, so every
2360/// comparison and every counted object reference sees what it would have
2361/// seen in full.
2362fn load_hub_by_link(
2363    conn: &Connection,
2364    collection: &str,
2365    links: &[String],
2366) -> rusqlite::Result<ReplicaHub> {
2367    read_hub(conn, collection, Some(links))
2368}
2369
2370/// The shared reader behind [`load_hub`] and [`load_hub_by_link`]: `None` reads
2371/// the whole collection, `Some` only the named link ids.
2372fn read_hub(
2373    conn: &Connection,
2374    collection: &str,
2375    links: Option<&[String]>,
2376) -> rusqlite::Result<ReplicaHub> {
2377    let mut hub = ReplicaHub::default();
2378
2379    if let Some(policy) = conn
2380        .query_row(
2381            sql::LOAD_CONFLICT,
2382            named_params! { ":collection": collection },
2383            |r| r.get::<_, String>(0),
2384        )
2385        .optional()?
2386    {
2387        hub.conflict = conflict_from_str(&policy);
2388    }
2389
2390    // NOTE: the scoped statements name a `:links` the unscoped ones do
2391    // not, and a parameter a statement never declared is an error, so the
2392    // scope is bound only when there is one.
2393    let scope = links.map(|links| serde_json::to_string(links).unwrap_or_else(|_| "[]".into()));
2394    let (items_sql, bindings_sql) = match scope {
2395        Some(_) => (sql::LOAD_ITEMS_BY_LINK, sql::LOAD_BINDINGS_BY_LINK),
2396        None => (sql::LOAD_ITEMS, sql::LOAD_BINDINGS),
2397    };
2398    let mut params: Vec<(&str, &dyn ToSql)> = vec![(":collection", &collection)];
2399    if let Some(scope) = &scope {
2400        params.push((":links", scope));
2401    }
2402
2403    for (link, item) in rows(conn, items_sql, params.as_slice(), item_from_row)? {
2404        hub.items.insert(link, item);
2405    }
2406    for (link, source, binding) in rows(conn, bindings_sql, params.as_slice(), binding_from_row)? {
2407        if let Some(item) = hub.items.get_mut(&link) {
2408            item.sources.insert(source, binding);
2409        }
2410    }
2411
2412    Ok(hub)
2413}
2414
2415/// Persists the change from `old` to `new` for a collection's hub by
2416/// diffing the two in memory and issuing only the item and binding
2417/// writes that differ, never a whole-collection delete-and-reinsert.
2418///
2419/// Paired with a batch-scoped read ([`load_hub_by_link`]) that makes both
2420/// halves of a write proportional to the batch rather than to the
2421/// collection. An item no source holds any more is retained rather than
2422/// deleted, `source` naming the side whose removal retired it.
2423///
2424/// `superseded` carries the handles this batch is replacing, the one
2425/// thing the two hubs cannot say: a rebuilt spine and a duplicated
2426/// identity produce the same diff, and only the drop's reason separates
2427/// them.
2428fn save_hub_diff(
2429    conn: &Connection,
2430    collection: &str,
2431    source: &ReplicaSourceId,
2432    account: Option<&str>,
2433    old: &ReplicaHub,
2434    new: &ReplicaHub,
2435    superseded: &BTreeSet<ReplicaHandle>,
2436) -> Result<(), PimdirError> {
2437    conn.execute(
2438        sql::ENSURE_COLLECTION,
2439        named_params! { ":collection": collection, ":account": account },
2440    )?;
2441    if old.conflict != new.conflict {
2442        conn.execute(
2443            sql::SET_CONFLICT,
2444            named_params! { ":collection": collection, ":conflict": conflict_to_str(new.conflict) },
2445        )?;
2446    }
2447
2448    // NOTE: an item no source holds any more is retained rather than
2449    // deleted, a store losing one only to a purge. The bindings go with
2450    // the sources that held them; the row stays, hidden from `LOAD_ITEMS`
2451    // so no later sync re-derives against it.
2452    for (link, item) in &old.items {
2453        if new.items.contains_key(link) {
2454            continue;
2455        }
2456        conn.execute(
2457            sql::RETAIN_ITEM,
2458            named_params! { ":collection": collection, ":link_id": link.0, ":source": source.0 },
2459        )?;
2460        conn.execute(
2461            sql::DELETE_ITEM_BINDINGS,
2462            named_params! { ":collection": collection, ":link_id": link.0 },
2463        )?;
2464        // NOTE: the caller's refcount diff is about to release this
2465        // item's object references as it leaves the hub, but the row
2466        // survives and still points at them. Pinning them back, as a
2467        // queue row pins a queued body, keeps a retained body out of the
2468        // collector; revive and purge release the pin.
2469        for hash in [item.object.as_ref(), item.conflict_object.as_ref()]
2470            .into_iter()
2471            .flatten()
2472        {
2473            conn.execute(
2474                sql::ADJUST_REFCOUNT,
2475                named_params! { ":delta": 1, ":hash": hash.0 },
2476            )?;
2477        }
2478    }
2479
2480    for (link, item) in &new.items {
2481        match old.items.get(link) {
2482            None => insert_item(conn, collection, link, item)?,
2483            Some(prev) => {
2484                if !item_columns_eq(prev, item) {
2485                    update_item(conn, collection, link, item)?;
2486                }
2487                save_bindings_diff(conn, collection, link, prev, item, superseded)?;
2488            }
2489        }
2490    }
2491
2492    Ok(())
2493}
2494
2495/// Whether two items' persisted columns, everything but their bindings,
2496/// match.
2497///
2498/// Every column `UPDATE_ITEM` writes has to be here: one left out can
2499/// never change again, since the diff reports the row unchanged and no
2500/// statement is issued for it.
2501fn item_columns_eq(a: &ReplicaHubItem, b: &ReplicaHubItem) -> bool {
2502    a.flags == b.flags
2503        && a.object == b.object
2504        && a.meta == b.meta
2505        && a.sort_key == b.sort_key
2506        && a.level == b.level
2507        && a.deleted == b.deleted
2508        && a.conflicted == b.conflicted
2509        && a.conflict_object == b.conflict_object
2510}
2511
2512fn insert_item(
2513    conn: &Connection,
2514    collection: &str,
2515    link: &ReplicaLinkId,
2516    item: &ReplicaHubItem,
2517) -> rusqlite::Result<()> {
2518    // NOTE: a retained row may still hold this primary key, the item
2519    // being back from a source or a client `add`. Reviving it in place
2520    // keeps its `seq`: a message holds one public id for life.
2521    if revive_item(conn, collection, link, item)? {
2522        return Ok(());
2523    }
2524
2525    // NOTE: the public id is a property of the message, so a link id
2526    // already carrying a seq in another collection reuses it and every
2527    // placement shares one id; otherwise a fresh store-global id is
2528    // drawn, and ids are never reused.
2529    let seq: i64 = match conn
2530        .query_row(
2531            sql::SEQ_FOR_LINK_ANY,
2532            named_params! { ":link_id": link.0 },
2533            |row| row.get(0),
2534        )
2535        .optional()?
2536    {
2537        Some(existing) => existing,
2538        None => conn.query_row(sql::BUMP_NEXT_SEQ, [], |row| row.get(0))?,
2539    };
2540    conn.execute(
2541        sql::INSERT_ITEM,
2542        named_params! {
2543            ":collection": collection,
2544            ":link_id": link.0,
2545            ":seq": seq,
2546            ":flags": codec::flags_to_json(&item.flags),
2547            ":object_hash": item.object.as_ref().map(|o| o.0.as_str()),
2548            ":meta": item.meta.as_ref().map(|m| m.0.as_str()),
2549            ":sort_key": item.sort_key.0.as_str(),
2550            ":level": codec::level_to_int(item.level),
2551            ":deleted": item.deleted as i64,
2552            ":conflicted": item.conflicted as i64,
2553            ":conflict_object": item.conflict_object.as_ref().map(|o| o.0.as_str()),
2554        },
2555    )?;
2556    for (source, binding) in &item.sources {
2557        insert_binding(conn, collection, link, source, binding)?;
2558    }
2559    Ok(())
2560}
2561
2562/// Revives the retained row holding `(collection, link)`, if there is
2563/// one: it stops being retained (spec §11), adopts the incoming content
2564/// through the ordinary item update and binds the sources.
2565///
2566/// The retention pin the retire took is released here, and the caller's
2567/// refcount diff takes the live reference in the same transaction, so a
2568/// body kept only by the retained row is never sweepable in between.
2569fn revive_item(
2570    conn: &Connection,
2571    collection: &str,
2572    link: &ReplicaLinkId,
2573    item: &ReplicaHubItem,
2574) -> rusqlite::Result<bool> {
2575    let pinned: Option<(Option<String>, Option<String>)> = conn
2576        .query_row(
2577            sql::RETAINED_ITEM,
2578            named_params! { ":collection": collection, ":link_id": link.0 },
2579            |row| Ok((row.get(1)?, row.get(2)?)),
2580        )
2581        .optional()?;
2582    let Some((object, conflict_object)) = pinned else {
2583        return Ok(false);
2584    };
2585
2586    conn.execute(
2587        sql::REVIVE_ITEM,
2588        named_params! { ":collection": collection, ":link_id": link.0 },
2589    )?;
2590    update_item(conn, collection, link, item)?;
2591    for hash in [object, conflict_object].into_iter().flatten() {
2592        conn.execute(
2593            sql::ADJUST_REFCOUNT,
2594            named_params! { ":delta": -1, ":hash": hash },
2595        )?;
2596    }
2597    for (source, binding) in &item.sources {
2598        insert_binding(conn, collection, link, source, binding)?;
2599    }
2600    Ok(true)
2601}
2602
2603fn update_item(
2604    conn: &Connection,
2605    collection: &str,
2606    link: &ReplicaLinkId,
2607    item: &ReplicaHubItem,
2608) -> rusqlite::Result<()> {
2609    conn.execute(
2610        sql::UPDATE_ITEM,
2611        named_params! {
2612            ":collection": collection,
2613            ":link_id": link.0,
2614            ":flags": codec::flags_to_json(&item.flags),
2615            ":object_hash": item.object.as_ref().map(|o| o.0.as_str()),
2616            ":meta": item.meta.as_ref().map(|m| m.0.as_str()),
2617            ":sort_key": item.sort_key.0.as_str(),
2618            ":level": codec::level_to_int(item.level),
2619            ":deleted": item.deleted as i64,
2620            ":conflicted": item.conflicted as i64,
2621            ":conflict_object": item.conflict_object.as_ref().map(|o| o.0.as_str()),
2622        },
2623    )?;
2624    Ok(())
2625}
2626
2627/// Diffs one item's per-source bindings between `old` and `new`, issuing
2628/// only the binding writes that changed, and refusing the one write no
2629/// diff may express: a binding resolved to another handle (spec §10).
2630fn save_bindings_diff(
2631    conn: &Connection,
2632    collection: &str,
2633    link: &ReplicaLinkId,
2634    old: &ReplicaHubItem,
2635    new: &ReplicaHubItem,
2636    superseded: &BTreeSet<ReplicaHandle>,
2637) -> Result<(), PimdirError> {
2638    for source in old.sources.keys() {
2639        if !new.sources.contains_key(source) {
2640            conn.execute(
2641                sql::DELETE_BINDING,
2642                named_params! { ":collection": collection, ":link_id": link.0, ":source": source.0 },
2643            )?;
2644        }
2645    }
2646    for (source, binding) in &new.sources {
2647        match old.sources.get(source) {
2648            None => insert_binding(conn, collection, link, source, binding)?,
2649            // NOTE: a handle-space rebuild superseded the handle this
2650            // binding holds, so the row is replaced rather than
2651            // repointed, the way spec §10 says a legitimate rebind goes.
2652            // `UPDATE_BINDING` could not do it in any case: it writes
2653            // every column but `handle`, for the reason below.
2654            Some(prev) if binding.handle != prev.handle && superseded.contains(&prev.handle) => {
2655                conn.execute(
2656                    sql::DELETE_BINDING,
2657                    named_params! { ":collection": collection, ":link_id": link.0, ":source": source.0 },
2658                )?;
2659                insert_binding(conn, collection, link, source, binding)?
2660            }
2661            // NOTE: a binding pins one handle, and repointing it would
2662            // destroy the evidence that a source holds an identity twice,
2663            // silently, at the write. The second copy has a key and an
2664            // item of its own now (spec §9), so refusing is a complete
2665            // answer and nothing is recorded in the incoming handle's
2666            // place. The engine mints before it writes, so this catches a
2667            // consumer staging its own writes, and a rebuilt handle space
2668            // handing two placements to one key.
2669            Some(prev) if binding.handle != prev.handle => {
2670                return Err(PimdirError::Rebind {
2671                    collection: collection.into(),
2672                    link_id: link.0.clone(),
2673                    source: source.0.clone(),
2674                    bound: prev.handle.0.clone(),
2675                    incoming: binding.handle.0.clone(),
2676                });
2677            }
2678            Some(prev) if prev != binding => {
2679                update_binding(conn, collection, link, source, binding)?
2680            }
2681            Some(_) => {}
2682        }
2683    }
2684    Ok(())
2685}
2686
2687fn insert_binding(
2688    conn: &Connection,
2689    collection: &str,
2690    link: &ReplicaLinkId,
2691    source: &ReplicaSourceId,
2692    binding: &ReplicaSourceBinding,
2693) -> rusqlite::Result<()> {
2694    conn.execute(
2695        sql::INSERT_BINDING,
2696        named_params! {
2697            ":collection": collection,
2698            ":link_id": link.0,
2699            ":source": source.0,
2700            ":handle": binding.handle.0,
2701            ":base_flags": binding.base.as_ref().map(|b| codec::flags_to_json(&b.flags)),
2702            ":base_object": binding.base.as_ref().and_then(|b| b.object.as_ref()).map(|o| o.0.as_str()),
2703            ":base_revision": binding.base.as_ref().and_then(|b| b.revision.as_deref()),
2704            ":base_present": binding.base.is_some() as i64,
2705            ":conflicted": binding.conflicted as i64,
2706            ":conflict_revision": binding.conflicted.then_some(binding.conflict_revision.as_deref()).flatten(),
2707            ":conflict_object": conflict_object(binding).map(|hash| hash.0.as_str()),
2708            ":shared_object": binding.shared_object.as_ref().map(|hash| hash.0.as_str()),
2709        },
2710    )?;
2711    Ok(())
2712}
2713
2714fn update_binding(
2715    conn: &Connection,
2716    collection: &str,
2717    link: &ReplicaLinkId,
2718    source: &ReplicaSourceId,
2719    binding: &ReplicaSourceBinding,
2720) -> rusqlite::Result<()> {
2721    conn.execute(
2722        sql::UPDATE_BINDING,
2723        named_params! {
2724            ":collection": collection,
2725            ":link_id": link.0,
2726            ":source": source.0,
2727            ":base_flags": binding.base.as_ref().map(|b| codec::flags_to_json(&b.flags)),
2728            ":base_object": binding.base.as_ref().and_then(|b| b.object.as_ref()).map(|o| o.0.as_str()),
2729            ":base_revision": binding.base.as_ref().and_then(|b| b.revision.as_deref()),
2730            ":base_present": binding.base.is_some() as i64,
2731            ":conflicted": binding.conflicted as i64,
2732            ":conflict_revision": binding.conflicted.then_some(binding.conflict_revision.as_deref()).flatten(),
2733            ":conflict_object": conflict_object(binding).map(|hash| hash.0.as_str()),
2734            ":shared_object": binding.shared_object.as_ref().map(|hash| hash.0.as_str()),
2735        },
2736    )?;
2737    Ok(())
2738}
2739
2740/// The diverging remote body a binding is stuck on, as the column holds
2741/// it: the hash while the binding is conflicted, `NULL` otherwise.
2742///
2743/// Gated on the flag exactly as the revision beside it is (spec §13). A
2744/// body outliving the revision it was fetched at describes a version the
2745/// remote no longer holds, and it is also what releases the pin: a
2746/// resolved binding stops referencing the object, so the collector takes
2747/// it like any other unreferenced body.
2748fn conflict_object(binding: &ReplicaSourceBinding) -> Option<&ReplicaHash> {
2749    binding
2750        .conflicted
2751        .then_some(binding.conflict_object.as_ref())
2752        .flatten()
2753}
2754
2755/// The multiset of object references a hub holds, keyed by hash: every
2756/// item's `object` and `conflict_object` plus every binding's
2757/// `base.object` and its own `conflict_object`. Computed in memory, so
2758/// refcount maintenance is a per-hash delta rather than a full-table
2759/// rescan.
2760///
2761/// A binding's `shared_object` is deliberately not among them, where the
2762/// column beside it is. It records which body this source last agreed
2763/// with and is only ever compared for equality, never read as bytes, and
2764/// a content hash compares the same after the body it named has been
2765/// swept. Counting it would pin every body a source ever agreed with for
2766/// as long as the binding lives, and buy nothing.
2767fn object_refs(hub: &ReplicaHub) -> HashMap<String, i64> {
2768    let mut refs: HashMap<String, i64> = HashMap::new();
2769    let mut bump = |hash: &ReplicaHash| *refs.entry(hash.0.clone()).or_insert(0) += 1;
2770    for item in hub.items.values() {
2771        if let Some(object) = &item.object {
2772            bump(object);
2773        }
2774        if let Some(conflict) = &item.conflict_object {
2775            bump(conflict);
2776        }
2777        for binding in item.sources.values() {
2778            if let Some(object) = binding.base.as_ref().and_then(|b| b.object.as_ref()) {
2779                bump(object);
2780            }
2781            // NOTE: the pin that keeps a diverging body readable until
2782            // someone resolves the conflict, which is an interval of
2783            // days. Read off the same gate the column is written
2784            // through, so the two can never disagree about what is
2785            // referenced.
2786            if let Some(conflict) = conflict_object(binding) {
2787                bump(conflict);
2788            }
2789        }
2790    }
2791    refs
2792}
2793
2794/// Applies the change between two reference multisets as per-hash
2795/// refcount deltas (`refcount += new - old`), touching only hashes whose
2796/// count moved. A hash other collections reference keeps their share: the
2797/// delta reflects this collection's change alone.
2798fn adjust_refcounts(
2799    conn: &Connection,
2800    old: &HashMap<String, i64>,
2801    new: &HashMap<String, i64>,
2802) -> rusqlite::Result<()> {
2803    for (hash, new_count) in new {
2804        let delta = new_count - old.get(hash).copied().unwrap_or(0);
2805        if delta != 0 {
2806            conn.execute(
2807                sql::ADJUST_REFCOUNT,
2808                named_params! { ":delta": delta, ":hash": hash },
2809            )?;
2810        }
2811    }
2812    for (hash, old_count) in old {
2813        if !new.contains_key(hash) {
2814            conn.execute(
2815                sql::ADJUST_REFCOUNT,
2816                named_params! { ":delta": -old_count, ":hash": hash },
2817            )?;
2818        }
2819    }
2820    Ok(())
2821}
2822
2823/// Maps a client-read row to a [`PimdirItem`]. Shared by `list_items` and
2824/// `get_item`.
2825fn read_item_from_row(row: &Row) -> rusqlite::Result<PimdirItem> {
2826    let seq: i64 = row.get(0)?;
2827    let link: String = row.get(1)?;
2828    let flags: Option<String> = row.get(2)?;
2829    let object: Option<String> = row.get(3)?;
2830    let meta: Option<String> = row.get(4)?;
2831    let sort_key: String = row.get(5)?;
2832    let level: i64 = row.get(6)?;
2833
2834    // NOTE: the retained page selects these seven columns and three more,
2835    // so one mapper reads both shapes; a live read stops at the level.
2836    let retention = match row.as_ref().column_count() > 7 {
2837        true => Some(PimdirRetention {
2838            at: row.get(7)?,
2839            by: row.get(8)?,
2840            size: row.get::<_, Option<i64>>(9)?.map(|size| size.max(0) as u64),
2841        }),
2842        false => None,
2843    };
2844
2845    Ok(PimdirItem {
2846        seq,
2847        link_id: ReplicaLinkId(link),
2848        flags: codec::flags_from_json(flags.as_deref()),
2849        meta: meta.map(ReplicaMeta),
2850        sort_key,
2851        object: object.map(ReplicaHash),
2852        level: codec::level_from_int(level),
2853        retention,
2854    })
2855}
2856
2857fn item_from_row(row: &Row) -> rusqlite::Result<(ReplicaLinkId, ReplicaHubItem)> {
2858    let link: String = row.get(0)?;
2859    let flags: Option<String> = row.get(1)?;
2860    let object: Option<String> = row.get(2)?;
2861    let meta: Option<String> = row.get(3)?;
2862    let sort_key: String = row.get(4)?;
2863    let level: i64 = row.get(5)?;
2864    let deleted: i64 = row.get(6)?;
2865    let conflicted: i64 = row.get(7)?;
2866    let conflict_object: Option<String> = row.get(8)?;
2867
2868    Ok((
2869        ReplicaLinkId(link),
2870        ReplicaHubItem {
2871            flags: codec::flags_from_json(flags.as_deref()),
2872            object: object.map(ReplicaHash),
2873            meta: meta.map(ReplicaMeta),
2874            sort_key: ReplicaSortKey(sort_key),
2875            level: codec::level_from_int(level),
2876            deleted: deleted != 0,
2877            conflicted: conflicted != 0,
2878            conflict_object: conflict_object.map(ReplicaHash),
2879            sources: BTreeMap::new(),
2880        },
2881    ))
2882}
2883
2884fn binding_from_row(
2885    row: &Row,
2886) -> rusqlite::Result<(ReplicaLinkId, ReplicaSourceId, ReplicaSourceBinding)> {
2887    let link: String = row.get(0)?;
2888    let source: String = row.get(1)?;
2889    let handle: String = row.get(2)?;
2890    let base_flags: Option<String> = row.get(3)?;
2891    let base_object: Option<String> = row.get(4)?;
2892    let base_revision: Option<String> = row.get(5)?;
2893    let base_present: i64 = row.get(6)?;
2894    let conflicted: i64 = row.get(7)?;
2895    let conflict_revision: Option<String> = row.get(8)?;
2896    let conflict_object: Option<String> = row.get(9)?;
2897    let shared_object: Option<String> = row.get(10)?;
2898
2899    // NOTE: either witness. The column is the fact, and a base of no
2900    // revision, no body and markers nobody has read is a real agreement
2901    // its three value columns cannot express: reading presence off them
2902    // alone has such a placement come back as never-agreed, so the sync
2903    // re-derives the same push every run. The value columns stay a
2904    // witness for a row written before the column existed.
2905    let base = if base_present != 0
2906        || base_flags.is_some()
2907        || base_object.is_some()
2908        || base_revision.is_some()
2909    {
2910        Some(ReplicaBase {
2911            flags: codec::flags_from_json(base_flags.as_deref()),
2912            revision: base_revision,
2913            object: base_object.map(ReplicaHash),
2914        })
2915    } else {
2916        None
2917    };
2918
2919    let conflicted = conflicted != 0;
2920    Ok((
2921        ReplicaLinkId(link),
2922        ReplicaSourceId(source),
2923        ReplicaSourceBinding {
2924            handle: ReplicaHandle(handle),
2925            base,
2926            conflicted,
2927            // NOTE: spec §13, the revision and the body beside it are
2928            // meaningful only while conflicted, so a resolved binding
2929            // cannot hand a stale pair to the next sync.
2930            conflict_revision: conflicted.then_some(conflict_revision).flatten(),
2931            conflict_object: conflicted
2932                .then_some(conflict_object)
2933                .flatten()
2934                .map(ReplicaHash),
2935            // NOTE: ungated, where the pair above is gated: the
2936            // agreement point is the ordinary state of an ordinary
2937            // binding, and the edit resolving a conflict needs the one
2938            // the conflict was filed at.
2939            shared_object: shared_object.map(ReplicaHash),
2940        },
2941    ))
2942}
2943
2944fn conflict_from_str(value: &str) -> ReplicaHubConflict {
2945    match value {
2946        "prefer-incoming" => ReplicaHubConflict::PreferIncoming,
2947        "prefer-existing" => ReplicaHubConflict::PreferExisting,
2948        _ => ReplicaHubConflict::Manual,
2949    }
2950}
2951
2952fn conflict_to_str(policy: ReplicaHubConflict) -> &'static str {
2953    match policy {
2954        ReplicaHubConflict::Manual => "manual",
2955        ReplicaHubConflict::PreferIncoming => "prefer-incoming",
2956        ReplicaHubConflict::PreferExisting => "prefer-existing",
2957    }
2958}
2959
2960/// The sharded on-disk path of a blob (`objects/<h[0:2]>/<h[2:4]>/<hash>`),
2961/// falling back to a flat path for hashes shorter than four characters.
2962fn blob_path(blobs: &Path, hash: &str) -> PathBuf {
2963    if hash.len() >= 4 {
2964        blobs.join(&hash[0..2]).join(&hash[2..4]).join(hash)
2965    } else {
2966        blobs.join(hash)
2967    }
2968}
2969
2970/// Writes every body a batch carries to the blob store, ahead of the
2971/// transaction that indexes them (spec §14).
2972///
2973/// A body is content-addressed and immutable, so writing it early can
2974/// only produce a file some later batch produces identically, and the
2975/// worst a crash between the two leaves is an orphan blob. Inside the
2976/// transaction the same write would hold SQLite's single writer lock
2977/// across a file write, two `fsync`s and a rename, serialising every
2978/// other writer behind an I/O path that touches no database page.
2979///
2980/// What keeps a collector out of the window this opens is the writer's
2981/// lock (spec §8), not the file's age: between the write and the commit
2982/// the file is on disk with no row, indistinguishable from an orphan.
2983///
2984/// [`write_blob`] is idempotent, so the batch may re-offer the same body
2985/// without cost, which lets [`apply_ops`] keep its own write as the floor
2986/// for a caller that cannot stage ahead.
2987fn stage_blobs(blobs: &Path, ops: &[ReplicaWriteOp]) -> io::Result<()> {
2988    for op in ops {
2989        if let ReplicaWriteOp::StoreObject {
2990            object,
2991            body: Some(body),
2992        } = op
2993        {
2994            write_blob(blobs, &object.hash.0, body)?;
2995        }
2996    }
2997
2998    Ok(())
2999}
3000
3001/// Writes a blob atomically (temp → `fsync` → rename → `fsync` the shard
3002/// directory, spec §5); a present hash is immutable and left untouched.
3003fn write_blob(blobs: &Path, hash: &str, body: &[u8]) -> io::Result<()> {
3004    let path = blob_path(blobs, hash);
3005    if path.exists() {
3006        return Ok(());
3007    }
3008    let parent = path.parent().unwrap_or(blobs);
3009    fs::create_dir_all(parent)?;
3010    let tmp = parent.join(format!(".{hash}.tmp"));
3011    {
3012        let mut file = fs::File::create(&tmp)?;
3013        file.write_all(body)?;
3014        file.sync_all()?;
3015    }
3016    fs::rename(&tmp, &path)?;
3017    sync_dir(parent)
3018}
3019
3020/// Flushes a directory entry, so a rename into it survives a power loss.
3021///
3022/// Syncing the file makes its bytes durable and says nothing about the
3023/// name that reaches them. The database commit is durable, so without
3024/// this a crash can leave a committed row pointing at a body that never
3025/// arrived: the one asymmetry the write order exists to prevent, the
3026/// reverse leaving at worst an orphan blob.
3027fn sync_dir(dir: &Path) -> io::Result<()> {
3028    fs::File::open(dir)?.sync_all()
3029}
3030
3031/// Everything that can go wrong servicing the seam.
3032#[derive(Debug)]
3033pub enum PimdirError {
3034    /// The SQLite index refused a statement, or the connection itself failed.
3035    Sql(rusqlite::Error),
3036    /// The blob directory refused a read, a write or a rename.
3037    Io(io::Error),
3038    /// JSON encoding failed at the storage seam, the link id array a
3039    /// lookup hands to SQLite; a malformed queue payload reports as
3040    /// `Action`.
3041    Json(serde_json::Error),
3042    /// A queue action payload is malformed or unsupported (spec §15.3).
3043    Action(PimdirActionError),
3044    /// A write resolved an existing `(collection, link_id, source)`
3045    /// binding to a different handle, and was refused (spec §10).
3046    ///
3047    /// A binding pins one handle, so applying it would repoint the
3048    /// binding from the copy it held to another, which is where the
3049    /// evidence of a source holding one identity twice used to die. The
3050    /// second copy is an item of its own under a minted key (spec §9),
3051    /// which is what makes refusing a complete answer: nothing is
3052    /// recorded in the incoming handle's place. The one licensed rebind
3053    /// is the handle-space rebuild (spec §12), whose `Superseded` drop
3054    /// names the handle it replaces.
3055    Rebind {
3056        /// The collection holding the binding.
3057        collection: String,
3058        /// The identity it is keyed by.
3059        link_id: String,
3060        /// The source whose binding it is.
3061        source: String,
3062        /// The handle the binding holds, and keeps.
3063        bound: String,
3064        /// The handle the refused write carried.
3065        incoming: String,
3066    },
3067    /// The store's schema version is not one this crate services: it was
3068    /// written by a newer crate, or by a draft this one no longer reads.
3069    /// Such a store is recreated, never migrated.
3070    Version {
3071        /// The store's `user_version`.
3072        found: i64,
3073    },
3074    /// The store has no schema yet, and this opener does not create one.
3075    /// A producer and a reader both require the owner to have opened it
3076    /// first, which is the write that creates the database.
3077    Uncreated,
3078    /// The store's two schema stamps disagree, which spec §4.2 defines as
3079    /// corruption: `PRAGMA user_version` and `store_meta.version` mirror
3080    /// one another, so a store where they differ was half-written.
3081    VersionMismatch {
3082        /// The store's `PRAGMA user_version`.
3083        user_version: i64,
3084        /// The version its `store_meta` row records.
3085        store_meta: i64,
3086    },
3087    /// The store was created by a draft whose foreign keys lack the
3088    /// `ON UPDATE CASCADE` a rename depends on (spec §14), which no
3089    /// `ALTER TABLE` can add. Spec §6's other branch applies: the
3090    /// operator recreates the store, a resync of a derived cache.
3091    Unreconcilable {
3092        /// The first table found without the cascade.
3093        table: &'static str,
3094    },
3095    /// The store's `store_meta.hash_algo` is not one this crate computes,
3096    /// or not the one the caller declared. Either way the handle would
3097    /// name bodies the store does not use, so it is refused (spec §5).
3098    HashAlgo {
3099        /// The algorithm the store records.
3100        found: String,
3101        /// The algorithm the caller declared, when it declared one.
3102        declared: Option<&'static str>,
3103    },
3104    /// Another writer holds the store's single write lock (§8); the
3105    /// caller retries once that writer is done.
3106    Busy,
3107    /// Another process owns the store (§8), which this one asked to own
3108    /// too. Reported as soon as the lock is refused rather than waited
3109    /// out: a wait long enough to outlast a sync is a stall with no
3110    /// signal, and the caller is the only layer that can choose between
3111    /// retrying, backing off, queueing the intent and telling the user.
3112    Owned(PathBuf),
3113    /// A producer is between its blob write and the enqueue that pins it
3114    /// (§8), so a collector cannot run: the body it just wrote is
3115    /// referenced by nothing yet. Reported rather than waited out, since
3116    /// a producer holds its lock for as long as its handle lives.
3117    Staging(PathBuf),
3118}
3119
3120impl fmt::Display for PimdirError {
3121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3122        match self {
3123            PimdirError::Sql(err) => write!(f, "pimdir SQL error: {err}"),
3124            PimdirError::Io(err) => write!(f, "pimdir I/O error: {err}"),
3125            PimdirError::Json(err) => write!(f, "pimdir JSON error: {err}"),
3126            PimdirError::Action(err) => write!(f, "pimdir action error: {err}"),
3127            PimdirError::Rebind {
3128                collection,
3129                link_id,
3130                source,
3131                bound,
3132                incoming,
3133            } => write!(
3134                f,
3135                "pimdir binding {collection}/{link_id} on source {source} holds handle {bound}, and this write carries {incoming}: a binding pins one handle, and a second copy of an identity is stored under a key of its own"
3136            ),
3137            PimdirError::Version { found } => write!(
3138                f,
3139                "pimdir store schema version {found} is unsupported (this crate services version {})",
3140                sql::VERSION
3141            ),
3142            PimdirError::Uncreated => write!(
3143                f,
3144                "pimdir store has no schema yet: its owner has to create it first"
3145            ),
3146            PimdirError::VersionMismatch {
3147                user_version,
3148                store_meta,
3149            } => write!(
3150                f,
3151                "pimdir store is corrupt: PRAGMA user_version is {user_version} but store_meta records {store_meta}"
3152            ),
3153            PimdirError::Unreconcilable { table } => write!(
3154                f,
3155                "pimdir store predates the ON UPDATE CASCADE on `{table}`, which cannot be added in place: delete the store and let it resync"
3156            ),
3157            PimdirError::HashAlgo {
3158                found,
3159                declared: Some(declared),
3160            } => write!(
3161                f,
3162                "pimdir store names its objects with `{found}`, not the `{declared}` this handle declared"
3163            ),
3164            PimdirError::HashAlgo {
3165                found,
3166                declared: None,
3167            } => write!(
3168                f,
3169                "pimdir store names its objects with `{found}`, which this crate does not compute"
3170            ),
3171            PimdirError::Owned(store) => write!(
3172                f,
3173                "pimdir store at {} is owned by another process",
3174                store.display()
3175            ),
3176            PimdirError::Staging(store) => write!(
3177                f,
3178                "pimdir store at {} has a producer staging a body",
3179                store.display()
3180            ),
3181            PimdirError::Busy => write!(
3182                f,
3183                "pimdir store is busy: another writer holds the write lock; retry once it releases"
3184            ),
3185        }
3186    }
3187}
3188
3189/// Maps a SQLite busy/locked failure to [`PimdirError::Busy`], leaving
3190/// any other error as a plain SQL error.
3191fn busy_or_sql(err: rusqlite::Error) -> PimdirError {
3192    match &err {
3193        rusqlite::Error::SqliteFailure(e, _)
3194            if matches!(e.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked) =>
3195        {
3196            PimdirError::Busy
3197        }
3198        _ => PimdirError::Sql(err),
3199    }
3200}
3201
3202impl std::error::Error for PimdirError {}
3203
3204impl From<rusqlite::Error> for PimdirError {
3205    fn from(err: rusqlite::Error) -> Self {
3206        PimdirError::Sql(err)
3207    }
3208}
3209
3210impl From<io::Error> for PimdirError {
3211    fn from(err: io::Error) -> Self {
3212        PimdirError::Io(err)
3213    }
3214}
3215
3216impl From<serde_json::Error> for PimdirError {
3217    fn from(err: serde_json::Error) -> Self {
3218        PimdirError::Json(err)
3219    }
3220}
3221
3222impl From<PimdirActionError> for PimdirError {
3223    fn from(err: PimdirActionError) -> Self {
3224        PimdirError::Action(err)
3225    }
3226}