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}