|
| 1 | +// SPDX-License-Identifier: BUSL-1.1 |
| 2 | + |
| 3 | +//! Bidirectional conversions between [`StoredCollection`] (catalog record) |
| 4 | +//! and [`CollectionDescriptor`] (engine-agnostic sync wire type), so |
| 5 | +//! `CREATE COLLECTION` and CRDT sync produce the same descriptor shape. |
| 6 | +//! |
| 7 | +//! `From<&StoredCollection> for CollectionDescriptor` is the emit side: it |
| 8 | +//! reads only the 11 fields the descriptor carries, dropping ownership, |
| 9 | +//! timestamps, and enforcement/index/constraint state that never travels |
| 10 | +//! over sync. [`stored_from_descriptor`] is the receive side: it starts |
| 11 | +//! from [`StoredCollection::new`] (which sets sane enforcement defaults |
| 12 | +//! and a fresh `created_at`) and overlays only the descriptor-carried |
| 13 | +//! fields. |
| 14 | +
|
| 15 | +use nodedb_types::sync::wire::CollectionDescriptor; |
| 16 | + |
| 17 | +use super::collection::StoredCollection; |
| 18 | + |
| 19 | +impl From<&StoredCollection> for CollectionDescriptor { |
| 20 | + fn from(stored: &StoredCollection) -> Self { |
| 21 | + Self { |
| 22 | + tenant_id: stored.tenant_id, |
| 23 | + database_id: stored.database_id, |
| 24 | + name: stored.name.clone(), |
| 25 | + collection_type: stored.collection_type.clone(), |
| 26 | + bitemporal: stored.bitemporal, |
| 27 | + fields: stored.fields.clone(), |
| 28 | + primary: stored.primary, |
| 29 | + vector_primary: stored.vector_primary.clone(), |
| 30 | + partition_strategy: stored.partition_strategy.clone(), |
| 31 | + declared_primary_key: stored.declared_primary_key.clone(), |
| 32 | + descriptor_version: stored.descriptor_version, |
| 33 | + } |
| 34 | + } |
| 35 | +} |
| 36 | + |
| 37 | +/// Materialize a [`StoredCollection`] from a synced [`CollectionDescriptor`]. |
| 38 | +/// |
| 39 | +/// `owner` is assigned to the receiving peer's identity — the descriptor |
| 40 | +/// carries no owner, since ownership is a local catalog concept, not a |
| 41 | +/// sync-wire one. All fields not carried by the descriptor (field_defs, |
| 42 | +/// event_defs, indexes, constraints, `is_active`, etc.) are left at the |
| 43 | +/// [`StoredCollection::new`] defaults. |
| 44 | +// Consumed by the sync receive handler that materializes announced |
| 45 | +// collections into the catalog; only the round-trip tests call it today. |
| 46 | +#[allow(dead_code)] |
| 47 | +pub(crate) fn stored_from_descriptor( |
| 48 | + descriptor: &CollectionDescriptor, |
| 49 | + owner: &str, |
| 50 | +) -> StoredCollection { |
| 51 | + let mut stored = StoredCollection::new(descriptor.tenant_id, &descriptor.name, owner); |
| 52 | + stored.database_id = descriptor.database_id; |
| 53 | + stored.collection_type = descriptor.collection_type.clone(); |
| 54 | + stored.bitemporal = descriptor.bitemporal; |
| 55 | + stored.fields = descriptor.fields.clone(); |
| 56 | + stored.primary = descriptor.primary; |
| 57 | + stored.vector_primary = descriptor.vector_primary.clone(); |
| 58 | + stored.partition_strategy = descriptor.partition_strategy.clone(); |
| 59 | + stored.declared_primary_key = descriptor.declared_primary_key.clone(); |
| 60 | + stored.descriptor_version = descriptor.descriptor_version; |
| 61 | + stored |
| 62 | +} |
| 63 | + |
| 64 | +#[cfg(test)] |
| 65 | +mod tests { |
| 66 | + use nodedb_types::CollectionType; |
| 67 | + use nodedb_types::collection_config::{PartitionStrategy, PrimaryEngine, VectorPrimaryConfig}; |
| 68 | + use nodedb_types::columnar::{ColumnDef, ColumnType, StrictSchema}; |
| 69 | + use nodedb_types::kv::KvConfig; |
| 70 | + |
| 71 | + use super::*; |
| 72 | + |
| 73 | + fn assert_mapped_fields_match(stored: &StoredCollection, back: &StoredCollection) { |
| 74 | + assert_eq!(back.name, stored.name); |
| 75 | + assert_eq!(back.tenant_id, stored.tenant_id); |
| 76 | + assert_eq!(back.database_id, stored.database_id); |
| 77 | + assert_eq!(back.collection_type, stored.collection_type); |
| 78 | + assert_eq!(back.bitemporal, stored.bitemporal); |
| 79 | + assert_eq!(back.fields, stored.fields); |
| 80 | + assert_eq!(back.primary, stored.primary); |
| 81 | + assert_eq!(back.vector_primary, stored.vector_primary); |
| 82 | + assert_eq!(back.partition_strategy, stored.partition_strategy); |
| 83 | + assert_eq!(back.declared_primary_key, stored.declared_primary_key); |
| 84 | + assert_eq!(back.descriptor_version, stored.descriptor_version); |
| 85 | + } |
| 86 | + |
| 87 | + fn base_stored(name: &str, collection_type: CollectionType) -> StoredCollection { |
| 88 | + let mut stored = StoredCollection::new(7, name, "alice"); |
| 89 | + stored.partition_strategy = |
| 90 | + PartitionStrategy::default_for_collection_type(&collection_type); |
| 91 | + stored.collection_type = collection_type; |
| 92 | + stored.bitemporal = true; |
| 93 | + stored.declared_primary_key = Some("id".to_string()); |
| 94 | + stored.descriptor_version = 5; |
| 95 | + stored |
| 96 | + } |
| 97 | + |
| 98 | + #[test] |
| 99 | + fn document_schemaless_round_trips() { |
| 100 | + let stored = base_stored("users", CollectionType::document()); |
| 101 | + let descriptor = CollectionDescriptor::from(&stored); |
| 102 | + let back = stored_from_descriptor(&descriptor, "sync"); |
| 103 | + assert_mapped_fields_match(&stored, &back); |
| 104 | + } |
| 105 | + |
| 106 | + #[test] |
| 107 | + fn document_strict_round_trips() { |
| 108 | + let schema = StrictSchema { |
| 109 | + columns: vec![ |
| 110 | + ColumnDef::required("name", ColumnType::String), |
| 111 | + ColumnDef::nullable("bio", ColumnType::String), |
| 112 | + ], |
| 113 | + version: 1, |
| 114 | + dropped_columns: Vec::new(), |
| 115 | + bitemporal: false, |
| 116 | + }; |
| 117 | + let stored = base_stored("people", CollectionType::strict(schema)); |
| 118 | + let descriptor = CollectionDescriptor::from(&stored); |
| 119 | + let back = stored_from_descriptor(&descriptor, "sync"); |
| 120 | + assert_mapped_fields_match(&stored, &back); |
| 121 | + } |
| 122 | + |
| 123 | + #[test] |
| 124 | + fn key_value_round_trips() { |
| 125 | + let schema = StrictSchema { |
| 126 | + columns: vec![ColumnDef::required("id", ColumnType::Int64).with_primary_key()], |
| 127 | + version: 1, |
| 128 | + dropped_columns: Vec::new(), |
| 129 | + bitemporal: false, |
| 130 | + }; |
| 131 | + let stored = base_stored("sessions", CollectionType::kv(schema)); |
| 132 | + let descriptor = CollectionDescriptor::from(&stored); |
| 133 | + let back = stored_from_descriptor(&descriptor, "sync"); |
| 134 | + assert_mapped_fields_match(&stored, &back); |
| 135 | + // Sanity: the KvConfig payload itself survived intact. |
| 136 | + match (&stored.collection_type, &back.collection_type) { |
| 137 | + (CollectionType::KeyValue(a), CollectionType::KeyValue(b)) => { |
| 138 | + let a: &KvConfig = a; |
| 139 | + let b: &KvConfig = b; |
| 140 | + assert_eq!(a.schema, b.schema); |
| 141 | + } |
| 142 | + _ => panic!("expected KeyValue collection type"), |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + #[test] |
| 147 | + fn columnar_plain_round_trips() { |
| 148 | + let stored = base_stored("events", CollectionType::columnar()); |
| 149 | + let descriptor = CollectionDescriptor::from(&stored); |
| 150 | + let back = stored_from_descriptor(&descriptor, "sync"); |
| 151 | + assert_mapped_fields_match(&stored, &back); |
| 152 | + } |
| 153 | + |
| 154 | + #[test] |
| 155 | + fn timeseries_round_trips() { |
| 156 | + let stored = base_stored("metrics", CollectionType::timeseries("ts", "1m")); |
| 157 | + let descriptor = CollectionDescriptor::from(&stored); |
| 158 | + let back = stored_from_descriptor(&descriptor, "sync"); |
| 159 | + assert_mapped_fields_match(&stored, &back); |
| 160 | + } |
| 161 | + |
| 162 | + #[test] |
| 163 | + fn spatial_round_trips() { |
| 164 | + let stored = base_stored("places", CollectionType::spatial("geom")); |
| 165 | + let descriptor = CollectionDescriptor::from(&stored); |
| 166 | + let back = stored_from_descriptor(&descriptor, "sync"); |
| 167 | + assert_mapped_fields_match(&stored, &back); |
| 168 | + } |
| 169 | + |
| 170 | + #[test] |
| 171 | + fn vector_primary_round_trips() { |
| 172 | + let mut stored = base_stored("embeddings", CollectionType::document()); |
| 173 | + stored.primary = PrimaryEngine::Vector; |
| 174 | + stored.vector_primary = Some(VectorPrimaryConfig { |
| 175 | + vector_field: "emb".to_string(), |
| 176 | + dim: 768, |
| 177 | + ..VectorPrimaryConfig::default() |
| 178 | + }); |
| 179 | + let descriptor = CollectionDescriptor::from(&stored); |
| 180 | + let back = stored_from_descriptor(&descriptor, "sync"); |
| 181 | + assert_mapped_fields_match(&stored, &back); |
| 182 | + } |
| 183 | + |
| 184 | + #[test] |
| 185 | + fn bitemporal_flag_round_trips() { |
| 186 | + let mut stored = base_stored("audit_log", CollectionType::document()); |
| 187 | + stored.bitemporal = true; |
| 188 | + let descriptor = CollectionDescriptor::from(&stored); |
| 189 | + assert!(descriptor.bitemporal); |
| 190 | + let back = stored_from_descriptor(&descriptor, "sync"); |
| 191 | + assert!(back.bitemporal); |
| 192 | + } |
| 193 | + |
| 194 | + #[test] |
| 195 | + fn owner_assigned_on_receive() { |
| 196 | + let stored = base_stored("owned", CollectionType::document()); |
| 197 | + assert_eq!(stored.owner, "alice"); |
| 198 | + let descriptor = CollectionDescriptor::from(&stored); |
| 199 | + let back = stored_from_descriptor(&descriptor, "sync"); |
| 200 | + assert_eq!(back.owner, "sync"); |
| 201 | + } |
| 202 | +} |
0 commit comments