Skip to main content

ipfrs_storage/
object_store.rs

1//! High-level object store abstraction over raw block storage.
2//!
3//! Provides named object management, versioning, and metadata-rich retrieval.
4
5use std::collections::HashMap;
6
7/// A single version of a stored object.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct ObjectVersion {
10    /// Monotonically increasing version number, starts at 1.
11    pub version: u32,
12    /// Content identifier for this version.
13    pub cid: String,
14    /// Size of this version in bytes.
15    pub size_bytes: u64,
16    /// Unix timestamp (seconds) when this version was created.
17    pub created_at_secs: u64,
18    /// Who created this version.
19    pub author: String,
20    /// Version commit message.
21    pub message: String,
22}
23
24/// A named, versioned object stored in the object store.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct StoredObject {
27    /// Unique numeric identifier.
28    pub object_id: u64,
29    /// Human-readable name (unique within namespace).
30    pub name: String,
31    /// Namespace this object belongs to.
32    pub namespace: String,
33    /// All versions, ordered oldest (index 0) to newest (last index).
34    pub versions: Vec<ObjectVersion>,
35    /// Arbitrary tags attached to this object.
36    pub tags: Vec<String>,
37    /// Whether this object is pinned (prevents deletion).
38    pub pinned: bool,
39}
40
41impl StoredObject {
42    /// Returns the most recent version, or `None` if there are no versions.
43    pub fn current_version(&self) -> Option<&ObjectVersion> {
44        self.versions.last()
45    }
46
47    /// Returns the total number of versions.
48    pub fn version_count(&self) -> usize {
49        self.versions.len()
50    }
51
52    /// Returns the sum of `size_bytes` across all versions.
53    pub fn total_size_bytes(&self) -> u64 {
54        self.versions.iter().map(|v| v.size_bytes).sum()
55    }
56
57    /// Finds a version by its version number, or `None` if not present.
58    pub fn find_version(&self, v: u32) -> Option<&ObjectVersion> {
59        self.versions.iter().find(|ov| ov.version == v)
60    }
61}
62
63/// Aggregate statistics for a [`StorageObjectStore`].
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ObjectStoreStats {
66    /// Total number of objects.
67    pub total_objects: usize,
68    /// Total number of versions across all objects.
69    pub total_versions: usize,
70    /// Total bytes across all versions of all objects.
71    pub total_size_bytes: u64,
72    /// Number of pinned objects.
73    pub pinned_objects: usize,
74    /// Number of unique namespaces.
75    pub namespaces: usize,
76}
77
78/// High-level object store providing named object management, versioning, and
79/// metadata-rich retrieval on top of raw block storage.
80#[derive(Debug, Default)]
81pub struct StorageObjectStore {
82    /// Objects keyed by their numeric id.
83    pub objects: HashMap<u64, StoredObject>,
84    /// Index from `(namespace, name)` to `object_id`.
85    pub name_index: HashMap<(String, String), u64>,
86    /// Counter used to assign the next object id.
87    pub next_object_id: u64,
88}
89
90impl StorageObjectStore {
91    /// Creates a new, empty object store.
92    pub fn new() -> Self {
93        Self {
94            objects: HashMap::new(),
95            name_index: HashMap::new(),
96            next_object_id: 1,
97        }
98    }
99
100    /// Creates a new object with version 1 and returns its `object_id`.
101    ///
102    /// If an object with the same `(namespace, name)` already exists the existing
103    /// object id is returned without creating a duplicate entry.
104    #[allow(clippy::too_many_arguments)]
105    pub fn create(
106        &mut self,
107        name: &str,
108        namespace: &str,
109        cid: &str,
110        size_bytes: u64,
111        author: &str,
112        message: &str,
113        created_at_secs: u64,
114    ) -> u64 {
115        let key = (namespace.to_owned(), name.to_owned());
116        if let Some(&existing_id) = self.name_index.get(&key) {
117            return existing_id;
118        }
119
120        let object_id = self.next_object_id;
121        self.next_object_id += 1;
122
123        let version = ObjectVersion {
124            version: 1,
125            cid: cid.to_owned(),
126            size_bytes,
127            created_at_secs,
128            author: author.to_owned(),
129            message: message.to_owned(),
130        };
131
132        let stored = StoredObject {
133            object_id,
134            name: name.to_owned(),
135            namespace: namespace.to_owned(),
136            versions: vec![version],
137            tags: Vec::new(),
138            pinned: false,
139        };
140
141        self.objects.insert(object_id, stored);
142        self.name_index.insert(key, object_id);
143        object_id
144    }
145
146    /// Appends a new version to an existing object.
147    ///
148    /// The new version number is `previous_max_version + 1`.
149    /// Returns `false` if the object is not found.
150    pub fn add_version(
151        &mut self,
152        object_id: u64,
153        cid: &str,
154        size_bytes: u64,
155        author: &str,
156        message: &str,
157        created_at_secs: u64,
158    ) -> bool {
159        let Some(obj) = self.objects.get_mut(&object_id) else {
160            return false;
161        };
162
163        let next_version = obj.versions.iter().map(|v| v.version).max().unwrap_or(0) + 1;
164
165        obj.versions.push(ObjectVersion {
166            version: next_version,
167            cid: cid.to_owned(),
168            size_bytes,
169            created_at_secs,
170            author: author.to_owned(),
171            message: message.to_owned(),
172        });
173
174        true
175    }
176
177    /// Retrieves an object by its `(namespace, name)` pair.
178    pub fn get_by_name(&self, namespace: &str, name: &str) -> Option<&StoredObject> {
179        let key = (namespace.to_owned(), name.to_owned());
180        let id = self.name_index.get(&key)?;
181        self.objects.get(id)
182    }
183
184    /// Retrieves an object by its numeric id.
185    pub fn get(&self, object_id: u64) -> Option<&StoredObject> {
186        self.objects.get(&object_id)
187    }
188
189    /// Deletes an object by id.
190    ///
191    /// Returns `false` when the object is not found **or** when it is pinned.
192    pub fn delete(&mut self, object_id: u64) -> bool {
193        let Some(obj) = self.objects.get(&object_id) else {
194            return false;
195        };
196
197        if obj.pinned {
198            return false;
199        }
200
201        let key = (obj.namespace.clone(), obj.name.clone());
202        self.objects.remove(&object_id);
203        self.name_index.remove(&key);
204        true
205    }
206
207    /// Pins an object, preventing it from being deleted.
208    ///
209    /// Returns `false` if the object is not found.
210    pub fn pin(&mut self, object_id: u64) -> bool {
211        let Some(obj) = self.objects.get_mut(&object_id) else {
212            return false;
213        };
214        obj.pinned = true;
215        true
216    }
217
218    /// Unpins an object, allowing it to be deleted.
219    ///
220    /// Returns `false` if the object is not found.
221    pub fn unpin(&mut self, object_id: u64) -> bool {
222        let Some(obj) = self.objects.get_mut(&object_id) else {
223            return false;
224        };
225        obj.pinned = false;
226        true
227    }
228
229    /// Adds a tag to an object (idempotent — duplicate tags are silently ignored).
230    ///
231    /// Returns `false` if the object is not found.
232    pub fn add_tag(&mut self, object_id: u64, tag: &str) -> bool {
233        let Some(obj) = self.objects.get_mut(&object_id) else {
234            return false;
235        };
236        if !obj.tags.iter().any(|t| t == tag) {
237            obj.tags.push(tag.to_owned());
238        }
239        true
240    }
241
242    /// Returns all objects in the given namespace, sorted ascending by name.
243    pub fn objects_in_namespace(&self, namespace: &str) -> Vec<&StoredObject> {
244        let mut result: Vec<&StoredObject> = self
245            .objects
246            .values()
247            .filter(|o| o.namespace == namespace)
248            .collect();
249        result.sort_by(|a, b| a.name.cmp(&b.name));
250        result
251    }
252
253    /// Returns all objects that carry the given tag, sorted ascending by `object_id`.
254    pub fn objects_with_tag(&self, tag: &str) -> Vec<&StoredObject> {
255        let mut result: Vec<&StoredObject> = self
256            .objects
257            .values()
258            .filter(|o| o.tags.iter().any(|t| t == tag))
259            .collect();
260        result.sort_by_key(|o| o.object_id);
261        result
262    }
263
264    /// Computes aggregate statistics for the entire store.
265    pub fn stats(&self) -> ObjectStoreStats {
266        let total_objects = self.objects.len();
267        let total_versions = self.objects.values().map(|o| o.versions.len()).sum();
268        let total_size_bytes = self.objects.values().map(|o| o.total_size_bytes()).sum();
269        let pinned_objects = self.objects.values().filter(|o| o.pinned).count();
270        let namespaces = self
271            .objects
272            .values()
273            .map(|o| o.namespace.as_str())
274            .collect::<std::collections::HashSet<_>>()
275            .len();
276
277        ObjectStoreStats {
278            total_objects,
279            total_versions,
280            total_size_bytes,
281            pinned_objects,
282            namespaces,
283        }
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    fn make_store() -> StorageObjectStore {
292        StorageObjectStore::new()
293    }
294
295    // ── Helpers ──────────────────────────────────────────────────────────────
296
297    fn create_simple(store: &mut StorageObjectStore, name: &str, namespace: &str) -> u64 {
298        store.create(name, namespace, "cid-abc", 100, "alice", "initial", 1000)
299    }
300
301    // ── Tests ─────────────────────────────────────────────────────────────────
302
303    #[test]
304    fn test_new_starts_empty() {
305        let store = make_store();
306        assert!(store.objects.is_empty());
307        assert!(store.name_index.is_empty());
308        assert_eq!(store.next_object_id, 1);
309    }
310
311    #[test]
312    fn test_create_stores_object() {
313        let mut store = make_store();
314        let id = create_simple(&mut store, "obj1", "ns1");
315        let obj = store.get(id).expect("object should exist");
316        assert_eq!(obj.name, "obj1");
317        assert_eq!(obj.namespace, "ns1");
318        assert_eq!(obj.versions.len(), 1);
319        assert_eq!(obj.versions[0].version, 1);
320        assert_eq!(obj.versions[0].cid, "cid-abc");
321    }
322
323    #[test]
324    fn test_create_returns_incrementing_ids() {
325        let mut store = make_store();
326        let id1 = create_simple(&mut store, "a", "ns");
327        let id2 = create_simple(&mut store, "b", "ns");
328        let id3 = create_simple(&mut store, "c", "ns");
329        assert!(id1 < id2);
330        assert!(id2 < id3);
331    }
332
333    #[test]
334    fn test_create_registers_in_name_index() {
335        let mut store = make_store();
336        let id = create_simple(&mut store, "myobj", "mynamespace");
337        let indexed_id = store
338            .name_index
339            .get(&("mynamespace".to_owned(), "myobj".to_owned()))
340            .copied();
341        assert_eq!(indexed_id, Some(id));
342    }
343
344    #[test]
345    fn test_create_idempotent_same_namespace_name() {
346        let mut store = make_store();
347        let id1 = create_simple(&mut store, "dup", "ns");
348        let id2 = create_simple(&mut store, "dup", "ns");
349        assert_eq!(id1, id2);
350        assert_eq!(store.objects.len(), 1);
351    }
352
353    #[test]
354    fn test_add_version_appends_with_correct_version_number() {
355        let mut store = make_store();
356        let id = create_simple(&mut store, "v", "ns");
357        let ok = store.add_version(id, "cid-v2", 200, "bob", "second", 2000);
358        assert!(ok);
359        let obj = store.get(id).expect("object should exist");
360        assert_eq!(obj.versions.len(), 2);
361        assert_eq!(obj.versions[1].version, 2);
362        assert_eq!(obj.versions[1].cid, "cid-v2");
363
364        let ok2 = store.add_version(id, "cid-v3", 300, "carol", "third", 3000);
365        assert!(ok2);
366        assert_eq!(store.get(id).unwrap().versions[2].version, 3);
367    }
368
369    #[test]
370    fn test_add_version_false_for_unknown_object_id() {
371        let mut store = make_store();
372        let ok = store.add_version(9999, "cid", 100, "x", "msg", 0);
373        assert!(!ok);
374    }
375
376    #[test]
377    fn test_current_version_returns_last_version() {
378        let mut store = make_store();
379        let id = create_simple(&mut store, "cv", "ns");
380        store.add_version(id, "cid-v2", 200, "bob", "second", 2000);
381        let obj = store.get(id).unwrap();
382        let cv = obj.current_version().expect("should have current version");
383        assert_eq!(cv.version, 2);
384        assert_eq!(cv.cid, "cid-v2");
385    }
386
387    #[test]
388    fn test_current_version_none_when_no_versions() {
389        // Construct a StoredObject with no versions manually.
390        let obj = StoredObject {
391            object_id: 1,
392            name: "empty".to_owned(),
393            namespace: "ns".to_owned(),
394            versions: vec![],
395            tags: vec![],
396            pinned: false,
397        };
398        assert!(obj.current_version().is_none());
399    }
400
401    #[test]
402    fn test_find_version_by_number() {
403        let mut store = make_store();
404        let id = create_simple(&mut store, "fv", "ns");
405        store.add_version(id, "cid-v2", 200, "bob", "second", 2000);
406        store.add_version(id, "cid-v3", 300, "carol", "third", 3000);
407        let obj = store.get(id).unwrap();
408        let v2 = obj.find_version(2).expect("version 2 should exist");
409        assert_eq!(v2.cid, "cid-v2");
410        assert!(obj.find_version(99).is_none());
411    }
412
413    #[test]
414    fn test_version_count_correct() {
415        let mut store = make_store();
416        let id = create_simple(&mut store, "vc", "ns");
417        assert_eq!(store.get(id).unwrap().version_count(), 1);
418        store.add_version(id, "cid-v2", 200, "x", "m", 0);
419        assert_eq!(store.get(id).unwrap().version_count(), 2);
420    }
421
422    #[test]
423    fn test_get_by_name_some() {
424        let mut store = make_store();
425        let id = create_simple(&mut store, "named", "space");
426        let obj = store.get_by_name("space", "named");
427        assert!(obj.is_some());
428        assert_eq!(obj.unwrap().object_id, id);
429    }
430
431    #[test]
432    fn test_get_by_name_none() {
433        let store = make_store();
434        assert!(store.get_by_name("nonexistent", "nothing").is_none());
435    }
436
437    #[test]
438    fn test_get_some() {
439        let mut store = make_store();
440        let id = create_simple(&mut store, "g", "ns");
441        assert!(store.get(id).is_some());
442    }
443
444    #[test]
445    fn test_get_none() {
446        let store = make_store();
447        assert!(store.get(12345).is_none());
448    }
449
450    #[test]
451    fn test_delete_removes_object() {
452        let mut store = make_store();
453        let id = create_simple(&mut store, "del", "ns");
454        let ok = store.delete(id);
455        assert!(ok);
456        assert!(store.get(id).is_none());
457        assert!(!store
458            .name_index
459            .contains_key(&("ns".to_owned(), "del".to_owned())));
460    }
461
462    #[test]
463    fn test_delete_false_when_pinned() {
464        let mut store = make_store();
465        let id = create_simple(&mut store, "pinned-obj", "ns");
466        store.pin(id);
467        let ok = store.delete(id);
468        assert!(!ok);
469        assert!(store.get(id).is_some());
470    }
471
472    #[test]
473    fn test_delete_false_when_not_found() {
474        let mut store = make_store();
475        assert!(!store.delete(999));
476    }
477
478    #[test]
479    fn test_pin_sets_pinned_true() {
480        let mut store = make_store();
481        let id = create_simple(&mut store, "p", "ns");
482        assert!(!store.get(id).unwrap().pinned);
483        let ok = store.pin(id);
484        assert!(ok);
485        assert!(store.get(id).unwrap().pinned);
486    }
487
488    #[test]
489    fn test_pin_false_when_not_found() {
490        let mut store = make_store();
491        assert!(!store.pin(404));
492    }
493
494    #[test]
495    fn test_unpin_sets_pinned_false() {
496        let mut store = make_store();
497        let id = create_simple(&mut store, "u", "ns");
498        store.pin(id);
499        let ok = store.unpin(id);
500        assert!(ok);
501        assert!(!store.get(id).unwrap().pinned);
502    }
503
504    #[test]
505    fn test_unpin_false_when_not_found() {
506        let mut store = make_store();
507        assert!(!store.unpin(404));
508    }
509
510    #[test]
511    fn test_add_tag_idempotent() {
512        let mut store = make_store();
513        let id = create_simple(&mut store, "t", "ns");
514        assert!(store.add_tag(id, "important"));
515        assert!(store.add_tag(id, "important")); // duplicate — should be ignored
516        assert!(store.add_tag(id, "critical"));
517        let obj = store.get(id).unwrap();
518        assert_eq!(obj.tags.len(), 2);
519        assert!(obj.tags.contains(&"important".to_owned()));
520        assert!(obj.tags.contains(&"critical".to_owned()));
521    }
522
523    #[test]
524    fn test_add_tag_false_when_not_found() {
525        let mut store = make_store();
526        assert!(!store.add_tag(999, "tag"));
527    }
528
529    #[test]
530    fn test_objects_in_namespace_filtered_and_sorted() {
531        let mut store = make_store();
532        create_simple(&mut store, "zebra", "alpha");
533        create_simple(&mut store, "apple", "alpha");
534        create_simple(&mut store, "mango", "alpha");
535        create_simple(&mut store, "only-here", "beta");
536
537        let result = store.objects_in_namespace("alpha");
538        assert_eq!(result.len(), 3);
539        assert_eq!(result[0].name, "apple");
540        assert_eq!(result[1].name, "mango");
541        assert_eq!(result[2].name, "zebra");
542    }
543
544    #[test]
545    fn test_objects_in_namespace_empty_for_unknown() {
546        let store = make_store();
547        assert!(store.objects_in_namespace("no-such-ns").is_empty());
548    }
549
550    #[test]
551    fn test_objects_with_tag_sorted_by_object_id() {
552        let mut store = make_store();
553        let id1 = create_simple(&mut store, "a", "ns");
554        let id2 = create_simple(&mut store, "b", "ns");
555        let id3 = create_simple(&mut store, "c", "ns");
556        store.add_tag(id3, "hot");
557        store.add_tag(id1, "hot");
558        store.add_tag(id2, "hot");
559
560        let result = store.objects_with_tag("hot");
561        assert_eq!(result.len(), 3);
562        assert_eq!(result[0].object_id, id1);
563        assert_eq!(result[1].object_id, id2);
564        assert_eq!(result[2].object_id, id3);
565    }
566
567    #[test]
568    fn test_objects_with_tag_empty_for_unknown_tag() {
569        let mut store = make_store();
570        create_simple(&mut store, "a", "ns");
571        assert!(store.objects_with_tag("nosuch").is_empty());
572    }
573
574    #[test]
575    fn test_total_size_bytes_sums_all_versions() {
576        let mut store = make_store();
577        let id = store.create("s", "ns", "c1", 100, "a", "m", 0);
578        store.add_version(id, "c2", 200, "a", "m", 0);
579        store.add_version(id, "c3", 300, "a", "m", 0);
580        let obj = store.get(id).unwrap();
581        assert_eq!(obj.total_size_bytes(), 600);
582    }
583
584    #[test]
585    fn test_stats_namespaces_count() {
586        let mut store = make_store();
587        create_simple(&mut store, "a", "alpha");
588        create_simple(&mut store, "b", "alpha");
589        create_simple(&mut store, "c", "beta");
590        create_simple(&mut store, "d", "gamma");
591
592        let stats = store.stats();
593        assert_eq!(stats.namespaces, 3);
594        assert_eq!(stats.total_objects, 4);
595    }
596
597    #[test]
598    fn test_stats_pinned_objects_count() {
599        let mut store = make_store();
600        let id1 = create_simple(&mut store, "x", "ns");
601        let id2 = create_simple(&mut store, "y", "ns");
602        create_simple(&mut store, "z", "ns");
603        store.pin(id1);
604        store.pin(id2);
605
606        let stats = store.stats();
607        assert_eq!(stats.pinned_objects, 2);
608    }
609
610    #[test]
611    fn test_stats_total_versions_and_size() {
612        let mut store = make_store();
613        let id1 = store.create("a", "ns", "c1", 50, "x", "m", 0);
614        store.add_version(id1, "c2", 75, "x", "m", 0);
615        let _id2 = store.create("b", "ns", "c3", 100, "x", "m", 0);
616
617        let stats = store.stats();
618        assert_eq!(stats.total_versions, 3);
619        assert_eq!(stats.total_size_bytes, 225);
620    }
621}