From cc06c7366201140c0477d7b6634cbd38e1181458 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Thu, 28 May 2026 01:36:26 +0200 Subject: [PATCH 01/31] fix: dissociate IndexScan lifetime from its generic Index type --- src/scan.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/scan.rs b/src/scan.rs index 1542dfe..8f2f22a 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -42,11 +42,11 @@ pub enum ScanRange { pub struct IndexScan<'a, ReadHandle, Record, Idx> where + Self: 'a, ReadHandle: MultiStoreReadHandle, Record: Entity, Idx: Index, - Idx::Kind<'a>: IndexKind, Record::Key<'a>>, - Self: 'a, + for<'b> Idx::Kind<'b>: IndexKind, Record::Key<'b>>, { collection_name: &'static str, read_handle: ReadHandle, @@ -62,7 +62,7 @@ where ReadHandle: MultiStoreReadHandle, Record: Entity, Idx: Index, - Idx::Kind<'a>: IndexKind, Record::Key<'a>>, + for<'b> Idx::Kind<'b>: IndexKind, Record::Key<'b>>, { pub fn new(collection_name: &'static str, read_handle: ReadHandle) -> Self { Self { @@ -122,9 +122,9 @@ where ReadHandle: MultiStoreReadHandle, Record: Entity, Idx: Index, - Idx::Kind<'a>: IndexKind, Record::Key<'a>>, KeyPrefix: Prefix, StoreKey<'a, Idx, Record::Key<'a>, Record>: Key + Prefixable, + for<'b> Idx::Kind<'b>: IndexKind, Record::Key<'b>>, { fn prefix(mut self, prefix: KeyPrefix) -> Self { self.range = ScanRange::Prefix(prefix.encode_prefix()); From d1c37d8299028d9a5b81d3d2f2e07e2a8bac76a1 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Thu, 28 May 2026 01:37:33 +0200 Subject: [PATCH 02/31] refactor: simplify index remove --- src/index.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/index.rs b/src/index.rs index 517ee64..c89a330 100644 --- a/src/index.rs +++ b/src/index.rs @@ -56,8 +56,7 @@ pub trait Index { target: (&Record::Key<'a>, &'a Record), ) -> Result<(), Error> { let mut store = db.open_store(Self::NAME)?; - let ikey = Self::key(target.1); - let skey = Self::Kind::store_key(ikey, target.0); + let skey = Self::Kind::store_key(Self::key(target.1), target.0); store.remove(skey.encode()).map_err(Error::Backend) } From 91711a99c1a71478fd876e3ed40e6cb202748bca Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Thu, 28 May 2026 01:39:13 +0200 Subject: [PATCH 03/31] refactor: rename fn set into remove for indexes --- src/index.rs | 2 +- src/index_registry.rs | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/index.rs b/src/index.rs index c89a330..5d30e25 100644 --- a/src/index.rs +++ b/src/index.rs @@ -19,7 +19,7 @@ pub trait Index { fn key(entity: &Record) -> Self::Key<'_>; - fn set<'a, DB: MultiStoreWriteHandle>( + fn update<'a, DB: MultiStoreWriteHandle>( db: &mut DB, old: Option<(&Record::Key<'a>, &'a Record)>, new: (&Record::Key<'a>, &'a Record), diff --git a/src/index_registry.rs b/src/index_registry.rs index 4042f82..b3681b2 100644 --- a/src/index_registry.rs +++ b/src/index_registry.rs @@ -12,7 +12,7 @@ pub struct Cons(PhantomData<(Head, Tail)>); /// IndexRegistry is a recursive HList trait to allow defining multiple indexes as generic types. pub trait IndexRegistry { - fn set<'a, DB: MultiStoreWriteHandle>( + fn update<'a, DB: MultiStoreWriteHandle>( db: &mut DB, old: Option<(&T::Key<'a>, &'a T)>, new: (&T::Key<'a>, &'a T), @@ -30,7 +30,7 @@ impl IndexRegistry for Nil where T: Entity, { - fn set<'a, DB: MultiStoreWriteHandle>( + fn update<'a, DB: MultiStoreWriteHandle>( _db: &mut DB, _old: Option<(&T::Key<'a>, &'a T)>, _new: (&T::Key<'a>, &'a T), @@ -57,13 +57,13 @@ where Tail: IndexRegistry, for<'a> Head::Kind<'a>: IndexKind, T::Key<'a>>, { - fn set<'a, DB: MultiStoreWriteHandle>( + fn update<'a, DB: MultiStoreWriteHandle>( db: &mut DB, old: Option<(&T::Key<'a>, &'a T)>, new: (&T::Key<'a>, &'a T), ) -> Result<(), Error> { - Head::set(db, old, new)?; - Tail::set(db, old, new) + Head::update(db, old, new)?; + Tail::update(db, old, new) } fn remove<'a, DB: MultiStoreWriteHandle>( @@ -135,7 +135,7 @@ mod tests { fn key(r: &Record) -> u32 { r.0 } - fn set( + fn update( db: &mut DB, _old: Option<(&u32, &Record)>, _new: (&u32, &Record), @@ -164,7 +164,7 @@ mod tests { fn key(r: &Record) -> u32 { r.0 } - fn set( + fn update( _db: &mut DB, _old: Option<(&u32, &Record)>, _new: (&u32, &Record), @@ -271,19 +271,19 @@ mod tests { // ── set ─────────────────────────────────────────────────────────────────── #[test] - fn set() { + fn update() { let record = Record(1); let pk = 1u32; let cases: &[(&dyn Fn(&mut Spy) -> Result<(), Error>, &[&str], bool)] = &[ ( - &|s| >::set(s, None, (&pk, &record)), + &|s| >::update(s, None, (&pk, &record)), &[], false, ), ( &|s| { - > as IndexRegistry>::set( + > as IndexRegistry>::update( s, None, (&pk, &record), @@ -294,7 +294,7 @@ mod tests { ), ( &|s| { - > as IndexRegistry>::set( + > as IndexRegistry>::update( s, None, (&pk, &record), From 027e043b53a127098a0575186e3bec4a58f95209 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Thu, 28 May 2026 01:40:50 +0200 Subject: [PATCH 04/31] feat: update indexes on insert --- src/collection.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/collection.rs b/src/collection.rs index cfcf5ae..658c156 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -49,6 +49,8 @@ where } store.set(&enc_pk, &value.to_bytes()?)?; + + Indexes::update(&mut tx, None, (&pk, &value))?; } tx.commit().map_err(Error::Backend) From eb9e246b042ae92185f602edfc83889b18ba4b23 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Thu, 28 May 2026 02:04:40 +0200 Subject: [PATCH 05/31] feat: use borrowed values in collection api --- src/collection.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/collection.rs b/src/collection.rs index 658c156..a33c427 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -1,3 +1,4 @@ +use std::borrow::Borrow; use crate::entity::Entity; use crate::error::Error; use crate::index::{Index, IndexKind}; @@ -36,7 +37,8 @@ where } } - pub fn insert(&self, value: Record) -> Result<(), Error> { + pub fn insert(&self, value: impl Borrow) -> Result<(), Error> { + let value = value.borrow(); let pk = value.key(); let enc_pk = pk.encode(); let mut tx = self.db.write(self.name)?; @@ -56,19 +58,25 @@ where tx.commit().map_err(Error::Backend) } - pub fn get(&self, _key: Record::Key<'_>) -> Result, Error> { + pub fn get<'a>(&self, _key: impl Borrow< as Key>::OwnedKey>) -> Result, Error> + where + Record: 'a, + { Ok(None) } - pub fn update(&self, _value: Record) -> Result<(), Error> { + pub fn update(&self, _value: impl Borrow) -> Result<(), Error> { Ok(()) } - pub fn save(&self, _value: Record) -> Result<(), Error> { + pub fn save(&self, _value: impl Borrow) -> Result<(), Error> { Ok(()) } - pub fn remove(&self, _key: Record::Key<'_>) -> Result<(), Error> { + pub fn remove<'a>(&self, _key: impl Borrow< as Key>::OwnedKey>) -> Result<(), Error> + where + Record: 'a, + { Ok(()) } From e96cf99429d5f4e135d011bcdc25654f9088ba79 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Thu, 28 May 2026 02:18:03 +0200 Subject: [PATCH 06/31] feat: impl collection::get --- src/collection.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/collection.rs b/src/collection.rs index a33c427..7552dc2 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -5,7 +5,7 @@ use crate::index::{Index, IndexKind}; use crate::index_registry::{Cons, ContainsIndex, IndexRegistry, Nil}; use crate::key::Key; use crate::scan::IndexScan; -use crate::store::{MultiStore, MultiStoreWriteHandle, ReadKVStore, WriteKVStore}; +use crate::store::{MultiStore, MultiStoreReadHandle, MultiStoreWriteHandle, ReadKVStore, WriteKVStore}; use std::marker::PhantomData; pub struct Collection @@ -58,11 +58,19 @@ where tx.commit().map_err(Error::Backend) } - pub fn get<'a>(&self, _key: impl Borrow< as Key>::OwnedKey>) -> Result, Error> + pub fn get<'a>(&self, key: impl Borrow< as Key>::OwnedKey>) -> Result, Error> where Record: 'a, { - Ok(None) + self.db.read(self.name)? + .open_store(Self::MAIN_STORE)? + .get(key.borrow().encode()) + .map_err(Error::Backend) + .and_then(|res| res.map(|bytes| + Record::from_bytes(&bytes) + .map_err(Error::Codec)) + .transpose() + ) } pub fn update(&self, _value: impl Borrow) -> Result<(), Error> { From 596d81f11919c12aa85d4a8f0c791a311ec15b97 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Thu, 28 May 2026 02:40:58 +0200 Subject: [PATCH 07/31] feat: impl collection::remove() --- src/collection.rs | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/collection.rs b/src/collection.rs index 7552dc2..3094bf5 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -41,19 +41,17 @@ where let value = value.borrow(); let pk = value.key(); let enc_pk = pk.encode(); - let mut tx = self.db.write(self.name)?; - { - let mut store = tx.open_store(Self::MAIN_STORE)?; + let mut tx = self.db.write(self.name)?; + let mut store = tx.open_store(Self::MAIN_STORE)?; - if store.get(&enc_pk)?.is_some() { - Err(Error::AlreadyExists(self.name.to_string()))? - } + if store.get(&enc_pk)?.is_some() { + Err(Error::AlreadyExists(self.name.to_string()))? + } - store.set(&enc_pk, &value.to_bytes()?)?; + store.set(&enc_pk, &value.to_bytes()?)?; - Indexes::update(&mut tx, None, (&pk, &value))?; - } + Indexes::update(&mut tx, None, (&pk, &value))?; tx.commit().map_err(Error::Backend) } @@ -81,11 +79,31 @@ where Ok(()) } - pub fn remove<'a>(&self, _key: impl Borrow< as Key>::OwnedKey>) -> Result<(), Error> + pub fn remove<'a>(&self, key: impl Borrow< as Key>::OwnedKey>) -> Result<(), Error> where Record: 'a, { - Ok(()) + let pk = key.borrow(); + let enc_pk = pk.encode(); + + let mut tx = self.db.write(self.name)?; + let mut store = tx.open_store(Self::MAIN_STORE)?; + + let record = store.get(enc_pk)? + .map(|bytes| + Record::from_bytes(&bytes).map_err(Error::Codec) + ).transpose()?; + + let record = match record { + Some(record) => record, + None => return Ok(()), + }; + + store.remove(key.borrow().encode())?; + + Indexes::remove(&mut tx, (&record.key(), &record))?; + + tx.commit().map_err(Error::Backend) } pub fn index<'a, Idx, P>( From 1ab0d10c0626f2c505d1c48e21acb1582112080a Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Thu, 28 May 2026 02:43:40 +0200 Subject: [PATCH 08/31] refactor: simplify collection::get --- src/collection.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/collection.rs b/src/collection.rs index 3094bf5..953d99b 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -62,13 +62,9 @@ where { self.db.read(self.name)? .open_store(Self::MAIN_STORE)? - .get(key.borrow().encode()) - .map_err(Error::Backend) - .and_then(|res| res.map(|bytes| - Record::from_bytes(&bytes) - .map_err(Error::Codec)) - .transpose() - ) + .get(key.borrow().encode())? + .map(|bytes| Record::from_bytes(&bytes).map_err(Error::Codec)) + .transpose() } pub fn update(&self, _value: impl Borrow) -> Result<(), Error> { From 6599c65d5808832240d5b02f422c72a19a08d2ec Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:48:53 +0200 Subject: [PATCH 09/31] docs: typo in redame --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3c93876..055eae1 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ let downloads = collection::("downloads", DB {}) .with_index::() .with_index::() .with_index::() - build(); + .build(); downloads.save(dl)?; let my_dl = downloads.get(&dl.info_hash)?; From 489f644cdafd7ab6f5b860cc1ce8f35f5de4520e Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:49:47 +0200 Subject: [PATCH 10/31] refactor: add granularity to index lifetimes --- src/collection.rs | 5 +++-- src/index.rs | 47 ++++++++++++++++++++++++------------------- src/index_registry.rs | 40 ++++++++++++++++++++---------------- 3 files changed, 52 insertions(+), 40 deletions(-) diff --git a/src/collection.rs b/src/collection.rs index 953d99b..05a0dab 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -51,7 +51,7 @@ where store.set(&enc_pk, &value.to_bytes()?)?; - Indexes::update(&mut tx, None, (&pk, &value))?; + Indexes::update(&mut tx, &pk, None, &value)?; tx.commit().map_err(Error::Backend) } @@ -97,7 +97,7 @@ where store.remove(key.borrow().encode())?; - Indexes::remove(&mut tx, (&record.key(), &record))?; + Indexes::remove(&mut tx, &record.key(), &record)?; tx.commit().map_err(Error::Backend) } @@ -136,6 +136,7 @@ where pub fn with_index(self) -> CollectionBuilder> where Idx: Index, + for<'ik, 'pk> Idx::Kind<'ik>: IndexKind, Record::Key<'pk>> { assert!( !Indexes::has_index(Idx::NAME), diff --git a/src/index.rs b/src/index.rs index 5d30e25..b49c16e 100644 --- a/src/index.rs +++ b/src/index.rs @@ -19,15 +19,19 @@ pub trait Index { fn key(entity: &Record) -> Self::Key<'_>; - fn update<'a, DB: MultiStoreWriteHandle>( + fn update<'a, 'b, DB: MultiStoreWriteHandle>( db: &mut DB, - old: Option<(&Record::Key<'a>, &'a Record)>, - new: (&Record::Key<'a>, &'a Record), - ) -> Result<(), Error> { - let new_skey = Self::Kind::store_key(Self::key(new.1), new.0); + pk: &Record::Key<'a>, + old: Option<&'b Record>, + new: &'a Record, + ) -> Result<(), Error> + where + for<'ik, 'pk> Self::Kind<'ik>: IndexKind, Record::Key<'pk>>, + { + let new_skey = Self::Kind::store_key(Self::key(new), pk); let mut store = None; - if let Some((pk, entity)) = old { + if let Some(entity) = old { let old_skey = Self::Kind::store_key(Self::key(entity), pk); if old_skey == new_skey { @@ -46,24 +50,25 @@ pub trait Index { Err(Error::AlreadyExists(Self::NAME.to_string()))? } - store.set(skey, new.0.encode())?; + store.set(skey, pk.encode())?; Ok(()) } fn remove<'a, DB: MultiStoreWriteHandle>( db: &mut DB, - target: (&Record::Key<'a>, &'a Record), + pk: &Record::Key<'a>, + item: &'a Record, ) -> Result<(), Error> { let mut store = db.open_store(Self::NAME)?; - let skey = Self::Kind::store_key(Self::key(target.1), target.0); + let skey = Self::Kind::store_key(Self::key(item), pk); store.remove(skey.encode()).map_err(Error::Backend) } } -pub type StoreKey<'a, I, PK, T> = - <>::Kind<'a> as IndexKind<>::Key<'a>, PK>>::StoreKey<'a>; +pub type StoreKey<'a, 'b, I, PK, T> = + <>::Kind<'a> as IndexKind<>::Key<'b>, PK>>::StoreKey<'a, 'b>; /// IndexKind helps to specify an index behavior by expressing the actual stored key in the index /// based on the index key and the underlying entity primary key. @@ -75,12 +80,12 @@ where IndexKey: Key, PrimaryKey: Key, { - type StoreKey<'a>: Key + type StoreKey<'a, 'b>: Key where IndexKey: 'a, - PrimaryKey: 'a; + PrimaryKey: 'b; - fn store_key<'a>(k: IndexKey, pk: &'a PrimaryKey) -> Self::StoreKey<'a> + fn store_key<'a, 'b>(k: IndexKey, pk: &'b PrimaryKey) -> Self::StoreKey<'a, 'b> where IndexKey: 'a; } @@ -92,13 +97,13 @@ where IndexKey: Key, PrimaryKey: Key, { - type StoreKey<'a> + type StoreKey<'a, 'b> = IndexKey where IndexKey: 'a, - PrimaryKey: 'a; + PrimaryKey: 'b; - fn store_key<'a>(k: IndexKey, _pk: &'a PrimaryKey) -> Self::StoreKey<'a> + fn store_key<'a, 'b>(k: IndexKey, _pk: &'b PrimaryKey) -> Self::StoreKey<'a, 'b> where IndexKey: 'a, { @@ -113,13 +118,13 @@ where IndexKey: Key + AppendKey, PrimaryKey: Key, { - type StoreKey<'a> - = >::Key<'a> + type StoreKey<'a, 'b> + = >::Key<'b> where IndexKey: 'a, - PrimaryKey: 'a; + PrimaryKey: 'b; - fn store_key<'a>(k: IndexKey, pk: &'a PrimaryKey) -> Self::StoreKey<'a> + fn store_key<'a, 'b>(k: IndexKey, pk: &'b PrimaryKey) -> Self::StoreKey<'a, 'b> where IndexKey: 'a, { diff --git a/src/index_registry.rs b/src/index_registry.rs index b3681b2..0775bb4 100644 --- a/src/index_registry.rs +++ b/src/index_registry.rs @@ -12,15 +12,17 @@ pub struct Cons(PhantomData<(Head, Tail)>); /// IndexRegistry is a recursive HList trait to allow defining multiple indexes as generic types. pub trait IndexRegistry { - fn update<'a, DB: MultiStoreWriteHandle>( + fn update<'a, 'b, DB: MultiStoreWriteHandle>( db: &mut DB, - old: Option<(&T::Key<'a>, &'a T)>, - new: (&T::Key<'a>, &'a T), + pk: &T::Key<'a>, + old: Option<&'b T>, + new: &'a T, ) -> Result<(), Error>; fn remove<'a, DB: MultiStoreWriteHandle>( db: &mut DB, - target: (&T::Key<'a>, &'a T), + pk: &T::Key<'a>, + item: &'a T, ) -> Result<(), Error>; fn has_index(name: &str) -> bool; @@ -30,17 +32,19 @@ impl IndexRegistry for Nil where T: Entity, { - fn update<'a, DB: MultiStoreWriteHandle>( + fn update<'a, 'b, DB: MultiStoreWriteHandle>( _db: &mut DB, - _old: Option<(&T::Key<'a>, &'a T)>, - _new: (&T::Key<'a>, &'a T), + _pk: &T::Key<'a>, + _old: Option<&'b T>, + _new: &'a T, ) -> Result<(), Error> { Ok(()) } fn remove<'a, DB: MultiStoreWriteHandle>( _db: &mut DB, - _target: (&T::Key<'a>, &'a T), + _pk: &T::Key<'a>, + _item: &'a T, ) -> Result<(), Error> { Ok(()) } @@ -55,23 +59,25 @@ where T: Entity, Head: Index, Tail: IndexRegistry, - for<'a> Head::Kind<'a>: IndexKind, T::Key<'a>>, + for<'ik, 'pk> Head::Kind<'ik>: IndexKind, T::Key<'pk>>, { - fn update<'a, DB: MultiStoreWriteHandle>( + fn update<'a, 'b, DB: MultiStoreWriteHandle>( db: &mut DB, - old: Option<(&T::Key<'a>, &'a T)>, - new: (&T::Key<'a>, &'a T), + pk: &T::Key<'a>, + old: Option<&'b T>, + new: &'a T, ) -> Result<(), Error> { - Head::update(db, old, new)?; - Tail::update(db, old, new) + Head::update(db, pk, old, new)?; + Tail::update(db, pk, old, new) } fn remove<'a, DB: MultiStoreWriteHandle>( db: &mut DB, - target: (&T::Key<'a>, &'a T), + pk: &T::Key<'a>, + item: &'a T, ) -> Result<(), Error> { - Head::remove(db, target)?; - Tail::remove(db, target) + Head::remove(db, pk, item)?; + Tail::remove(db, pk, item) } fn has_index(name: &str) -> bool { From 85dde5247a10f70b54d881b41943520ee769f61f Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:51:34 +0200 Subject: [PATCH 11/31] feat: impl collection::update --- src/collection.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/collection.rs b/src/collection.rs index 05a0dab..b4b86ed 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -67,8 +67,24 @@ where .transpose() } - pub fn update(&self, _value: impl Borrow) -> Result<(), Error> { - Ok(()) + pub fn update(&self, value: impl Borrow) -> Result<(), Error> { + let value = value.borrow(); + let pk = value.key(); + let enc_pk = pk.encode(); + + let mut tx = self.db.write(self.name)?; + let mut store = tx.open_store(Self::MAIN_STORE)?; + + let old = store.get(&enc_pk)? + .map(|bytes| + Record::from_bytes(&bytes).map_err(Error::Codec) + ).transpose()?; + + store.set(&enc_pk, &value.to_bytes()?)?; + + Indexes::update(&mut tx, &pk, old.as_ref(), &value)?; + + tx.commit().map_err(Error::Backend) } pub fn save(&self, _value: impl Borrow) -> Result<(), Error> { From 671abbbce35e540cf5b47ec0cbd23beeb604f53b Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:54:59 +0200 Subject: [PATCH 12/31] test: refactor idx registry tests --- src/index_registry.rs | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/index_registry.rs b/src/index_registry.rs index 0775bb4..ecb9eb9 100644 --- a/src/index_registry.rs +++ b/src/index_registry.rs @@ -143,15 +143,17 @@ mod tests { } fn update( db: &mut DB, - _old: Option<(&u32, &Record)>, - _new: (&u32, &Record), + _pk: &u32, + _old: Option<&Record>, + _new: &Record, ) -> Result<(), Error> { db.open_store(Self::NAME)?; Ok(()) } fn remove( db: &mut DB, - _target: (&u32, &Record), + _pk: &u32, + _item: &Record, ) -> Result<(), Error> { db.open_store(Self::NAME)?; Ok(()) @@ -172,14 +174,16 @@ mod tests { } fn update( _db: &mut DB, - _old: Option<(&u32, &Record)>, - _new: (&u32, &Record), + _pk: &u32, + _old: Option<&Record>, + _new: &Record, ) -> Result<(), Error> { Err(Error::Unexpected("injected".into())) } fn remove( _db: &mut DB, - _target: (&u32, &Record), + _pk: &u32, + _item: &Record, ) -> Result<(), Error> { Err(Error::Unexpected("injected".into())) } @@ -283,7 +287,7 @@ mod tests { let cases: &[(&dyn Fn(&mut Spy) -> Result<(), Error>, &[&str], bool)] = &[ ( - &|s| >::update(s, None, (&pk, &record)), + &|s| >::update(s, &pk, None, &record), &[], false, ), @@ -291,8 +295,7 @@ mod tests { &|s| { > as IndexRegistry>::update( s, - None, - (&pk, &record), + &pk, None, &record, ) }, &["index_a", "index_b"], @@ -302,8 +305,7 @@ mod tests { &|s| { > as IndexRegistry>::update( s, - None, - (&pk, &record), + &pk, None, &record, ) }, &[], @@ -328,7 +330,7 @@ mod tests { let cases: &[(&dyn Fn(&mut Spy) -> Result<(), Error>, &[&str], bool)] = &[ ( - &|s| >::remove(s, (&pk, &record)), + &|s| >::remove(s, &pk, &record), &[], false, ), @@ -336,7 +338,7 @@ mod tests { &|s| { > as IndexRegistry>::remove( s, - (&pk, &record), + &pk, &record, ) }, &["index_a", "index_b"], @@ -346,7 +348,7 @@ mod tests { &|s| { > as IndexRegistry>::remove( s, - (&pk, &record), + &pk, &record, ) }, &[], From 770f58d75f17d82ccd6edb48f2e2f172cf5a9bce Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:02:14 +0200 Subject: [PATCH 13/31] refactor: contaminate idx scan with lifetimes --- src/scan.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/scan.rs b/src/scan.rs index 8f2f22a..9204123 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -52,7 +52,7 @@ where read_handle: ReadHandle, range: ScanRange, direction: Direction, - after: Option, Record>>, + after: Option, Record>>, _marker: PhantomData<(Record, Idx)>, } @@ -78,7 +78,7 @@ where pub fn range( mut self, - range: Range, Record>>>, + range: Range, Record>>>, ) -> Self { self.range = ScanRange::Range { left: range.start.map(|p| p.encode().as_ref().to_vec()), @@ -92,7 +92,7 @@ where self } - pub fn after(mut self, cursor: StoreKey<'a, Idx, Record::Key<'a>, Record>) -> Self { + pub fn after(mut self, cursor: StoreKey<'a, 'a , Idx, Record::Key<'a>, Record>) -> Self { self.after = Some(cursor); self } @@ -116,14 +116,14 @@ pub trait PrefixScan, KeyPrefix: Prefix> } impl<'a, ReadHandle, Record, Idx, KeyPrefix> - PrefixScan, Record>, KeyPrefix> + PrefixScan, Record>, KeyPrefix> for IndexScan<'a, ReadHandle, Record, Idx> where ReadHandle: MultiStoreReadHandle, Record: Entity, Idx: Index, KeyPrefix: Prefix, - StoreKey<'a, Idx, Record::Key<'a>, Record>: Key + Prefixable, + StoreKey<'a, 'a, Idx, Record::Key<'a>, Record>: Key + Prefixable, for<'b> Idx::Kind<'b>: IndexKind, Record::Key<'b>>, { fn prefix(mut self, prefix: KeyPrefix) -> Self { @@ -141,7 +141,7 @@ where fn range( mut self, - range: Range, Record>, KeyPrefix>>>, + range: Range, Record>, KeyPrefix>>>, ) -> Self { self.range = ScanRange::Range { left: range.start.map(|p| p.encode()), From a059526cda71fb2514b1b2442319038bc2d0cbdc Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:06:01 +0200 Subject: [PATCH 14/31] style: hug linters --- src/collection.rs | 43 ++++++++++++++++++++++++++----------------- src/index.rs | 4 ++-- src/index_registry.rs | 24 ++++++++++-------------- src/scan.rs | 2 +- 4 files changed, 39 insertions(+), 34 deletions(-) diff --git a/src/collection.rs b/src/collection.rs index b4b86ed..f9541d1 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -1,11 +1,13 @@ -use std::borrow::Borrow; use crate::entity::Entity; use crate::error::Error; use crate::index::{Index, IndexKind}; use crate::index_registry::{Cons, ContainsIndex, IndexRegistry, Nil}; use crate::key::Key; use crate::scan::IndexScan; -use crate::store::{MultiStore, MultiStoreReadHandle, MultiStoreWriteHandle, ReadKVStore, WriteKVStore}; +use crate::store::{ + MultiStore, MultiStoreReadHandle, MultiStoreWriteHandle, ReadKVStore, WriteKVStore, +}; +use std::borrow::Borrow; use std::marker::PhantomData; pub struct Collection @@ -51,16 +53,20 @@ where store.set(&enc_pk, &value.to_bytes()?)?; - Indexes::update(&mut tx, &pk, None, &value)?; + Indexes::update(&mut tx, &pk, None, value)?; tx.commit().map_err(Error::Backend) } - pub fn get<'a>(&self, key: impl Borrow< as Key>::OwnedKey>) -> Result, Error> + pub fn get<'a>( + &self, + key: impl Borrow< as Key>::OwnedKey>, + ) -> Result, Error> where Record: 'a, { - self.db.read(self.name)? + self.db + .read(self.name)? .open_store(Self::MAIN_STORE)? .get(key.borrow().encode())? .map(|bytes| Record::from_bytes(&bytes).map_err(Error::Codec)) @@ -75,14 +81,14 @@ where let mut tx = self.db.write(self.name)?; let mut store = tx.open_store(Self::MAIN_STORE)?; - let old = store.get(&enc_pk)? - .map(|bytes| - Record::from_bytes(&bytes).map_err(Error::Codec) - ).transpose()?; + let old = store + .get(&enc_pk)? + .map(|bytes| Record::from_bytes(&bytes).map_err(Error::Codec)) + .transpose()?; - store.set(&enc_pk, &value.to_bytes()?)?; + store.set(&enc_pk, &value.to_bytes()?)?; - Indexes::update(&mut tx, &pk, old.as_ref(), &value)?; + Indexes::update(&mut tx, &pk, old.as_ref(), value)?; tx.commit().map_err(Error::Backend) } @@ -91,7 +97,10 @@ where Ok(()) } - pub fn remove<'a>(&self, key: impl Borrow< as Key>::OwnedKey>) -> Result<(), Error> + pub fn remove<'a>( + &self, + key: impl Borrow< as Key>::OwnedKey>, + ) -> Result<(), Error> where Record: 'a, { @@ -101,10 +110,10 @@ where let mut tx = self.db.write(self.name)?; let mut store = tx.open_store(Self::MAIN_STORE)?; - let record = store.get(enc_pk)? - .map(|bytes| - Record::from_bytes(&bytes).map_err(Error::Codec) - ).transpose()?; + let record = store + .get(enc_pk)? + .map(|bytes| Record::from_bytes(&bytes).map_err(Error::Codec)) + .transpose()?; let record = match record { Some(record) => record, @@ -152,7 +161,7 @@ where pub fn with_index(self) -> CollectionBuilder> where Idx: Index, - for<'ik, 'pk> Idx::Kind<'ik>: IndexKind, Record::Key<'pk>> + for<'ik, 'pk> Idx::Kind<'ik>: IndexKind, Record::Key<'pk>>, { assert!( !Indexes::has_index(Idx::NAME), diff --git a/src/index.rs b/src/index.rs index b49c16e..a30a905 100644 --- a/src/index.rs +++ b/src/index.rs @@ -19,10 +19,10 @@ pub trait Index { fn key(entity: &Record) -> Self::Key<'_>; - fn update<'a, 'b, DB: MultiStoreWriteHandle>( + fn update<'a, DB: MultiStoreWriteHandle>( db: &mut DB, pk: &Record::Key<'a>, - old: Option<&'b Record>, + old: Option<&Record>, new: &'a Record, ) -> Result<(), Error> where diff --git a/src/index_registry.rs b/src/index_registry.rs index ecb9eb9..46a8b1d 100644 --- a/src/index_registry.rs +++ b/src/index_registry.rs @@ -12,10 +12,10 @@ pub struct Cons(PhantomData<(Head, Tail)>); /// IndexRegistry is a recursive HList trait to allow defining multiple indexes as generic types. pub trait IndexRegistry { - fn update<'a, 'b, DB: MultiStoreWriteHandle>( + fn update<'a, DB: MultiStoreWriteHandle>( db: &mut DB, pk: &T::Key<'a>, - old: Option<&'b T>, + old: Option<&T>, new: &'a T, ) -> Result<(), Error>; @@ -32,10 +32,10 @@ impl IndexRegistry for Nil where T: Entity, { - fn update<'a, 'b, DB: MultiStoreWriteHandle>( + fn update<'a, DB: MultiStoreWriteHandle>( _db: &mut DB, _pk: &T::Key<'a>, - _old: Option<&'b T>, + _old: Option<&T>, _new: &'a T, ) -> Result<(), Error> { Ok(()) @@ -61,10 +61,10 @@ where Tail: IndexRegistry, for<'ik, 'pk> Head::Kind<'ik>: IndexKind, T::Key<'pk>>, { - fn update<'a, 'b, DB: MultiStoreWriteHandle>( + fn update<'a, DB: MultiStoreWriteHandle>( db: &mut DB, pk: &T::Key<'a>, - old: Option<&'b T>, + old: Option<&T>, new: &'a T, ) -> Result<(), Error> { Head::update(db, pk, old, new)?; @@ -294,8 +294,7 @@ mod tests { ( &|s| { > as IndexRegistry>::update( - s, - &pk, None, &record, + s, &pk, None, &record, ) }, &["index_a", "index_b"], @@ -304,8 +303,7 @@ mod tests { ( &|s| { > as IndexRegistry>::update( - s, - &pk, None, &record, + s, &pk, None, &record, ) }, &[], @@ -337,8 +335,7 @@ mod tests { ( &|s| { > as IndexRegistry>::remove( - s, - &pk, &record, + s, &pk, &record, ) }, &["index_a", "index_b"], @@ -347,8 +344,7 @@ mod tests { ( &|s| { > as IndexRegistry>::remove( - s, - &pk, &record, + s, &pk, &record, ) }, &[], diff --git a/src/scan.rs b/src/scan.rs index 9204123..1dd0334 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -92,7 +92,7 @@ where self } - pub fn after(mut self, cursor: StoreKey<'a, 'a , Idx, Record::Key<'a>, Record>) -> Self { + pub fn after(mut self, cursor: StoreKey<'a, 'a, Idx, Record::Key<'a>, Record>) -> Self { self.after = Some(cursor); self } From 6fbe41dd90907806f8d49caa57cf5ec5ee5bc7ba Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:19:52 +0200 Subject: [PATCH 15/31] feat: force Key to be Debug --- src/key.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/key.rs b/src/key.rs index 1c34925..f8d7400 100644 --- a/src/key.rs +++ b/src/key.rs @@ -1,3 +1,4 @@ +use std::fmt::Debug; use crate::inline_vec::IVec; use crate::{impl_signed_integer_key, impl_unsigned_integer_key}; @@ -37,7 +38,7 @@ use crate::{impl_signed_integer_key, impl_unsigned_integer_key}; /// /// Changing a `Key` implementation changes the physical storage layout and /// should be treated as a migration. -pub trait Key: Eq { +pub trait Key: Debug + Eq { /// The encoded size of the key. /// /// Fixed-size keys allow Colette to preallocate buffers efficiently. From 1cff2cad4374a71c92d9528a62d2befc606374d6 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:20:05 +0200 Subject: [PATCH 16/31] fix: properly give fmted keys in errors --- src/collection.rs | 2 +- src/index.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/collection.rs b/src/collection.rs index f9541d1..1ff996b 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -48,7 +48,7 @@ where let mut store = tx.open_store(Self::MAIN_STORE)?; if store.get(&enc_pk)?.is_some() { - Err(Error::AlreadyExists(self.name.to_string()))? + Err(Error::AlreadyExists(format!("{:?}", pk)))? } store.set(&enc_pk, &value.to_bytes()?)?; diff --git a/src/index.rs b/src/index.rs index a30a905..d0d84a6 100644 --- a/src/index.rs +++ b/src/index.rs @@ -47,7 +47,7 @@ pub trait Index { let skey = new_skey.encode(); if store.get(&skey)?.is_some() { - Err(Error::AlreadyExists(Self::NAME.to_string()))? + Err(Error::AlreadyExists(format!("{:?}", new_skey)))? } store.set(skey, pk.encode())?; From d0fd37e92287672454cfe0a057330267db7e58d1 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:20:26 +0200 Subject: [PATCH 17/31] fix: err when updating unexisting record --- src/collection.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/collection.rs b/src/collection.rs index 1ff996b..dc7789a 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -86,6 +86,10 @@ where .map(|bytes| Record::from_bytes(&bytes).map_err(Error::Codec)) .transpose()?; + if old.is_none() { + Err(Error::NotFound(format!("{:?}", pk)))? + } + store.set(&enc_pk, &value.to_bytes()?)?; Indexes::update(&mut tx, &pk, old.as_ref(), value)?; From db50bace392d2137a7e45aba1aceb69dc04be0ed Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:21:45 +0200 Subject: [PATCH 18/31] feat: impl collection::save --- src/collection.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/collection.rs b/src/collection.rs index dc7789a..9bda6c5 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -97,8 +97,24 @@ where tx.commit().map_err(Error::Backend) } - pub fn save(&self, _value: impl Borrow) -> Result<(), Error> { - Ok(()) + pub fn save(&self, value: impl Borrow) -> Result<(), Error> { + let value = value.borrow(); + let pk = value.key(); + let enc_pk = pk.encode(); + + let mut tx = self.db.write(self.name)?; + let mut store = tx.open_store(Self::MAIN_STORE)?; + + let old = store + .get(&enc_pk)? + .map(|bytes| Record::from_bytes(&bytes).map_err(Error::Codec)) + .transpose()?; + + store.set(&enc_pk, &value.to_bytes()?)?; + + Indexes::update(&mut tx, &pk, old.as_ref(), value)?; + + tx.commit().map_err(Error::Backend) } pub fn remove<'a>( From 83865a93ac43b1d53724b515f8c2059b7fa58dac Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:22:43 +0200 Subject: [PATCH 19/31] docs: complete readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 055eae1..166dc94 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,10 @@ pub struct Download { size: u64, } -#[derive(Clone, Copy, Eq, PartialEq)] +#[derive(Clone, Copy, Eq, PartialEq, Debug)] pub struct InfoHash([u8; 20]); -#[derive(Clone, Copy, Eq, PartialEq)] +#[derive(Clone, Copy, Eq, PartialEq, Debug)] pub enum Status { Queued, Submitted, From 017fb869db25102505d98cfe4fc032b128526ea2 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:30:12 +0200 Subject: [PATCH 20/31] refactor: rename col::index to col::scan --- src/collection.rs | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/collection.rs b/src/collection.rs index 9bda6c5..0841624 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -58,21 +58,6 @@ where tx.commit().map_err(Error::Backend) } - pub fn get<'a>( - &self, - key: impl Borrow< as Key>::OwnedKey>, - ) -> Result, Error> - where - Record: 'a, - { - self.db - .read(self.name)? - .open_store(Self::MAIN_STORE)? - .get(key.borrow().encode())? - .map(|bytes| Record::from_bytes(&bytes).map_err(Error::Codec)) - .transpose() - } - pub fn update(&self, value: impl Borrow) -> Result<(), Error> { let value = value.borrow(); let pk = value.key(); @@ -147,7 +132,22 @@ where tx.commit().map_err(Error::Backend) } - pub fn index<'a, Idx, P>( + pub fn get<'a>( + &self, + key: impl Borrow< as Key>::OwnedKey>, + ) -> Result, Error> + where + Record: 'a, + { + self.db + .read(self.name)? + .open_store(Self::MAIN_STORE)? + .get(key.borrow().encode())? + .map(|bytes| Record::from_bytes(&bytes).map_err(Error::Codec)) + .transpose() + } + + pub fn scan<'a, Idx, P>( &self, _idx: Idx, ) -> Result, Error> From ea7bf38decd738ff67c27cee9e55b6813cdb1210 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:34:04 +0200 Subject: [PATCH 21/31] docs: document collection API --- src/collection.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/collection.rs b/src/collection.rs index 0841624..e7acfa7 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -39,6 +39,11 @@ where } } + /// Inserts a new record into the collection. + /// + /// Returns an error if a record with the same primary key already exists. + /// + /// All indexes are updated atomically within the same transaction. pub fn insert(&self, value: impl Borrow) -> Result<(), Error> { let value = value.borrow(); let pk = value.key(); @@ -58,6 +63,11 @@ where tx.commit().map_err(Error::Backend) } + /// Updates an existing record in the collection. + /// + /// Returns an error if the record does not already exist. + /// + /// Indexes are automatically updated when indexed fields change. pub fn update(&self, value: impl Borrow) -> Result<(), Error> { let value = value.borrow(); let pk = value.key(); @@ -82,6 +92,12 @@ where tx.commit().map_err(Error::Backend) } + /// Saves a record into the collection. + /// + /// If the record already exists, it is updated. + /// Otherwise, a new record is inserted. + /// + /// Indexes are updated atomically within the same transaction. pub fn save(&self, value: impl Borrow) -> Result<(), Error> { let value = value.borrow(); let pk = value.key(); @@ -102,6 +118,11 @@ where tx.commit().map_err(Error::Backend) } + /// Removes a record from the collection by its primary key. + /// + /// If the record exists, all associated index entries are also removed. + /// + /// Returns `Ok(())` if the record does not exist. pub fn remove<'a>( &self, key: impl Borrow< as Key>::OwnedKey>, @@ -132,6 +153,9 @@ where tx.commit().map_err(Error::Backend) } + /// Retrieves a record from the collection by its primary key. + /// + /// Returns `Ok(None)` if the record does not exist. pub fn get<'a>( &self, key: impl Borrow< as Key>::OwnedKey>, @@ -147,6 +171,16 @@ where .transpose() } + /// Creates a typed scan over a collection index. + /// + /// Scans can be configured with: + /// - prefixes + /// - cursors + /// - ordering direction + /// - limits + /// + /// The returned scan is lazy and does not perform any database access + /// until iterated. pub fn scan<'a, Idx, P>( &self, _idx: Idx, From 36db5b1926f111e5a2bc6422c450384221da5985 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:34:55 +0200 Subject: [PATCH 22/31] test: introduce mocked store for testing purposes --- src/lib.rs | 3 + src/testing.rs | 250 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 src/testing.rs diff --git a/src/lib.rs b/src/lib.rs index c7f50c9..f8b3822 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,3 +10,6 @@ pub mod macros; pub mod prefix; pub mod scan; pub mod store; + +#[cfg(test)] +pub mod testing; diff --git a/src/testing.rs b/src/testing.rs new file mode 100644 index 0000000..6e2b0ce --- /dev/null +++ b/src/testing.rs @@ -0,0 +1,250 @@ +//! Reusable mock implementations of the [`crate::store`] traits for use across +//! test modules. +//! +//! Every operation performed through a [`MockDb`] is recorded in a shared +//! [`TxLog`] that callers can inspect after the fact. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use crate::error::BackendError; +use crate::scan::{Direction, ScanRange}; +use crate::store::{ + MultiStore, MultiStoreReadHandle, MultiStoreWriteHandle, ReadKVStore, ReadWriteKVStore, + WriteKVStore, +}; + +// ── Error helpers ───────────────────────────────────────────────────────────── + +/// Constructs a [`BackendError`] from a static message, for use in error +/// factory functions passed to [`MockDb::with_write_err`] / +/// [`MockDb::with_commit_err`]. +pub fn backend_error(msg: &'static str) -> BackendError { + BackendError::new(std::io::Error::new(std::io::ErrorKind::Other, msg)) +} + +// ── TxLog ───────────────────────────────────────────────────────────────────── + +/// Shared log of all store operations performed via a [`MockDb`] instance. +/// +/// The log is shared between the db and all handles/stores it produces so +/// that callers can observe the full sequence of operations after a test call. +#[derive(Default, Debug)] +pub struct TxLog { + /// Names of stores opened via `open_store` (in call order). + pub opens: Vec, + /// Raw keys passed to `get` (in call order). + pub gets: Vec>, + /// `(key, value)` pairs passed to `set` (in call order). + pub sets: Vec<(Vec, Vec)>, + /// Raw keys passed to `remove` (in call order). + pub removes: Vec>, + /// Whether `commit` was called on the write handle. + pub committed: bool, +} + +// ── MockStore ───────────────────────────────────────────────────────────────── + +/// A mock [`ReadWriteKVStore`] that records every operation in a shared +/// [`TxLog`] and serves pre-configured byte values for `get` calls. +pub struct MockStore { + log: Rc>, + data: HashMap, Vec>, +} + +impl ReadKVStore for MockStore { + type Iter = std::iter::Empty, Vec), BackendError>>; + + fn get(&self, key: impl AsRef<[u8]>) -> Result>, BackendError> { + let key = key.as_ref().to_vec(); + self.log.borrow_mut().gets.push(key.clone()); + Ok(self.data.get(&key).cloned()) + } + + fn scan(&self, _: ScanRange, _: Direction) -> Result { + Ok(std::iter::empty()) + } +} + +impl WriteKVStore for MockStore { + fn set(&mut self, key: impl AsRef<[u8]>, value: impl AsRef<[u8]>) -> Result<(), BackendError> { + self.log + .borrow_mut() + .sets + .push((key.as_ref().to_vec(), value.as_ref().to_vec())); + Ok(()) + } + + fn remove(&mut self, key: impl AsRef<[u8]>) -> Result<(), BackendError> { + self.log.borrow_mut().removes.push(key.as_ref().to_vec()); + Ok(()) + } +} + +impl ReadWriteKVStore for MockStore {} + +// ── MockWriteHandle ─────────────────────────────────────────────────────────── + +/// A mock [`MultiStoreWriteHandle`]. +/// +/// Each call to `open_store` is logged and returns a [`MockStore`] seeded with +/// the data registered under that store name on the owning [`MockDb`]. +pub struct MockWriteHandle { + log: Rc>, + store_data: HashMap, Vec>>, + commit_err: Option BackendError>, +} + +impl MultiStoreWriteHandle for MockWriteHandle { + type Store = MockStore; + + fn open_store(&mut self, name: &str) -> Result { + self.log.borrow_mut().opens.push(name.to_string()); + let data = self.store_data.get(name).cloned().unwrap_or_default(); + Ok(MockStore { + log: self.log.clone(), + data, + }) + } + + fn commit(self) -> Result<(), BackendError> { + self.log.borrow_mut().committed = true; + match self.commit_err { + Some(make_err) => Err(make_err()), + None => Ok(()), + } + } +} + +// ── MockReadHandle ──────────────────────────────────────────────────────────── + +/// A mock [`MultiStoreReadHandle`]. +/// +/// Read operations are also recorded in the shared log. +pub struct MockReadHandle { + log: Rc>, + store_data: HashMap, Vec>>, +} + +impl MultiStoreReadHandle for MockReadHandle { + type Store = MockStore; + + fn open_store(&self, name: &str) -> Result { + self.log.borrow_mut().opens.push(name.to_string()); + let data = self.store_data.get(name).cloned().unwrap_or_default(); + Ok(MockStore { + log: self.log.clone(), + data, + }) + } +} + +// ── MockDb ──────────────────────────────────────────────────────────────────── + +/// A configurable mock [`MultiStore`]. +/// +/// # Usage +/// +/// ```rust,ignore +/// let db = MockDb::new() +/// .with_data("__main", enc_pk, enc_val) // simulate existing record +/// .with_commit_err(|| backend_error("disk full")); +/// +/// let log = db.log(); // clone the Rc before the db is moved +/// collection.insert(record)?; +/// +/// let log = log.borrow(); +/// assert_eq!(log.sets.len(), 1); +/// assert!(log.committed); +/// ``` +pub struct MockDb { + log: Rc>, + store_data: HashMap, Vec>>, + read_err: Option BackendError>, + write_err: Option BackendError>, + commit_err: Option BackendError>, +} + +impl Default for MockDb { + fn default() -> Self { + Self { + log: Rc::new(RefCell::new(TxLog::default())), + store_data: HashMap::new(), + read_err: None, + write_err: None, + commit_err: None, + } + } +} + +impl MockDb { + pub fn new() -> Self { + Self::default() + } + + /// Returns a handle to the shared operation log for post-call assertions. + /// + /// Clone this before moving `MockDb` into a [`Collection`]. + pub fn log(&self) -> Rc> { + self.log.clone() + } + + /// Pre-seeds a named store with a key/value entry (returned by `get`). + pub fn with_data( + mut self, + store: &str, + key: impl Into>, + value: impl Into>, + ) -> Self { + self.store_data + .entry(store.to_string()) + .or_default() + .insert(key.into(), value.into()); + self + } + + /// Makes `read()` return an error produced by `make_err`. + pub fn with_read_err(mut self, make_err: fn() -> BackendError) -> Self { + self.read_err = Some(make_err); + self + } + + /// Makes `write()` return an error produced by `make_err`. + pub fn with_write_err(mut self, make_err: fn() -> BackendError) -> Self { + self.write_err = Some(make_err); + self + } + + /// Makes `commit()` return an error produced by `make_err`. + pub fn with_commit_err(mut self, make_err: fn() -> BackendError) -> Self { + self.commit_err = Some(make_err); + self + } +} + +impl MultiStore for MockDb { + type ReadHandle = MockReadHandle; + type WriteHandle = MockWriteHandle; + + fn read(&self, _: &str) -> Result { + if let Some(make_err) = self.read_err { + return Err(make_err()); + } + Ok(MockReadHandle { + log: self.log.clone(), + store_data: self.store_data.clone(), + }) + } + + fn write(&self, _: &str) -> Result { + if let Some(make_err) = self.write_err { + return Err(make_err()); + } + Ok(MockWriteHandle { + log: self.log.clone(), + store_data: self.store_data.clone(), + commit_err: self.commit_err, + }) + } +} From 1c5df75ddf2390fa343355c3502f86b3fc12dfc8 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:35:09 +0200 Subject: [PATCH 23/31] feat: add internal helpers for errors --- src/error.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/error.rs b/src/error.rs index eb1e9ea..d2ee3aa 100644 --- a/src/error.rs +++ b/src/error.rs @@ -21,6 +21,12 @@ pub enum Error { #[derive(Debug, thiserror::Error)] pub struct BackendError(Box); +impl BackendError { + pub(crate) fn new(e: impl std::error::Error + Send + Sync + 'static) -> Self { + BackendError(Box::new(e)) + } +} + impl Display for BackendError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) @@ -30,6 +36,12 @@ impl Display for BackendError { #[derive(Debug, thiserror::Error)] pub struct CodecError(Box); +impl CodecError { + pub(crate) fn new(e: impl std::error::Error + Send + Sync + 'static) -> Self { + CodecError(Box::new(e)) + } +} + impl Display for CodecError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) From 8d7e83f219ea9b013d92c720b35b7c4d22e2b9f5 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:35:39 +0200 Subject: [PATCH 24/31] test(collection): add test cases for insert --- src/collection.rs | 251 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) diff --git a/src/collection.rs b/src/collection.rs index e7acfa7..350a21c 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -246,3 +246,254 @@ where _marker: PhantomData, } } + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use crate::collection::Collection; + use crate::entity::Entity; + use crate::error::{CodecError, Error}; + use crate::index::{Index, Unique}; + use crate::index_registry::{Cons, Nil}; + use crate::key::Key; + use crate::store::MultiStoreWriteHandle; + use crate::testing::{MockDb, backend_error}; + + // ── Minimal entity ──────────────────────────────────────────────────────── + + struct TestRecord { + id: u32, + } + + impl Entity for TestRecord { + type Key<'a> = u32; + + fn key(&self) -> u32 { + self.id + } + + fn to_bytes(&self) -> Result, CodecError> { + Ok(self.id.to_be_bytes().to_vec()) + } + + fn from_bytes(bytes: &[u8]) -> Result { + let id = u32::from_be_bytes( + bytes + .try_into() + .map_err(|_| CodecError::new(std::io::Error::other("bad length")))?, + ); + Ok(TestRecord { id }) + } + } + + // ── Spy indexes ─────────────────────────────────────────────────────────── + // Each spy opens its named store (observable via TxLog::opens) and succeeds. + + struct IndexA; + struct IndexB; + struct FailIndex; + + macro_rules! spy_index { + ($ty:ty, $name:literal) => { + impl Index for $ty { + type Key<'a> = u32; + type Kind<'a> = Unique; + const NAME: &'static str = $name; + + fn key(r: &TestRecord) -> u32 { + r.id + } + + fn update( + db: &mut DB, + _pk: &u32, + _old: Option<&TestRecord>, + _new: &TestRecord, + ) -> Result<(), Error> { + db.open_store(Self::NAME)?; + Ok(()) + } + + fn remove( + db: &mut DB, + _pk: &u32, + _item: &TestRecord, + ) -> Result<(), Error> { + db.open_store(Self::NAME)?; + Ok(()) + } + } + }; + } + + spy_index!(IndexA, "index_a"); + spy_index!(IndexB, "index_b"); + + impl Index for FailIndex { + type Key<'a> = u32; + type Kind<'a> = Unique; + const NAME: &'static str = "fail"; + + fn key(r: &TestRecord) -> u32 { + r.id + } + + fn update( + _db: &mut DB, + _pk: &u32, + _old: Option<&TestRecord>, + _new: &TestRecord, + ) -> Result<(), Error> { + Err(Error::Unexpected("injected index error".into())) + } + + fn remove( + _db: &mut DB, + _pk: &u32, + _item: &TestRecord, + ) -> Result<(), Error> { + Err(Error::Unexpected("injected index error".into())) + } + } + + // ── insert ──────────────────────────────────────────────────────────────── + + #[test] + fn insert() { + let enc_pk = 1u32.encode().to_vec(); + let enc_val = TestRecord { id: 1 }.to_bytes().unwrap(); + + // Helper macro: runs insert with the given Indexes type parameter (which is + // a compile-time choice) and returns the result + a snapshot of the log. + macro_rules! run { + ($indexes:ty, $db:expr) => {{ + let db: MockDb = $db; + let log = db.log(); + let col = Collection::<_, TestRecord, $indexes>::new("col", db); + let result = col.insert(TestRecord { id: 1 }); + let log = Rc::clone(&log); + (result, log) + }}; + } + + // ── Store-level cases (index type fixed to Nil) ─────────────────────── + + struct Case { + name: &'static str, + db: MockDb, + expect_result: fn(&Result<(), Error>), + expect_opens: &'static [&'static str], + expect_sets: usize, + expect_committed: bool, + } + + let cases = vec![ + Case { + name: "inserts new record", + db: MockDb::new(), + expect_result: |r| assert!(r.is_ok()), + expect_opens: &["__main"], + expect_sets: 1, + expect_committed: true, + }, + Case { + name: "fails when record already exists", + db: MockDb::new().with_data("__main", enc_pk.clone(), enc_val.clone()), + expect_result: |r| assert!(matches!(r, Err(Error::AlreadyExists(_)))), + expect_opens: &["__main"], + expect_sets: 0, + expect_committed: false, + }, + Case { + name: "propagates backend error from write()", + db: MockDb::new().with_write_err(|| backend_error("write failed")), + expect_result: |r| assert!(matches!(r, Err(Error::Backend(_)))), + expect_opens: &[], + expect_sets: 0, + expect_committed: false, + }, + Case { + // set is called before commit; commit failure must be propagated + name: "propagates backend error from commit()", + db: MockDb::new().with_commit_err(|| backend_error("commit failed")), + expect_result: |r| assert!(matches!(r, Err(Error::Backend(_)))), + expect_opens: &["__main"], + expect_sets: 1, + expect_committed: true, + }, + ]; + + for c in cases { + let (result, log) = run!(Nil, c.db); + let log = log.borrow(); + + (c.expect_result)(&result); + assert_eq!(log.opens.as_slice(), c.expect_opens, "[{}] opens", c.name); + assert_eq!(log.sets.len(), c.expect_sets, "[{}] sets count", c.name); + assert_eq!(log.committed, c.expect_committed, "[{}] committed", c.name); + } + + // Verify set writes the correct key and value bytes + let (result, log) = run!(Nil, MockDb::new()); + assert!(result.is_ok()); + let log = log.borrow(); + assert_eq!(log.sets[0].0, enc_pk, "set key must be the encoded primary key"); + assert_eq!(log.sets[0].1, enc_val, "set value must be to_bytes() output"); + + // ── Index dispatch cases ────────────────────────────────────────────── + + struct IndexCase { + name: &'static str, + expected_opens: &'static [&'static str], + expect_ok: bool, + expect_committed: bool, + } + + // Indexes vary at compile time, so we use the macro for each arity. + let (r, log) = run!(Nil, MockDb::new()); + let nil_case = (r, log.borrow().opens.clone(), log.borrow().committed); + + let (r, log) = run!(Cons, MockDb::new()); + let one_index = (r, log.borrow().opens.clone(), log.borrow().committed); + + let (r, log) = run!(Cons>, MockDb::new()); + let two_indexes = (r, log.borrow().opens.clone(), log.borrow().committed); + + // Failing index: set is still called on main store, but commit is skipped + let (r, log) = run!(Cons, MockDb::new()); + let fail_index = (r, log.borrow().opens.clone(), log.borrow().committed, log.borrow().sets.len()); + + let index_cases: &[IndexCase] = &[ + IndexCase { + name: "no indexes: only main store opened", + expected_opens: &["__main"], + expect_ok: true, expect_committed: true, + }, + IndexCase { + name: "one index: main store then index store opened", + expected_opens: &["__main", "index_a"], + expect_ok: true, expect_committed: true, + }, + IndexCase { + name: "two indexes: all stores opened in registry order", + expected_opens: &["__main", "index_a", "index_b"], + expect_ok: true, expect_committed: true, + }, + ]; + + let dispatch_results = [nil_case, one_index, two_indexes]; + for (c, (result, opens, committed)) in index_cases.iter().zip(&dispatch_results) { + assert_eq!(result.is_ok(), c.expect_ok, "[{}] result", c.name); + assert_eq!(opens.as_slice(), c.expected_opens, "[{}] opens", c.name); + assert_eq!(*committed, c.expect_committed, "[{}] committed", c.name); + } + + // Failing index: error propagated, set occurred, commit skipped + let (fail_result, fail_opens, fail_committed, fail_sets) = fail_index; + assert!(matches!(fail_result, Err(Error::Unexpected(_))), "failing index error propagated"); + assert_eq!(fail_opens.as_slice(), &["__main"] as &[&str], "failing index: only main store opened"); + assert_eq!(fail_sets, 1, "failing index: main record was written before index error"); + assert!(!fail_committed, "failing index: commit must not be called"); + } +} From bf41351dae49f345125ad55a7327f94633c9cded Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:13:56 +0200 Subject: [PATCH 25/31] test: collection::update --- src/collection.rs | 127 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/src/collection.rs b/src/collection.rs index 350a21c..0d8f554 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -496,4 +496,131 @@ mod tests { assert_eq!(fail_sets, 1, "failing index: main record was written before index error"); assert!(!fail_committed, "failing index: commit must not be called"); } + + #[test] + fn update() { + let enc_pk = 1u32.encode().to_vec(); + let enc_val = TestRecord { id: 1 }.to_bytes().unwrap(); + + // Pre-seeds the main store with a valid existing record. + let existing_db = + || MockDb::new().with_data("__main", enc_pk.clone(), enc_val.clone()); + + macro_rules! run { + ($indexes:ty, $db:expr) => {{ + let db: MockDb = $db; + let log = db.log(); + let col = Collection::<_, TestRecord, $indexes>::new("col", db); + let result = col.update(TestRecord { id: 1 }); + (result, log) + }}; + } + + // ── Store-level cases (index type fixed to Nil) ─────────────────────── + + struct Case { + name: &'static str, + db: MockDb, + expect_result: fn(&Result<(), Error>), + expect_opens: &'static [&'static str], + expect_sets: usize, + expect_committed: bool, + } + + let cases = vec![ + Case { + name: "updates existing record", + db: existing_db(), + expect_result: |r| assert!(r.is_ok()), + expect_opens: &["__main"], + expect_sets: 1, + expect_committed: true, + }, + Case { + name: "fails when record not found", + db: MockDb::new(), + expect_result: |r| assert!(matches!(r, Err(Error::NotFound(_)))), + expect_opens: &["__main"], + expect_sets: 0, + expect_committed: false, + }, + Case { + name: "propagates backend error from write()", + db: MockDb::new().with_write_err(|| backend_error("write failed")), + expect_result: |r| assert!(matches!(r, Err(Error::Backend(_)))), + expect_opens: &[], + expect_sets: 0, + expect_committed: false, + }, + Case { + name: "propagates backend error from commit()", + db: existing_db().with_commit_err(|| backend_error("commit failed")), + expect_result: |r| assert!(matches!(r, Err(Error::Backend(_)))), + expect_opens: &["__main"], + expect_sets: 1, + expect_committed: true, + }, + Case { + // from_bytes is called on the stored value before set — codec errors must surface + name: "propagates codec error from corrupted stored bytes", + db: MockDb::new().with_data("__main", enc_pk.clone(), vec![0x01]), + expect_result: |r| assert!(matches!(r, Err(Error::Codec(_)))), + expect_opens: &["__main"], + expect_sets: 0, + expect_committed: false, + }, + ]; + + for c in cases { + let (result, log) = run!(Nil, c.db); + let log = log.borrow(); + + (c.expect_result)(&result); + assert_eq!(log.opens.as_slice(), c.expect_opens, "[{}] opens", c.name); + assert_eq!(log.sets.len(), c.expect_sets, "[{}] sets count", c.name); + assert_eq!(log.committed, c.expect_committed, "[{}] committed", c.name); + } + + // Verify set writes the correct key and value bytes + { + let (result, log) = run!(Nil, existing_db()); + assert!(result.is_ok()); + let log = log.borrow(); + assert_eq!(log.sets[0].0, enc_pk, "set key must be the encoded primary key"); + assert_eq!(log.sets[0].1, enc_val, "set value must be to_bytes() output"); + } + + // ── Index dispatch ──────────────────────────────────────────────────── + + { + let (r, log) = run!(Nil, existing_db()); + let log = log.borrow(); + assert!(r.is_ok(), "no indexes: result"); + assert_eq!(log.opens, vec!["__main"], "no indexes: opens"); + assert!(log.committed, "no indexes: committed"); + } + { + let (r, log) = run!(Cons, existing_db()); + let log = log.borrow(); + assert!(r.is_ok(), "one index: result"); + assert_eq!(log.opens, vec!["__main", "index_a"], "one index: opens"); + assert!(log.committed, "one index: committed"); + } + { + let (r, log) = run!(Cons>, existing_db()); + let log = log.borrow(); + assert!(r.is_ok(), "two indexes: result"); + assert_eq!(log.opens, vec!["__main", "index_a", "index_b"], "two indexes: opens"); + assert!(log.committed, "two indexes: committed"); + } + { + // Failing index: set happened on main store, but commit is skipped + let (r, log) = run!(Cons, existing_db()); + let log = log.borrow(); + assert!(matches!(r, Err(Error::Unexpected(_))), "failing index: error propagated"); + assert_eq!(log.opens, vec!["__main"], "failing index: only main store opened"); + assert_eq!(log.sets.len(), 1, "failing index: main record written before index error"); + assert!(!log.committed, "failing index: commit must not be called"); + } + } } From df1af7280c7931e5eff1665d15a1e1242ba49124 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:29:16 +0200 Subject: [PATCH 26/31] test: simplify with a mocked index registry --- src/collection.rs | 259 +++++++++++++--------------------------------- src/testing.rs | 78 +++++++++++++- 2 files changed, 146 insertions(+), 191 deletions(-) diff --git a/src/collection.rs b/src/collection.rs index 0d8f554..0dc87d5 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -249,16 +249,11 @@ where #[cfg(test)] mod tests { - use std::rc::Rc; - use crate::collection::Collection; use crate::entity::Entity; use crate::error::{CodecError, Error}; - use crate::index::{Index, Unique}; - use crate::index_registry::{Cons, Nil}; use crate::key::Key; - use crate::store::MultiStoreWriteHandle; - use crate::testing::{MockDb, backend_error}; + use crate::testing::{MockDb, SpyRegistry, backend_error}; // ── Minimal entity ──────────────────────────────────────────────────────── @@ -287,76 +282,6 @@ mod tests { } } - // ── Spy indexes ─────────────────────────────────────────────────────────── - // Each spy opens its named store (observable via TxLog::opens) and succeeds. - - struct IndexA; - struct IndexB; - struct FailIndex; - - macro_rules! spy_index { - ($ty:ty, $name:literal) => { - impl Index for $ty { - type Key<'a> = u32; - type Kind<'a> = Unique; - const NAME: &'static str = $name; - - fn key(r: &TestRecord) -> u32 { - r.id - } - - fn update( - db: &mut DB, - _pk: &u32, - _old: Option<&TestRecord>, - _new: &TestRecord, - ) -> Result<(), Error> { - db.open_store(Self::NAME)?; - Ok(()) - } - - fn remove( - db: &mut DB, - _pk: &u32, - _item: &TestRecord, - ) -> Result<(), Error> { - db.open_store(Self::NAME)?; - Ok(()) - } - } - }; - } - - spy_index!(IndexA, "index_a"); - spy_index!(IndexB, "index_b"); - - impl Index for FailIndex { - type Key<'a> = u32; - type Kind<'a> = Unique; - const NAME: &'static str = "fail"; - - fn key(r: &TestRecord) -> u32 { - r.id - } - - fn update( - _db: &mut DB, - _pk: &u32, - _old: Option<&TestRecord>, - _new: &TestRecord, - ) -> Result<(), Error> { - Err(Error::Unexpected("injected index error".into())) - } - - fn remove( - _db: &mut DB, - _pk: &u32, - _item: &TestRecord, - ) -> Result<(), Error> { - Err(Error::Unexpected("injected index error".into())) - } - } - // ── insert ──────────────────────────────────────────────────────────────── #[test] @@ -364,263 +289,219 @@ mod tests { let enc_pk = 1u32.encode().to_vec(); let enc_val = TestRecord { id: 1 }.to_bytes().unwrap(); - // Helper macro: runs insert with the given Indexes type parameter (which is - // a compile-time choice) and returns the result + a snapshot of the log. macro_rules! run { - ($indexes:ty, $db:expr) => {{ + ($db:expr) => {{ let db: MockDb = $db; let log = db.log(); - let col = Collection::<_, TestRecord, $indexes>::new("col", db); + let col = Collection::<_, TestRecord, SpyRegistry>::new("col", db); let result = col.insert(TestRecord { id: 1 }); - let log = Rc::clone(&log); (result, log) }}; } - // ── Store-level cases (index type fixed to Nil) ─────────────────────── - struct Case { name: &'static str, db: MockDb, + registry_fails: bool, expect_result: fn(&Result<(), Error>), expect_opens: &'static [&'static str], expect_sets: usize, expect_committed: bool, + expect_registry_called: bool, } let cases = vec![ Case { name: "inserts new record", db: MockDb::new(), + registry_fails: false, expect_result: |r| assert!(r.is_ok()), expect_opens: &["__main"], expect_sets: 1, expect_committed: true, + expect_registry_called: true, }, Case { name: "fails when record already exists", db: MockDb::new().with_data("__main", enc_pk.clone(), enc_val.clone()), + registry_fails: false, expect_result: |r| assert!(matches!(r, Err(Error::AlreadyExists(_)))), expect_opens: &["__main"], expect_sets: 0, expect_committed: false, + expect_registry_called: false, }, Case { name: "propagates backend error from write()", db: MockDb::new().with_write_err(|| backend_error("write failed")), + registry_fails: false, expect_result: |r| assert!(matches!(r, Err(Error::Backend(_)))), expect_opens: &[], expect_sets: 0, expect_committed: false, + expect_registry_called: false, }, Case { - // set is called before commit; commit failure must be propagated name: "propagates backend error from commit()", db: MockDb::new().with_commit_err(|| backend_error("commit failed")), + registry_fails: false, expect_result: |r| assert!(matches!(r, Err(Error::Backend(_)))), expect_opens: &["__main"], expect_sets: 1, expect_committed: true, + expect_registry_called: true, + }, + Case { + // set is called before the registry; commit is skipped on registry error + name: "propagates registry error", + db: MockDb::new(), + registry_fails: true, + expect_result: |r| assert!(matches!(r, Err(Error::Unexpected(_)))), + expect_opens: &["__main"], + expect_sets: 1, + expect_committed: false, + expect_registry_called: true, }, ]; for c in cases { - let (result, log) = run!(Nil, c.db); + SpyRegistry::reset(); + SpyRegistry::set_fail(c.registry_fails); + let (result, log) = run!(c.db); let log = log.borrow(); (c.expect_result)(&result); assert_eq!(log.opens.as_slice(), c.expect_opens, "[{}] opens", c.name); assert_eq!(log.sets.len(), c.expect_sets, "[{}] sets count", c.name); assert_eq!(log.committed, c.expect_committed, "[{}] committed", c.name); + assert_eq!(SpyRegistry::was_update_called(), c.expect_registry_called, "[{}] registry called", c.name); } - // Verify set writes the correct key and value bytes - let (result, log) = run!(Nil, MockDb::new()); + // Verify the exact bytes written to the main store + SpyRegistry::reset(); + let (result, log) = run!(MockDb::new()); assert!(result.is_ok()); let log = log.borrow(); assert_eq!(log.sets[0].0, enc_pk, "set key must be the encoded primary key"); assert_eq!(log.sets[0].1, enc_val, "set value must be to_bytes() output"); - - // ── Index dispatch cases ────────────────────────────────────────────── - - struct IndexCase { - name: &'static str, - expected_opens: &'static [&'static str], - expect_ok: bool, - expect_committed: bool, - } - - // Indexes vary at compile time, so we use the macro for each arity. - let (r, log) = run!(Nil, MockDb::new()); - let nil_case = (r, log.borrow().opens.clone(), log.borrow().committed); - - let (r, log) = run!(Cons, MockDb::new()); - let one_index = (r, log.borrow().opens.clone(), log.borrow().committed); - - let (r, log) = run!(Cons>, MockDb::new()); - let two_indexes = (r, log.borrow().opens.clone(), log.borrow().committed); - - // Failing index: set is still called on main store, but commit is skipped - let (r, log) = run!(Cons, MockDb::new()); - let fail_index = (r, log.borrow().opens.clone(), log.borrow().committed, log.borrow().sets.len()); - - let index_cases: &[IndexCase] = &[ - IndexCase { - name: "no indexes: only main store opened", - expected_opens: &["__main"], - expect_ok: true, expect_committed: true, - }, - IndexCase { - name: "one index: main store then index store opened", - expected_opens: &["__main", "index_a"], - expect_ok: true, expect_committed: true, - }, - IndexCase { - name: "two indexes: all stores opened in registry order", - expected_opens: &["__main", "index_a", "index_b"], - expect_ok: true, expect_committed: true, - }, - ]; - - let dispatch_results = [nil_case, one_index, two_indexes]; - for (c, (result, opens, committed)) in index_cases.iter().zip(&dispatch_results) { - assert_eq!(result.is_ok(), c.expect_ok, "[{}] result", c.name); - assert_eq!(opens.as_slice(), c.expected_opens, "[{}] opens", c.name); - assert_eq!(*committed, c.expect_committed, "[{}] committed", c.name); - } - - // Failing index: error propagated, set occurred, commit skipped - let (fail_result, fail_opens, fail_committed, fail_sets) = fail_index; - assert!(matches!(fail_result, Err(Error::Unexpected(_))), "failing index error propagated"); - assert_eq!(fail_opens.as_slice(), &["__main"] as &[&str], "failing index: only main store opened"); - assert_eq!(fail_sets, 1, "failing index: main record was written before index error"); - assert!(!fail_committed, "failing index: commit must not be called"); } + // ── update ──────────────────────────────────────────────────────────────── + #[test] fn update() { let enc_pk = 1u32.encode().to_vec(); let enc_val = TestRecord { id: 1 }.to_bytes().unwrap(); - // Pre-seeds the main store with a valid existing record. let existing_db = || MockDb::new().with_data("__main", enc_pk.clone(), enc_val.clone()); macro_rules! run { - ($indexes:ty, $db:expr) => {{ + ($db:expr) => {{ let db: MockDb = $db; let log = db.log(); - let col = Collection::<_, TestRecord, $indexes>::new("col", db); + let col = Collection::<_, TestRecord, SpyRegistry>::new("col", db); let result = col.update(TestRecord { id: 1 }); (result, log) }}; } - // ── Store-level cases (index type fixed to Nil) ─────────────────────── - struct Case { name: &'static str, db: MockDb, + registry_fails: bool, expect_result: fn(&Result<(), Error>), expect_opens: &'static [&'static str], expect_sets: usize, expect_committed: bool, + expect_registry_called: bool, } let cases = vec![ Case { name: "updates existing record", db: existing_db(), + registry_fails: false, expect_result: |r| assert!(r.is_ok()), expect_opens: &["__main"], expect_sets: 1, expect_committed: true, + expect_registry_called: true, }, Case { name: "fails when record not found", db: MockDb::new(), + registry_fails: false, expect_result: |r| assert!(matches!(r, Err(Error::NotFound(_)))), expect_opens: &["__main"], expect_sets: 0, expect_committed: false, + expect_registry_called: false, }, Case { name: "propagates backend error from write()", db: MockDb::new().with_write_err(|| backend_error("write failed")), + registry_fails: false, expect_result: |r| assert!(matches!(r, Err(Error::Backend(_)))), expect_opens: &[], expect_sets: 0, expect_committed: false, + expect_registry_called: false, }, Case { name: "propagates backend error from commit()", db: existing_db().with_commit_err(|| backend_error("commit failed")), + registry_fails: false, expect_result: |r| assert!(matches!(r, Err(Error::Backend(_)))), expect_opens: &["__main"], expect_sets: 1, expect_committed: true, + expect_registry_called: true, }, Case { // from_bytes is called on the stored value before set — codec errors must surface name: "propagates codec error from corrupted stored bytes", db: MockDb::new().with_data("__main", enc_pk.clone(), vec![0x01]), + registry_fails: false, expect_result: |r| assert!(matches!(r, Err(Error::Codec(_)))), expect_opens: &["__main"], expect_sets: 0, expect_committed: false, + expect_registry_called: false, + }, + Case { + // set is called before the registry; commit is skipped on registry error + name: "propagates registry error", + db: existing_db(), + registry_fails: true, + expect_result: |r| assert!(matches!(r, Err(Error::Unexpected(_)))), + expect_opens: &["__main"], + expect_sets: 1, + expect_committed: false, + expect_registry_called: true, }, ]; for c in cases { - let (result, log) = run!(Nil, c.db); + SpyRegistry::reset(); + SpyRegistry::set_fail(c.registry_fails); + let (result, log) = run!(c.db); let log = log.borrow(); (c.expect_result)(&result); assert_eq!(log.opens.as_slice(), c.expect_opens, "[{}] opens", c.name); assert_eq!(log.sets.len(), c.expect_sets, "[{}] sets count", c.name); assert_eq!(log.committed, c.expect_committed, "[{}] committed", c.name); + assert_eq!(SpyRegistry::was_update_called(), c.expect_registry_called, "[{}] registry called", c.name); } - // Verify set writes the correct key and value bytes - { - let (result, log) = run!(Nil, existing_db()); - assert!(result.is_ok()); - let log = log.borrow(); - assert_eq!(log.sets[0].0, enc_pk, "set key must be the encoded primary key"); - assert_eq!(log.sets[0].1, enc_val, "set value must be to_bytes() output"); - } - - // ── Index dispatch ──────────────────────────────────────────────────── - - { - let (r, log) = run!(Nil, existing_db()); - let log = log.borrow(); - assert!(r.is_ok(), "no indexes: result"); - assert_eq!(log.opens, vec!["__main"], "no indexes: opens"); - assert!(log.committed, "no indexes: committed"); - } - { - let (r, log) = run!(Cons, existing_db()); - let log = log.borrow(); - assert!(r.is_ok(), "one index: result"); - assert_eq!(log.opens, vec!["__main", "index_a"], "one index: opens"); - assert!(log.committed, "one index: committed"); - } - { - let (r, log) = run!(Cons>, existing_db()); - let log = log.borrow(); - assert!(r.is_ok(), "two indexes: result"); - assert_eq!(log.opens, vec!["__main", "index_a", "index_b"], "two indexes: opens"); - assert!(log.committed, "two indexes: committed"); - } - { - // Failing index: set happened on main store, but commit is skipped - let (r, log) = run!(Cons, existing_db()); - let log = log.borrow(); - assert!(matches!(r, Err(Error::Unexpected(_))), "failing index: error propagated"); - assert_eq!(log.opens, vec!["__main"], "failing index: only main store opened"); - assert_eq!(log.sets.len(), 1, "failing index: main record written before index error"); - assert!(!log.committed, "failing index: commit must not be called"); - } + // Verify the exact bytes written to the main store + SpyRegistry::reset(); + let (result, log) = run!(existing_db()); + assert!(result.is_ok()); + let log = log.borrow(); + assert_eq!(log.sets[0].0, enc_pk, "set key must be the encoded primary key"); + assert_eq!(log.sets[0].1, enc_val, "set value must be to_bytes() output"); } } + diff --git a/src/testing.rs b/src/testing.rs index 6e2b0ce..1621fcd 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -4,11 +4,13 @@ //! Every operation performed through a [`MockDb`] is recorded in a shared //! [`TxLog`] that callers can inspect after the fact. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::rc::Rc; -use crate::error::BackendError; +use crate::entity::Entity; +use crate::error::{BackendError, Error}; +use crate::index_registry::IndexRegistry; use crate::scan::{Direction, ScanRange}; use crate::store::{ MultiStore, MultiStoreReadHandle, MultiStoreWriteHandle, ReadKVStore, ReadWriteKVStore, @@ -248,3 +250,75 @@ impl MultiStore for MockDb { }) } } + +// ── SpyRegistry ─────────────────────────────────────────────────────────────── + +thread_local! { + static REGISTRY_UPDATE_CALLED: Cell = Cell::new(false); + static REGISTRY_REMOVE_CALLED: Cell = Cell::new(false); + static REGISTRY_SHOULD_FAIL: Cell = Cell::new(false); +} + +/// A mock [`IndexRegistry`] backed by thread-local flags. +/// +/// Each test thread starts with a clean slate. Call [`SpyRegistry::reset`] +/// between successive uses within the same test function. +pub struct SpyRegistry; + +impl SpyRegistry { + /// Resets all flags to their initial state. + pub fn reset() { + REGISTRY_UPDATE_CALLED.with(|c| c.set(false)); + REGISTRY_REMOVE_CALLED.with(|c| c.set(false)); + REGISTRY_SHOULD_FAIL.with(|c| c.set(false)); + } + + /// When set to `true`, the next `update` or `remove` call returns + /// `Err(Error::Unexpected(...))`. + pub fn set_fail(fail: bool) { + REGISTRY_SHOULD_FAIL.with(|c| c.set(fail)); + } + + /// Returns `true` if `update` was called since the last [`reset`]. + pub fn was_update_called() -> bool { + REGISTRY_UPDATE_CALLED.with(|c| c.get()) + } + + /// Returns `true` if `remove` was called since the last [`reset`]. + pub fn was_remove_called() -> bool { + REGISTRY_REMOVE_CALLED.with(|c| c.get()) + } + + fn fail_if_needed() -> Result<(), Error> { + if REGISTRY_SHOULD_FAIL.with(|c| c.get()) { + Err(Error::Unexpected("injected registry error".into())) + } else { + Ok(()) + } + } +} + +impl IndexRegistry for SpyRegistry { + fn update<'a, DB: MultiStoreWriteHandle>( + _db: &mut DB, + _pk: &T::Key<'a>, + _old: Option<&T>, + _new: &'a T, + ) -> Result<(), Error> { + REGISTRY_UPDATE_CALLED.with(|c| c.set(true)); + Self::fail_if_needed() + } + + fn remove<'a, DB: MultiStoreWriteHandle>( + _db: &mut DB, + _pk: &T::Key<'a>, + _item: &'a T, + ) -> Result<(), Error> { + REGISTRY_REMOVE_CALLED.with(|c| c.set(true)); + Self::fail_if_needed() + } + + fn has_index(_name: &str) -> bool { + false + } +} From 51e35440c945f28e79159745a72c840c4afee018 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:31:58 +0200 Subject: [PATCH 27/31] test: collection::save --- src/collection.rs | 118 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/src/collection.rs b/src/collection.rs index 0dc87d5..71f9d7b 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -503,5 +503,123 @@ mod tests { assert_eq!(log.sets[0].0, enc_pk, "set key must be the encoded primary key"); assert_eq!(log.sets[0].1, enc_val, "set value must be to_bytes() output"); } + + // ── save ────────────────────────────────────────────────────────────────── + + #[test] + fn save() { + let enc_pk = 1u32.encode().to_vec(); + let enc_val = TestRecord { id: 1 }.to_bytes().unwrap(); + + let existing_db = + || MockDb::new().with_data("__main", enc_pk.clone(), enc_val.clone()); + + macro_rules! run { + ($db:expr) => {{ + let db: MockDb = $db; + let log = db.log(); + let col = Collection::<_, TestRecord, SpyRegistry>::new("col", db); + let result = col.save(TestRecord { id: 1 }); + (result, log) + }}; + } + + struct Case { + name: &'static str, + db: MockDb, + registry_fails: bool, + expect_result: fn(&Result<(), Error>), + expect_opens: &'static [&'static str], + expect_sets: usize, + expect_committed: bool, + expect_registry_called: bool, + } + + let cases = vec![ + Case { + name: "save when record does not exist", + db: MockDb::new(), + registry_fails: false, + expect_result: |r| assert!(r.is_ok()), + expect_opens: &["__main"], + expect_sets: 1, + expect_committed: true, + expect_registry_called: true, + }, + Case { + name: "overwrites when record already exists", + db: existing_db(), + registry_fails: false, + expect_result: |r| assert!(r.is_ok()), + expect_opens: &["__main"], + expect_sets: 1, + expect_committed: true, + expect_registry_called: true, + }, + Case { + name: "propagates backend error from write()", + db: MockDb::new().with_write_err(|| backend_error("write failed")), + registry_fails: false, + expect_result: |r| assert!(matches!(r, Err(Error::Backend(_)))), + expect_opens: &[], + expect_sets: 0, + expect_committed: false, + expect_registry_called: false, + }, + Case { + name: "propagates backend error from commit()", + db: existing_db().with_commit_err(|| backend_error("commit failed")), + registry_fails: false, + expect_result: |r| assert!(matches!(r, Err(Error::Backend(_)))), + expect_opens: &["__main"], + expect_sets: 1, + expect_committed: true, + expect_registry_called: true, + }, + Case { + // from_bytes is called on any stored value before set — codec errors must surface + name: "propagates codec error from corrupted stored bytes", + db: MockDb::new().with_data("__main", enc_pk.clone(), vec![0x01]), + registry_fails: false, + expect_result: |r| assert!(matches!(r, Err(Error::Codec(_)))), + expect_opens: &["__main"], + expect_sets: 0, + expect_committed: false, + expect_registry_called: false, + }, + Case { + // set is called before the registry; commit is skipped on registry error + name: "propagates registry error", + db: existing_db(), + registry_fails: true, + expect_result: |r| assert!(matches!(r, Err(Error::Unexpected(_)))), + expect_opens: &["__main"], + expect_sets: 1, + expect_committed: false, + expect_registry_called: true, + }, + ]; + + for c in cases { + SpyRegistry::reset(); + SpyRegistry::set_fail(c.registry_fails); + let (result, log) = run!(c.db); + let log = log.borrow(); + + (c.expect_result)(&result); + assert_eq!(log.opens.as_slice(), c.expect_opens, "[{}] opens", c.name); + assert_eq!(log.sets.len(), c.expect_sets, "[{}] sets count", c.name); + assert_eq!(log.committed, c.expect_committed, "[{}] committed", c.name); + assert_eq!(SpyRegistry::was_update_called(), c.expect_registry_called, "[{}] registry called", c.name); + } + + // Verify the exact bytes written to the main store + SpyRegistry::reset(); + let (result, log) = run!(MockDb::new()); + assert!(result.is_ok()); + let log = log.borrow(); + assert_eq!(log.sets[0].0, enc_pk, "set key must be the encoded primary key"); + assert_eq!(log.sets[0].1, enc_val, "set value must be to_bytes() output"); + } } From 981fa496adcc48de6f025d7e2a561622e6b08f1d Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:34:05 +0200 Subject: [PATCH 28/31] test: collection::remove --- src/collection.rs | 118 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/src/collection.rs b/src/collection.rs index 71f9d7b..fe4c852 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -621,5 +621,123 @@ mod tests { assert_eq!(log.sets[0].0, enc_pk, "set key must be the encoded primary key"); assert_eq!(log.sets[0].1, enc_val, "set value must be to_bytes() output"); } + + // ── remove ──────────────────────────────────────────────────────────────── + + #[test] + fn remove() { + let enc_pk = 1u32.encode().to_vec(); + let enc_val = TestRecord { id: 1 }.to_bytes().unwrap(); + + let existing_db = + || MockDb::new().with_data("__main", enc_pk.clone(), enc_val.clone()); + + macro_rules! run { + ($db:expr) => {{ + let db: MockDb = $db; + let log = db.log(); + let col = Collection::<_, TestRecord, SpyRegistry>::new("col", db); + let result = col.remove(1u32); + (result, log) + }}; + } + + struct Case { + name: &'static str, + db: MockDb, + registry_fails: bool, + expect_result: fn(&Result<(), Error>), + expect_opens: &'static [&'static str], + expect_removes: usize, + expect_committed: bool, + expect_registry_called: bool, + } + + let cases = vec![ + Case { + name: "removes existing record", + db: existing_db(), + registry_fails: false, + expect_result: |r| assert!(r.is_ok()), + expect_opens: &["__main"], + expect_removes: 1, + expect_committed: true, + expect_registry_called: true, + }, + Case { + // record absent → early Ok(()), no write to store, no registry + name: "returns ok when record does not exist", + db: MockDb::new(), + registry_fails: false, + expect_result: |r| assert!(r.is_ok()), + expect_opens: &["__main"], + expect_removes: 0, + expect_committed: false, + expect_registry_called: false, + }, + Case { + name: "propagates backend error from write()", + db: MockDb::new().with_write_err(|| backend_error("write failed")), + registry_fails: false, + expect_result: |r| assert!(matches!(r, Err(Error::Backend(_)))), + expect_opens: &[], + expect_removes: 0, + expect_committed: false, + expect_registry_called: false, + }, + Case { + name: "propagates backend error from commit()", + db: existing_db().with_commit_err(|| backend_error("commit failed")), + registry_fails: false, + expect_result: |r| assert!(matches!(r, Err(Error::Backend(_)))), + expect_opens: &["__main"], + expect_removes: 1, + expect_committed: true, + expect_registry_called: true, + }, + Case { + // from_bytes is called on any stored value before remove — codec errors must surface + name: "propagates codec error from corrupted stored bytes", + db: MockDb::new().with_data("__main", enc_pk.clone(), vec![0x01]), + registry_fails: false, + expect_result: |r| assert!(matches!(r, Err(Error::Codec(_)))), + expect_opens: &["__main"], + expect_removes: 0, + expect_committed: false, + expect_registry_called: false, + }, + Case { + // remove is called before the registry; commit is skipped on registry error + name: "propagates registry error", + db: existing_db(), + registry_fails: true, + expect_result: |r| assert!(matches!(r, Err(Error::Unexpected(_)))), + expect_opens: &["__main"], + expect_removes: 1, + expect_committed: false, + expect_registry_called: true, + }, + ]; + + for c in cases { + SpyRegistry::reset(); + SpyRegistry::set_fail(c.registry_fails); + let (result, log) = run!(c.db); + let log = log.borrow(); + + (c.expect_result)(&result); + assert_eq!(log.opens.as_slice(), c.expect_opens, "[{}] opens", c.name); + assert_eq!(log.removes.len(), c.expect_removes, "[{}] removes count", c.name); + assert_eq!(log.committed, c.expect_committed, "[{}] committed", c.name); + assert_eq!(SpyRegistry::was_remove_called(), c.expect_registry_called, "[{}] registry called", c.name); + } + + // Verify the exact key passed to store.remove() + SpyRegistry::reset(); + let (result, log) = run!(existing_db()); + assert!(result.is_ok()); + let log = log.borrow(); + assert_eq!(log.removes[0], enc_pk, "remove key must be the encoded primary key"); + } } From 2696c40146638a9c94de768b009fb14213ac9636 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:35:29 +0200 Subject: [PATCH 29/31] test: collection::get --- src/collection.rs | 71 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/collection.rs b/src/collection.rs index fe4c852..ac54a11 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -739,5 +739,76 @@ mod tests { let log = log.borrow(); assert_eq!(log.removes[0], enc_pk, "remove key must be the encoded primary key"); } + + // ── get ─────────────────────────────────────────────────────────────────── + + #[test] + fn get() { + let enc_pk = 1u32.encode().to_vec(); + let enc_val = TestRecord { id: 1 }.to_bytes().unwrap(); + + let existing_db = + || MockDb::new().with_data("__main", enc_pk.clone(), enc_val.clone()); + + macro_rules! run { + ($db:expr) => {{ + let db: MockDb = $db; + let log = db.log(); + let col = Collection::<_, TestRecord, SpyRegistry>::new("col", db); + let result = col.get(1u32); + (result, log) + }}; + } + + struct Case { + name: &'static str, + db: MockDb, + expect_result: fn(&Result, Error>), + expect_opens: &'static [&'static str], + } + + let cases = vec![ + Case { + name: "returns the record when it exists", + db: existing_db(), + expect_result: |r| { + let record = r.as_ref().unwrap().as_ref().unwrap(); + assert_eq!(record.id, 1); + }, + expect_opens: &["__main"], + }, + Case { + name: "returns None when record does not exist", + db: MockDb::new(), + expect_result: |r| assert!(matches!(r, Ok(None))), + expect_opens: &["__main"], + }, + Case { + name: "propagates backend error from read()", + db: MockDb::new().with_read_err(|| backend_error("read failed")), + expect_result: |r| assert!(matches!(r, Err(Error::Backend(_)))), + expect_opens: &[], + }, + Case { + name: "propagates codec error from corrupted stored bytes", + db: MockDb::new().with_data("__main", enc_pk.clone(), vec![0x01]), + expect_result: |r| assert!(matches!(r, Err(Error::Codec(_)))), + expect_opens: &["__main"], + }, + ]; + + for c in cases { + let (result, log) = run!(c.db); + let log = log.borrow(); + + (c.expect_result)(&result); + assert_eq!(log.opens.as_slice(), c.expect_opens, "[{}] opens", c.name); + } + + // Verify the exact key passed to store.get() + let (_, log) = run!(existing_db()); + let log = log.borrow(); + assert_eq!(log.gets[0], enc_pk, "get key must be the encoded primary key"); + } } From fcea3d1be6b994ecae752d8fab888c0eb6ed5669 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:36:19 +0200 Subject: [PATCH 30/31] style: some lookmaxxing --- src/collection.rs | 90 +++++++++++++++++++++++++++++++++++------------ src/key.rs | 2 +- 2 files changed, 68 insertions(+), 24 deletions(-) diff --git a/src/collection.rs b/src/collection.rs index ac54a11..83892be 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -253,7 +253,7 @@ mod tests { use crate::entity::Entity; use crate::error::{CodecError, Error}; use crate::key::Key; - use crate::testing::{MockDb, SpyRegistry, backend_error}; + use crate::testing::{backend_error, MockDb, SpyRegistry}; // ── Minimal entity ──────────────────────────────────────────────────────── @@ -374,7 +374,12 @@ mod tests { assert_eq!(log.opens.as_slice(), c.expect_opens, "[{}] opens", c.name); assert_eq!(log.sets.len(), c.expect_sets, "[{}] sets count", c.name); assert_eq!(log.committed, c.expect_committed, "[{}] committed", c.name); - assert_eq!(SpyRegistry::was_update_called(), c.expect_registry_called, "[{}] registry called", c.name); + assert_eq!( + SpyRegistry::was_update_called(), + c.expect_registry_called, + "[{}] registry called", + c.name + ); } // Verify the exact bytes written to the main store @@ -382,8 +387,14 @@ mod tests { let (result, log) = run!(MockDb::new()); assert!(result.is_ok()); let log = log.borrow(); - assert_eq!(log.sets[0].0, enc_pk, "set key must be the encoded primary key"); - assert_eq!(log.sets[0].1, enc_val, "set value must be to_bytes() output"); + assert_eq!( + log.sets[0].0, enc_pk, + "set key must be the encoded primary key" + ); + assert_eq!( + log.sets[0].1, enc_val, + "set value must be to_bytes() output" + ); } // ── update ──────────────────────────────────────────────────────────────── @@ -393,8 +404,7 @@ mod tests { let enc_pk = 1u32.encode().to_vec(); let enc_val = TestRecord { id: 1 }.to_bytes().unwrap(); - let existing_db = - || MockDb::new().with_data("__main", enc_pk.clone(), enc_val.clone()); + let existing_db = || MockDb::new().with_data("__main", enc_pk.clone(), enc_val.clone()); macro_rules! run { ($db:expr) => {{ @@ -492,7 +502,12 @@ mod tests { assert_eq!(log.opens.as_slice(), c.expect_opens, "[{}] opens", c.name); assert_eq!(log.sets.len(), c.expect_sets, "[{}] sets count", c.name); assert_eq!(log.committed, c.expect_committed, "[{}] committed", c.name); - assert_eq!(SpyRegistry::was_update_called(), c.expect_registry_called, "[{}] registry called", c.name); + assert_eq!( + SpyRegistry::was_update_called(), + c.expect_registry_called, + "[{}] registry called", + c.name + ); } // Verify the exact bytes written to the main store @@ -500,8 +515,14 @@ mod tests { let (result, log) = run!(existing_db()); assert!(result.is_ok()); let log = log.borrow(); - assert_eq!(log.sets[0].0, enc_pk, "set key must be the encoded primary key"); - assert_eq!(log.sets[0].1, enc_val, "set value must be to_bytes() output"); + assert_eq!( + log.sets[0].0, enc_pk, + "set key must be the encoded primary key" + ); + assert_eq!( + log.sets[0].1, enc_val, + "set value must be to_bytes() output" + ); } // ── save ────────────────────────────────────────────────────────────────── @@ -511,8 +532,7 @@ mod tests { let enc_pk = 1u32.encode().to_vec(); let enc_val = TestRecord { id: 1 }.to_bytes().unwrap(); - let existing_db = - || MockDb::new().with_data("__main", enc_pk.clone(), enc_val.clone()); + let existing_db = || MockDb::new().with_data("__main", enc_pk.clone(), enc_val.clone()); macro_rules! run { ($db:expr) => {{ @@ -610,7 +630,12 @@ mod tests { assert_eq!(log.opens.as_slice(), c.expect_opens, "[{}] opens", c.name); assert_eq!(log.sets.len(), c.expect_sets, "[{}] sets count", c.name); assert_eq!(log.committed, c.expect_committed, "[{}] committed", c.name); - assert_eq!(SpyRegistry::was_update_called(), c.expect_registry_called, "[{}] registry called", c.name); + assert_eq!( + SpyRegistry::was_update_called(), + c.expect_registry_called, + "[{}] registry called", + c.name + ); } // Verify the exact bytes written to the main store @@ -618,8 +643,14 @@ mod tests { let (result, log) = run!(MockDb::new()); assert!(result.is_ok()); let log = log.borrow(); - assert_eq!(log.sets[0].0, enc_pk, "set key must be the encoded primary key"); - assert_eq!(log.sets[0].1, enc_val, "set value must be to_bytes() output"); + assert_eq!( + log.sets[0].0, enc_pk, + "set key must be the encoded primary key" + ); + assert_eq!( + log.sets[0].1, enc_val, + "set value must be to_bytes() output" + ); } // ── remove ──────────────────────────────────────────────────────────────── @@ -629,8 +660,7 @@ mod tests { let enc_pk = 1u32.encode().to_vec(); let enc_val = TestRecord { id: 1 }.to_bytes().unwrap(); - let existing_db = - || MockDb::new().with_data("__main", enc_pk.clone(), enc_val.clone()); + let existing_db = || MockDb::new().with_data("__main", enc_pk.clone(), enc_val.clone()); macro_rules! run { ($db:expr) => {{ @@ -727,9 +757,19 @@ mod tests { (c.expect_result)(&result); assert_eq!(log.opens.as_slice(), c.expect_opens, "[{}] opens", c.name); - assert_eq!(log.removes.len(), c.expect_removes, "[{}] removes count", c.name); + assert_eq!( + log.removes.len(), + c.expect_removes, + "[{}] removes count", + c.name + ); assert_eq!(log.committed, c.expect_committed, "[{}] committed", c.name); - assert_eq!(SpyRegistry::was_remove_called(), c.expect_registry_called, "[{}] registry called", c.name); + assert_eq!( + SpyRegistry::was_remove_called(), + c.expect_registry_called, + "[{}] registry called", + c.name + ); } // Verify the exact key passed to store.remove() @@ -737,7 +777,10 @@ mod tests { let (result, log) = run!(existing_db()); assert!(result.is_ok()); let log = log.borrow(); - assert_eq!(log.removes[0], enc_pk, "remove key must be the encoded primary key"); + assert_eq!( + log.removes[0], enc_pk, + "remove key must be the encoded primary key" + ); } // ── get ─────────────────────────────────────────────────────────────────── @@ -747,8 +790,7 @@ mod tests { let enc_pk = 1u32.encode().to_vec(); let enc_val = TestRecord { id: 1 }.to_bytes().unwrap(); - let existing_db = - || MockDb::new().with_data("__main", enc_pk.clone(), enc_val.clone()); + let existing_db = || MockDb::new().with_data("__main", enc_pk.clone(), enc_val.clone()); macro_rules! run { ($db:expr) => {{ @@ -808,7 +850,9 @@ mod tests { // Verify the exact key passed to store.get() let (_, log) = run!(existing_db()); let log = log.borrow(); - assert_eq!(log.gets[0], enc_pk, "get key must be the encoded primary key"); + assert_eq!( + log.gets[0], enc_pk, + "get key must be the encoded primary key" + ); } } - diff --git a/src/key.rs b/src/key.rs index f8d7400..6fd92f1 100644 --- a/src/key.rs +++ b/src/key.rs @@ -1,6 +1,6 @@ -use std::fmt::Debug; use crate::inline_vec::IVec; use crate::{impl_signed_integer_key, impl_unsigned_integer_key}; +use std::fmt::Debug; /// A value that can be encoded as an ordered key for Colette stores and indexes. /// From 1a1ecd966aeb4a46ec5d1361fa69646caa0f11b9 Mon Sep 17 00:00:00 2001 From: Arnaud Mimart <33665250+amimart@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:39:54 +0200 Subject: [PATCH 31/31] style: make linters happy --- src/error.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/error.rs b/src/error.rs index d2ee3aa..c496d4e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -22,6 +22,7 @@ pub enum Error { pub struct BackendError(Box); impl BackendError { + #[cfg(test)] pub(crate) fn new(e: impl std::error::Error + Send + Sync + 'static) -> Self { BackendError(Box::new(e)) } @@ -37,6 +38,7 @@ impl Display for BackendError { pub struct CodecError(Box); impl CodecError { + #[cfg(test)] pub(crate) fn new(e: impl std::error::Error + Send + Sync + 'static) -> Self { CodecError(Box::new(e)) }