Skip to main content

reddb_server/storage/unified/devx/reddb/
impl_metadata.rs

1use super::*;
2use crate::storage::unified::metadata::{MetadataFilter, MetadataValue};
3
4impl RedDB {
5    pub fn is_replica_role(&self) -> bool {
6        matches!(
7            self.options.replication.role,
8            crate::replication::ReplicationRole::Replica { .. }
9        )
10    }
11
12    pub fn enforce_retention_policy(&self) -> Result<(), Box<dyn std::error::Error>> {
13        if self.options.read_only || self.is_replica_role() {
14            return Ok(());
15        }
16
17        // Export pruning is only meaningful for persistent mode where we
18        // have a metadata sidecar that tracks file-backed export artifacts.
19        if self.options.mode == StorageMode::Persistent {
20            let Some(path) = self.path() else {
21                return Ok(());
22            };
23
24            let _publication = self.store.vault_publication_lock.lock();
25            let Ok(mut metadata) = self.load_or_bootstrap_physical_metadata(true) else {
26                return Ok(());
27            };
28
29            self.prune_export_registry(&mut metadata.exports);
30            metadata.save_for_data_path(path)?;
31        }
32
33        let _ = self.sweep_ttl_expired_entities()?;
34
35        Ok(())
36    }
37
38    pub(crate) fn ttl_expired_entities_now(
39        &self,
40    ) -> Result<Vec<(String, EntityId)>, Box<dyn std::error::Error>> {
41        self.ttl_expired_entities_at(current_unix_ms())
42    }
43
44    pub fn replica_allows_entity_at_read(
45        &self,
46        collection: &str,
47        entity: &crate::storage::UnifiedEntity,
48    ) -> bool {
49        if !self.is_replica_role() {
50            return true;
51        }
52        !self.entity_expired_at(collection, entity, current_unix_ms())
53    }
54
55    fn sweep_ttl_expired_entities(&self) -> Result<usize, Box<dyn std::error::Error>> {
56        let to_delete = self.ttl_expired_entities_now()?;
57
58        let mut deleted = 0usize;
59        for (collection, id) in to_delete {
60            match self.store.delete(&collection, id) {
61                Ok(true) => deleted = deleted.saturating_add(1),
62                Ok(false) => {}
63                Err(err) => {
64                    return Err(format!(
65                        "failed deleting expired entity {id} from collection '{collection}': {err:?}"
66                    )
67                    .into());
68                }
69            }
70        }
71
72        Ok(deleted)
73    }
74
75    fn ttl_expired_entities_at(
76        &self,
77        now_ms: u64,
78    ) -> Result<Vec<(String, EntityId)>, Box<dyn std::error::Error>> {
79        let mut to_delete = Vec::<(String, EntityId)>::new();
80
81        let mut absolute_expired = self.expired_entities_by_expires_at(now_ms)?;
82        to_delete.append(&mut absolute_expired);
83
84        let mut relative_expired = self.expired_entities_by_ttl(now_ms)?;
85        to_delete.append(&mut relative_expired);
86
87        to_delete.sort_unstable();
88        to_delete.dedup();
89
90        Ok(to_delete)
91    }
92
93    fn entity_expired_at(
94        &self,
95        collection: &str,
96        entity: &crate::storage::UnifiedEntity,
97        now_ms: u64,
98    ) -> bool {
99        let Some(metadata) = self.store.get_metadata(collection, entity.id) else {
100            return false;
101        };
102
103        if metadata
104            .get("_expires_at")
105            .and_then(Self::metadata_u64)
106            .is_some_and(|expires_at_ms| expires_at_ms <= now_ms)
107        {
108            return true;
109        }
110
111        let ttl_ms = metadata.get("_ttl_ms").and_then(Self::metadata_u64);
112        let ttl_secs = if ttl_ms.is_none() {
113            metadata.get("_ttl").and_then(|value| {
114                Self::metadata_u64(value).and_then(|value_secs| value_secs.checked_mul(1000))
115            })
116        } else {
117            None
118        };
119
120        let Some(ttl_ms) = ttl_ms.or(ttl_secs) else {
121            return false;
122        };
123        entity
124            .created_at
125            .saturating_mul(1000)
126            .saturating_add(ttl_ms)
127            <= now_ms
128    }
129
130    fn expired_entities_by_expires_at(
131        &self,
132        now_ms: u64,
133    ) -> Result<Vec<(String, EntityId)>, Box<dyn std::error::Error>> {
134        let mut ids = self.store.filter_metadata_all(&[(
135            "_expires_at".to_string(),
136            MetadataFilter::Le(MetadataValue::Timestamp(now_ms)),
137        )]);
138
139        if let Ok(now_ms_i64) = i64::try_from(now_ms) {
140            ids.extend(self.store.filter_metadata_all(&[(
141                "_expires_at".to_string(),
142                MetadataFilter::Le(MetadataValue::Int(now_ms_i64)),
143            )]));
144        }
145
146        let now_ms_f64 = now_ms as f64;
147        if now_ms_f64.is_finite() {
148            ids.extend(self.store.filter_metadata_all(&[(
149                "_expires_at".to_string(),
150                MetadataFilter::Le(MetadataValue::Float(now_ms_f64)),
151            )]));
152        }
153
154        Ok(ids)
155    }
156
157    fn expired_entities_by_ttl(
158        &self,
159        now_ms: u64,
160    ) -> Result<Vec<(String, EntityId)>, Box<dyn std::error::Error>> {
161        let mut candidates = Vec::<(String, EntityId)>::new();
162
163        let ttl_ms_candidates = self
164            .store
165            .filter_metadata_all(&[("_ttl_ms".to_string(), MetadataFilter::IsNotNull)]);
166        candidates.extend(ttl_ms_candidates);
167
168        let ttl_candidates = self
169            .store
170            .filter_metadata_all(&[("_ttl".to_string(), MetadataFilter::IsNotNull)]);
171        candidates.extend(ttl_candidates);
172
173        if candidates.is_empty() {
174            return Ok(Vec::new());
175        }
176
177        candidates.sort_unstable();
178        candidates.dedup();
179
180        let mut expired = Vec::<(String, EntityId)>::new();
181        for (collection, entity_id) in candidates {
182            let Some(entity) = self.store.get(&collection, entity_id) else {
183                continue;
184            };
185
186            let Some(metadata) = self.store.get_metadata(&collection, entity_id) else {
187                continue;
188            };
189
190            let ttl_ms = metadata.get("_ttl_ms").and_then(Self::metadata_u64);
191            let ttl_secs = if ttl_ms.is_none() {
192                metadata.get("_ttl").and_then(|value| {
193                    Self::metadata_u64(value).and_then(|value_secs| value_secs.checked_mul(1000))
194                })
195            } else {
196                None
197            };
198
199            let Some(ttl_ms) = ttl_ms.or(ttl_secs) else {
200                continue;
201            };
202
203            let created_at_ms = entity.created_at.saturating_mul(1000);
204            let expiry_ms = created_at_ms.saturating_add(ttl_ms);
205            if expiry_ms <= now_ms {
206                expired.push((collection, entity_id));
207            }
208        }
209
210        Ok(expired)
211    }
212
213    fn metadata_u64(value: &MetadataValue) -> Option<u64> {
214        match value {
215            MetadataValue::Int(v) if *v >= 0 => Some(*v as u64),
216            MetadataValue::Timestamp(v) => Some(*v),
217            MetadataValue::Float(v) => {
218                if !v.is_finite() || !v.is_sign_positive() || v.fract().abs() >= f64::EPSILON {
219                    return None;
220                }
221                if *v > u64::MAX as f64 {
222                    return None;
223                }
224                Some(v.trunc() as u64)
225            }
226            MetadataValue::String(v) => v.parse::<u64>().ok(),
227            _ => None,
228        }
229    }
230
231    // ========================================================================
232    // Builder Methods - Create Entities
233    // ========================================================================
234
235    /// Start building a graph node
236    ///
237    /// # Example
238    /// ```ignore
239    /// let host = db.node("hosts", "Host")
240    ///     .property("ip", "192.168.1.1")
241    ///     .save()?;
242    /// ```
243    pub fn node(&self, collection: impl Into<String>, label: impl Into<String>) -> NodeBuilder {
244        NodeBuilder::new(
245            self.store.clone(),
246            self.preprocessors.clone(),
247            collection,
248            label,
249        )
250    }
251
252    /// Start building a graph edge
253    ///
254    /// # Example
255    /// ```ignore
256    /// let edge = db.edge("connections", "CONNECTS_TO")
257    ///     .from(host_a)
258    ///     .to(host_b)
259    ///     .weight(0.95)
260    ///     .property("protocol", "TCP")
261    ///     .save()?;
262    /// ```
263    pub fn edge(&self, collection: impl Into<String>, label: impl Into<String>) -> EdgeBuilder {
264        EdgeBuilder::new(
265            self.store.clone(),
266            self.preprocessors.clone(),
267            collection,
268            label,
269        )
270    }
271
272    /// Start building a vector entry
273    ///
274    /// # Example
275    /// ```ignore
276    /// let vec = db.vector("embeddings")
277    ///     .dense(embedding)
278    ///     .content("Original text content")
279    ///     .metadata("source", "document.pdf")
280    ///     .save()?;
281    /// ```
282    pub fn vector(&self, collection: impl Into<String>) -> VectorBuilder {
283        VectorBuilder::new(self.store.clone(), self.preprocessors.clone(), collection)
284    }
285
286    /// Start building a table row
287    ///
288    /// # Example
289    /// ```ignore
290    /// let row = db.row("scans", vec![
291    ///     ("timestamp", Value::Timestamp(now)),
292    ///     ("target", Value::text("192.168.1.0/24")),
293    ///     ("findings", Value::Integer(42)),
294    /// ]).save()?;
295    /// ```
296    pub fn row(&self, table: impl Into<String>, columns: Vec<(&str, Value)>) -> RowBuilder {
297        RowBuilder::new(
298            self.store.clone(),
299            self.preprocessors.clone(),
300            table,
301            columns,
302        )
303    }
304
305    /// Start building a document
306    ///
307    /// Documents are stored as enriched table rows with a full JSON body
308    /// field and flattened top-level keys for filtering.
309    ///
310    /// # Example
311    /// ```ignore
312    /// let doc = db.doc("articles")
313    ///     .field("title", "Hello World")
314    ///     .field("views", 42)
315    ///     .metadata("source", "web")
316    ///     .save()?;
317    /// ```
318    pub fn doc(&self, collection: impl Into<String>) -> DocumentBuilder {
319        DocumentBuilder::new(self.store.clone(), self.preprocessors.clone(), collection)
320    }
321
322    /// Start building a key-value pair
323    ///
324    /// KV pairs are stored as table rows with named fields `key` and `value`.
325    ///
326    /// # Example
327    /// ```ignore
328    /// let id = db.kv("config", "theme", Value::text("dark"))
329    ///     .metadata("updated_by", "admin")
330    ///     .save()?;
331    /// ```
332    pub fn kv(
333        &self,
334        collection: impl Into<String>,
335        key: impl Into<String>,
336        value: Value,
337    ) -> KvBuilder {
338        KvBuilder::new(
339            self.store.clone(),
340            self.preprocessors.clone(),
341            collection,
342            key,
343            value,
344        )
345    }
346
347    /// Get a key-value pair by key, returning the value and entity id
348    ///
349    /// Clone only the selected value. Legacy positional rows without an
350    /// indexed key use their collection schema in the borrowed fallback scan.
351    pub fn get_kv(&self, collection: &str, key: &str) -> Option<(Value, EntityId)> {
352        let manager = self.store.get_collection(collection)?;
353        if let Some(value) = manager.project_key_entity(key, None, false, |entity| {
354            (
355                entity
356                    .data
357                    .as_row()
358                    .and_then(|row| row.get_field("value"))
359                    .cloned()
360                    .unwrap_or(Value::Null),
361                entity.id,
362            )
363        }) {
364            return Some(value);
365        }
366        let columns = manager.column_schema();
367        let key_index = columns
368            .as_ref()
369            .and_then(|names| names.iter().position(|name| name == "key"));
370        let value_index = columns
371            .as_ref()
372            .and_then(|names| names.iter().position(|name| name == "value"));
373        let mut found = None;
374        manager.for_each_entity(|entity| {
375            let EntityData::Row(row) = &entity.data else {
376                return true;
377            };
378            let legacy = row.named.is_none() && row.schema.is_none();
379            let stored_key = row.get_field("key").or_else(|| {
380                if legacy {
381                    key_index.and_then(|index| row.columns.get(index))
382                } else {
383                    None
384                }
385            });
386            if matches!(stored_key, Some(Value::Text(stored)) if stored.as_ref() == key) {
387                let value = row.get_field("value").or_else(|| {
388                    if legacy {
389                        value_index.and_then(|index| row.columns.get(index))
390                    } else {
391                        None
392                    }
393                });
394                found = Some((value.cloned().unwrap_or(Value::Null), entity.id));
395                return false;
396            }
397            true
398        });
399        found
400    }
401
402    /// Delete a key-value pair by key, returning whether it was found and removed
403    pub fn delete_kv(
404        &self,
405        collection: &str,
406        key: &str,
407    ) -> Result<bool, super::super::error::DevXError> {
408        let Some((_, id)) = self.get_kv(collection, key) else {
409            return Ok(false);
410        };
411        self.store
412            .delete(collection, id)
413            .map_err(|err| super::super::error::DevXError::Storage(format!("{err:?}")))?;
414        Ok(true)
415    }
416
417    pub(crate) fn with_initialized_metadata(self) -> Result<Self, Box<dyn std::error::Error>> {
418        if self.options.mode == StorageMode::Persistent {
419            // Load metadata without persisting (avoids blocking catalog snapshot on boot)
420            match self.load_or_bootstrap_physical_metadata(false) {
421                Ok(metadata) => {
422                    crate::reserved_fields::validate_physical_metadata_contracts(&metadata)
423                        .map_err(|err| err.to_string())?;
424                }
425                Err(error)
426                    if error
427                        .downcast_ref::<std::io::Error>()
428                        .is_some_and(crate::physical::is_collection_contract_decode_error) =>
429                {
430                    return Err(error)
431                }
432                Err(_) => {}
433            }
434            // Skip repair on boot — deferred to first explicit persist_metadata() call.
435            // This avoids the recursive catalog_model_snapshot → physical_metadata loop
436            // that caused stack overflow / 12-second hang on startup.
437        }
438        self.load_collection_ttl_defaults_from_metadata();
439        // Single-file artifact: collection contracts ride the store's binary
440        // dump, not the metadata sidecar (which is never written in this
441        // profile). Seed the contract cache from that blob so each
442        // collection's declared model survives a restart — otherwise recovery
443        // re-infers the model from the stored entities and a KV collection
444        // comes back as a table.
445        if self.options.storage_profile.deploy_profile == crate::storage::DeployProfile::Embedded
446            && self.options.storage_profile.packaging
447                == crate::storage::StoragePackaging::SingleFile
448            && !self.paged_mode
449        {
450            self.seed_contract_cache_from_store_aux()?;
451        }
452        // Issue #866 — rehydrate the hypertable chunk spine before the
453        // API opens so chunk routing / pruning / TTL work immediately
454        // after a restart.
455        self.load_hypertables_from_metadata();
456        // Pending deliveries retain their persisted lock deadlines on recovery
457        // (ADR 0020). The lifecycle lazily reclaims expired locks on delivery;
458        // deleting every pending row here would redeliver still-owned work.
459        Ok(self)
460    }
461
462    pub(crate) fn persist_metadata(&self) -> Result<(), Box<dyn std::error::Error>> {
463        if self.options.mode != StorageMode::Persistent || self.options.read_only {
464            return Ok(());
465        }
466        // Default embedded options can reopen an existing paged file. Its
467        // native headers and index artifacts still require publication.
468        if !self.paged_mode
469            && self.options.storage_profile.deploy_profile
470                == crate::storage::DeployProfile::Embedded
471            && self.options.storage_profile.packaging
472                == crate::storage::StoragePackaging::SingleFile
473        {
474            return Ok(());
475        }
476        let Some(path) = self.path() else {
477            return Ok(());
478        };
479
480        let _publication = self.store.vault_publication_lock.lock();
481        let previous = self.load_or_bootstrap_physical_metadata(false).ok();
482        let collection_roots = self.physical_collection_roots();
483        let mut indexes = self
484            .native_physical_state()
485            .map(|state| self.physical_index_state_from_native_state(&state, previous.as_ref()))
486            .unwrap_or_else(|| self.physical_index_state());
487        if let Some(previous) = previous.as_ref() {
488            // Legacy paged contract edits publish the sidecar first. Do not
489            // resurrect a dropped collection from the older native registry.
490            // Preserve unrelated orphan descriptors without a removal proof.
491            let absent = |name: &str| {
492                !collection_roots.contains_key(name)
493                    && !previous
494                        .collection_contracts
495                        .iter()
496                        .any(|contract| contract.name == name)
497            };
498            let mut removed: BTreeSet<&str> = previous
499                .superblock
500                .collection_roots
501                .keys()
502                .map(String::as_str)
503                .filter(|name| absent(name))
504                .collect();
505            let mut seen = BTreeSet::new();
506            for event in previous.manifest_events.iter().rev() {
507                if seen.insert(event.collection.as_str())
508                    && matches!(&event.kind, crate::physical::ManifestEventKind::Remove)
509                    && absent(&event.collection)
510                {
511                    removed.insert(event.collection.as_str());
512                }
513            }
514            indexes.retain(|index| {
515                !index
516                    .collection
517                    .as_deref()
518                    .is_some_and(|collection| removed.contains(collection))
519            });
520        }
521        let mut metadata = PhysicalMetadataFile::from_state(
522            self.options.clone(),
523            self.catalog_snapshot(),
524            collection_roots,
525            indexes,
526            previous.as_ref(),
527        );
528        metadata.collection_ttl_defaults_ms = self.collection_ttl_defaults_snapshot();
529        // Issue #866 — persist the hypertable chunk spine (specs +
530        // chunk bounds / routing / TTL) onto the same metadata path as
531        // collection contracts so a restart recovers it identically.
532        metadata.hypertables = self.hypertable_registry_snapshot();
533        metadata.save_for_data_path(path)?;
534        self.persist_native_physical_header(&metadata)?;
535        Ok(())
536    }
537
538    fn bootstrap_metadata_from_native_state(&self) -> Result<bool, Box<dyn std::error::Error>> {
539        if self.options.mode != StorageMode::Persistent || self.options.read_only {
540            return Ok(false);
541        }
542        let Some(path) = self.path() else {
543            return Ok(false);
544        };
545        let Some(native_state) = self.native_physical_state() else {
546            return Ok(false);
547        };
548        if !Self::native_state_is_bootstrap_complete(&native_state) {
549            return Ok(false);
550        }
551
552        let _publication = self.store.vault_publication_lock.lock();
553        let previous = PhysicalMetadataFile::load_for_data_path(path).ok();
554        let metadata = self.metadata_from_native_state(&native_state, previous.as_ref());
555        metadata.save_for_data_path(path)?;
556        self.persist_native_physical_header(&metadata)?;
557        Ok(true)
558    }
559
560    /// Rebuild the external physical metadata view from the native state published in the
561    /// paged database file.
562    pub fn rebuild_physical_metadata_from_native_state(
563        &self,
564    ) -> Result<bool, Box<dyn std::error::Error>> {
565        self.bootstrap_metadata_from_native_state()
566    }
567
568    pub(crate) fn native_state_is_bootstrap_complete(native_state: &NativePhysicalState) -> bool {
569        let registry_complete = native_state.registry.as_ref().map(|registry| {
570            registry.collections_complete
571                && registry.indexes_complete
572                && registry.graph_projections_complete
573                && registry.analytics_jobs_complete
574                && registry.vector_artifacts_complete
575        });
576        let recovery_complete = native_state
577            .recovery
578            .as_ref()
579            .map(|recovery| recovery.snapshots_complete && recovery.exports_complete);
580        let catalog_complete = native_state
581            .catalog
582            .as_ref()
583            .map(|catalog| catalog.collections_complete);
584
585        registry_complete == Some(true)
586            && recovery_complete == Some(true)
587            && catalog_complete == Some(true)
588    }
589
590    pub(crate) fn load_or_bootstrap_physical_metadata(
591        &self,
592        persist_bootstrapped: bool,
593    ) -> Result<PhysicalMetadataFile, Box<dyn std::error::Error>> {
594        if self.options.mode != StorageMode::Persistent {
595            return Err("physical metadata requires persistent mode".into());
596        }
597        let Some(path) = self.path() else {
598            return Err("database path is not available".into());
599        };
600        let _publication = self.store.vault_publication_lock.lock();
601        let native_state = self.native_physical_state();
602
603        let metadata = if persist_bootstrapped && !self.options.read_only {
604            PhysicalMetadataFile::load_for_data_path(path)
605        } else {
606            PhysicalMetadataFile::inspect_for_data_path(path)
607        };
608        match metadata {
609            Ok(metadata) => {
610                if let Some(native_state) = native_state.as_ref() {
611                    let inspection = Self::inspect_native_header_against_metadata(
612                        native_state.header,
613                        &metadata,
614                    );
615                    if Self::repair_policy_for_inspection(&inspection)
616                        == NativeHeaderRepairPolicy::NativeAheadOfMetadata
617                    {
618                        let bootstrapped =
619                            self.metadata_from_native_state(native_state, Some(&metadata));
620                        if persist_bootstrapped && !self.options.read_only {
621                            bootstrapped.save_for_data_path(path)?;
622                            self.persist_native_physical_header(&bootstrapped)?;
623                        }
624                        return Ok(bootstrapped);
625                    }
626                }
627                Ok(metadata)
628            }
629            Err(err) => {
630                if crate::physical::is_collection_contract_decode_error(&err) {
631                    return Err(err.into());
632                }
633                let Some(native_state) = native_state else {
634                    return Err(err.into());
635                };
636                // Accept the bootstrap when the native state is either
637                // (a) fully populated and consistent (the original
638                // contract), or (b) trivially empty — a freshly created
639                // database with no collections written yet. Without (b)
640                // a brand-new data file can never reach
641                // `readiness_for_query = true`, because the bootstrap
642                // refuses to run until the registry/catalog/recovery
643                // structures are "complete", which they never become
644                // until the bootstrap has already run once.
645                //
646                // The emptiness check is conservative: header.sequence
647                // must still be at its initial value AND all three
648                // physical state summaries must be absent. Anything
649                // else falls through to the original error so we never
650                // paper over partially corrupted files.
651                let is_fresh_empty = native_state.header.sequence == 0
652                    && native_state.registry.is_none()
653                    && native_state.catalog.is_none()
654                    && native_state.recovery.is_none();
655                if !is_fresh_empty && !Self::native_state_is_bootstrap_complete(&native_state) {
656                    return Err(err.into());
657                }
658                let metadata = self.metadata_from_native_state(&native_state, None);
659                if persist_bootstrapped && !self.options.read_only {
660                    metadata.save_for_data_path(path)?;
661                    self.persist_native_physical_header(&metadata)?;
662                }
663                Ok(metadata)
664            }
665        }
666    }
667
668    pub(crate) fn physical_metadata_preference(&self) -> Option<&'static str> {
669        let path = self.path()?;
670        let native_state = self.native_physical_state();
671        let metadata = PhysicalMetadataFile::inspect_for_data_path(path).ok();
672
673        match (metadata, native_state) {
674            (Some(metadata), Some(native_state)) => {
675                let inspection =
676                    Self::inspect_native_header_against_metadata(native_state.header, &metadata);
677                match Self::repair_policy_for_inspection(&inspection) {
678                    NativeHeaderRepairPolicy::InSync => Some("sidecar_current"),
679                    NativeHeaderRepairPolicy::RepairNativeFromMetadata => Some("sidecar_current"),
680                    NativeHeaderRepairPolicy::NativeAheadOfMetadata => Some("native_ahead"),
681                }
682            }
683            (Some(_), None) => Some("sidecar_only"),
684            (None, Some(_)) => Some("sidecar_missing_native_available"),
685            (None, None) => Some("sidecar_missing_no_native"),
686        }
687    }
688
689    fn metadata_from_native_state(
690        &self,
691        native_state: &NativePhysicalState,
692        previous: Option<&PhysicalMetadataFile>,
693    ) -> PhysicalMetadataFile {
694        let now = SystemTime::now()
695            .duration_since(UNIX_EPOCH)
696            .unwrap_or_default()
697            .as_millis();
698        let catalog = self.catalog_snapshot();
699        let catalog_name = catalog.name.clone();
700        let catalog_total_entities = catalog.total_entities;
701        let catalog_total_collections = catalog.total_collections;
702        let indexes = self.physical_index_state();
703
704        let mut manifest =
705            crate::api::SchemaManifest::now(self.options.clone(), catalog.total_collections);
706        manifest.updated_at_unix_ms = now;
707
708        let manifest_events = native_state
709            .manifest
710            .as_ref()
711            .map(|summary| {
712                summary
713                    .recent_events
714                    .iter()
715                    .map(|event| crate::physical::ManifestEvent {
716                        collection: event.collection.clone(),
717                        object_key: event.object_key.clone(),
718                        kind: match event.kind.as_str() {
719                            "insert" => crate::physical::ManifestEventKind::Insert,
720                            "update" => crate::physical::ManifestEventKind::Update,
721                            "remove" => crate::physical::ManifestEventKind::Remove,
722                            _ => crate::physical::ManifestEventKind::Checkpoint,
723                        },
724                        block: crate::physical::BlockReference {
725                            index: event.block_index,
726                            checksum: event.block_checksum,
727                        },
728                        snapshot_min: event.snapshot_min,
729                        snapshot_max: event.snapshot_max,
730                    })
731                    .collect()
732            })
733            .unwrap_or_default();
734
735        let graph_projections = native_state
736            .registry
737            .as_ref()
738            .and_then(|registry| {
739                registry.graph_projections_complete.then(|| {
740                    registry
741                        .graph_projections
742                        .iter()
743                        .map(|projection| crate::physical::PhysicalGraphProjection {
744                            name: projection.name.clone(),
745                            created_at_unix_ms: projection.created_at_unix_ms,
746                            updated_at_unix_ms: projection.updated_at_unix_ms,
747                            state: "materialized".to_string(),
748                            source: projection.source.clone(),
749                            node_labels: projection.node_labels.clone(),
750                            node_types: projection.node_types.clone(),
751                            edge_labels: projection.edge_labels.clone(),
752                            last_materialized_sequence: projection.last_materialized_sequence,
753                        })
754                        .collect()
755                })
756            })
757            .or_else(|| previous.map(|metadata| metadata.graph_projections.clone()))
758            .unwrap_or_default();
759
760        let analytics_jobs = native_state
761            .registry
762            .as_ref()
763            .and_then(|registry| {
764                registry.analytics_jobs_complete.then(|| {
765                    registry
766                        .analytics_jobs
767                        .iter()
768                        .map(|job| crate::physical::PhysicalAnalyticsJob {
769                            id: job.id.clone(),
770                            kind: job.kind.clone(),
771                            state: job.state.clone(),
772                            projection: job.projection.clone(),
773                            created_at_unix_ms: job.created_at_unix_ms,
774                            updated_at_unix_ms: job.updated_at_unix_ms,
775                            last_run_sequence: job.last_run_sequence,
776                            metadata: job.metadata.clone(),
777                        })
778                        .collect()
779                })
780            })
781            .or_else(|| previous.map(|metadata| metadata.analytics_jobs.clone()))
782            .unwrap_or_default();
783
784        let exports = native_state
785            .recovery
786            .as_ref()
787            .and_then(|recovery| {
788                recovery.exports_complete.then(|| {
789                    recovery
790                        .exports
791                        .iter()
792                        .map(|export| crate::physical::ExportDescriptor {
793                            name: export.name.clone(),
794                            created_at_unix_ms: export.created_at_unix_ms,
795                            snapshot_id: export.snapshot_id,
796                            superblock_sequence: export.superblock_sequence,
797                            data_path: self
798                                .path()
799                                .map(|path| {
800                                    crate::physical::PhysicalMetadataFile::export_data_path_for(
801                                        path,
802                                        &export.name,
803                                    )
804                                    .display()
805                                    .to_string()
806                                })
807                                .unwrap_or_default(),
808                            metadata_path: self
809                                .path()
810                                .map(|path| {
811                                    let export_data_path =
812                                        crate::physical::PhysicalMetadataFile::export_data_path_for(
813                                            path,
814                                            &export.name,
815                                        );
816                                    crate::physical::PhysicalMetadataFile::metadata_path_for(
817                                        &export_data_path,
818                                    )
819                                    .display()
820                                    .to_string()
821                                })
822                                .unwrap_or_default(),
823                            collection_count: export.collection_count as usize,
824                            total_entities: export.total_entities as usize,
825                        })
826                        .collect()
827                })
828            })
829            .or_else(|| previous.map(|metadata| metadata.exports.clone()))
830            .unwrap_or_default();
831
832        let snapshots = native_state
833            .recovery
834            .as_ref()
835            .and_then(|recovery| {
836                recovery.snapshots_complete.then(|| {
837                    recovery
838                        .snapshots
839                        .iter()
840                        .map(|snapshot| crate::physical::SnapshotDescriptor {
841                            snapshot_id: snapshot.snapshot_id,
842                            created_at_unix_ms: snapshot.created_at_unix_ms,
843                            superblock_sequence: snapshot.superblock_sequence,
844                            collection_count: snapshot.collection_count as usize,
845                            total_entities: snapshot.total_entities as usize,
846                        })
847                        .collect()
848                })
849            })
850            .or_else(|| previous.map(|metadata| metadata.snapshots.clone()))
851            .unwrap_or_else(|| {
852                vec![crate::physical::SnapshotDescriptor {
853                    snapshot_id: native_state.header.sequence,
854                    created_at_unix_ms: now,
855                    superblock_sequence: native_state.header.sequence,
856                    collection_count: catalog_total_collections,
857                    total_entities: catalog_total_entities,
858                }]
859            });
860
861        let catalog_stats = native_state
862            .catalog
863            .as_ref()
864            .and_then(|native_catalog| {
865                native_catalog.collections_complete.then(|| {
866                    native_catalog
867                        .collections
868                        .iter()
869                        .map(|collection| {
870                            (
871                                collection.name.clone(),
872                                crate::api::CollectionStats {
873                                    entities: collection.entities as usize,
874                                    cross_refs: collection.cross_refs as usize,
875                                    segments: collection.segments as usize,
876                                },
877                            )
878                        })
879                        .collect::<BTreeMap<_, _>>()
880                })
881            })
882            .or_else(|| previous.map(|metadata| metadata.catalog.stats_by_collection.clone()))
883            .unwrap_or_else(|| catalog.stats_by_collection.clone());
884
885        PhysicalMetadataFile {
886            protocol_version: crate::physical::PHYSICAL_METADATA_PROTOCOL_VERSION.to_string(),
887            generated_at_unix_ms: now,
888            last_loaded_from: Some("native_bootstrap".to_string()),
889            last_healed_at_unix_ms: Some(now),
890            manifest,
891            catalog: crate::api::CatalogSnapshot {
892                name: catalog_name,
893                total_entities: native_state
894                    .catalog
895                    .as_ref()
896                    .map(|summary| summary.total_entities as usize)
897                    .unwrap_or(catalog_total_entities),
898                total_collections: native_state
899                    .catalog
900                    .as_ref()
901                    .map(|summary| summary.collection_count as usize)
902                    .unwrap_or(catalog_total_collections),
903                stats_by_collection: catalog_stats,
904                updated_at: SystemTime::now(),
905            },
906            manifest_events,
907            collection_ttl_defaults_ms: previous
908                .map(|metadata| metadata.collection_ttl_defaults_ms.clone())
909                .unwrap_or_default(),
910            collection_contracts: previous
911                .map(|metadata| metadata.collection_contracts.clone())
912                .unwrap_or_default(),
913            hypertables: previous
914                .map(|metadata| metadata.hypertables.clone())
915                .unwrap_or_default(),
916            tree_definitions: previous
917                .map(|metadata| metadata.tree_definitions.clone())
918                .unwrap_or_default(),
919            indexes,
920            graph_projections,
921            analytics_jobs,
922            exports,
923            superblock: crate::physical::SuperblockHeader {
924                format_version: native_state.header.format_version,
925                sequence: native_state.header.sequence,
926                copies: crate::physical::DEFAULT_SUPERBLOCK_COPIES,
927                manifest: crate::physical::ManifestPointers {
928                    oldest: crate::physical::BlockReference {
929                        index: native_state.header.manifest_oldest_root,
930                        checksum: 0,
931                    },
932                    newest: crate::physical::BlockReference {
933                        index: native_state.header.manifest_root,
934                        checksum: 0,
935                    },
936                },
937                free_set: crate::physical::BlockReference {
938                    index: native_state.header.free_set_root,
939                    checksum: 0,
940                },
941                collection_roots: native_state.collection_roots.clone(),
942            },
943            snapshots,
944        }
945    }
946
947    pub(crate) fn reconcile_index_states_with_native_artifacts(
948        &self,
949        mut indexes: Vec<PhysicalIndexState>,
950    ) -> Vec<PhysicalIndexState> {
951        let native_state = self.native_physical_state();
952        let native_artifacts = native_state
953            .as_ref()
954            .and_then(|state| state.registry.as_ref())
955            .map(|registry| registry.vector_artifacts.as_slice())
956            .unwrap_or_default();
957        let artifact_pages = native_state
958            .as_ref()
959            .and_then(|state| state.vector_artifact_pages.as_ref());
960        for index in &mut indexes {
961            let Some(collection) = index.collection.as_deref() else {
962                continue;
963            };
964            let Some(artifact_kind) = Self::native_artifact_kind_for_index(index.kind) else {
965                continue;
966            };
967            let Some(artifact) = native_artifacts.iter().find(|artifact| {
968                artifact.collection == collection && artifact.artifact_kind == artifact_kind
969            }) else {
970                index.build_state = if index.backend == "memory-budget-deferred" {
971                    "deferred-memory-budget"
972                } else {
973                    "metadata-only"
974                }
975                .to_string();
976                continue;
977            };
978            index.entries = artifact.vector_count as usize;
979            index.estimated_memory_bytes = artifact.serialized_bytes;
980            index.backend = format!("{}+native-artifact", index_backend_name(index.kind));
981            index.artifact_kind = Some(artifact.artifact_kind.clone());
982            index.artifact_checksum = Some(artifact.checksum);
983            index.build_state = "artifact-published".to_string();
984            if let Some(pages) = artifact_pages {
985                index.artifact_root_page = pages
986                    .iter()
987                    .find(|page| {
988                        page.collection == artifact.collection
989                            && page.artifact_kind == artifact.artifact_kind
990                    })
991                    .map(|page| page.root_page);
992            }
993        }
994        indexes
995    }
996
997    pub(crate) fn warmup_native_vector_artifact_for_index(
998        &self,
999        index: &PhysicalIndexState,
1000    ) -> Result<(), String> {
1001        let Some(collection) = index.collection.as_deref() else {
1002            return Ok(());
1003        };
1004        let Some(artifact_kind) = Self::native_artifact_kind_for_index(index.kind) else {
1005            return Ok(());
1006        };
1007        self.warmup_native_vector_artifact(collection, Some(artifact_kind))?;
1008        Ok(())
1009    }
1010
1011    pub(crate) fn apply_runtime_native_artifact_to_index_state(
1012        &self,
1013        index: &mut PhysicalIndexState,
1014    ) -> Result<(), String> {
1015        let Some(collection) = index.collection.as_deref() else {
1016            return Ok(());
1017        };
1018        let Some(artifact_kind) = Self::native_artifact_kind_for_index(index.kind) else {
1019            return Ok(());
1020        };
1021        let artifact = self.inspect_native_vector_artifact(collection, Some(artifact_kind))?;
1022        index.entries = artifact
1023            .graph_edge_count
1024            .or(artifact.text_posting_count)
1025            .unwrap_or(artifact.node_count) as usize;
1026        index.estimated_memory_bytes = artifact.byte_len;
1027        index.backend = format!("{}+native-artifact", index_backend_name(index.kind));
1028        index.artifact_kind = Some(artifact.artifact_kind.clone());
1029        index.artifact_checksum = Some(artifact.checksum);
1030        index.build_state = "ready".to_string();
1031        index.artifact_root_page = self
1032            .native_vector_artifact_pages()
1033            .and_then(|pages| {
1034                pages.into_iter().find(|page| {
1035                    page.collection == artifact.collection
1036                        && page.artifact_kind == artifact.artifact_kind
1037                })
1038            })
1039            .map(|page| page.root_page);
1040        Ok(())
1041    }
1042
1043    pub(crate) fn physical_index_state_from_native_state(
1044        &self,
1045        native_state: &NativePhysicalState,
1046        previous: Option<&PhysicalMetadataFile>,
1047    ) -> Vec<PhysicalIndexState> {
1048        let mut fresh = self.physical_index_state();
1049        let Some(registry) = native_state.registry.as_ref() else {
1050            if let Some(previous) = previous {
1051                for index in &previous.indexes {
1052                    if !fresh.iter().any(|candidate| candidate.name == index.name) {
1053                        fresh.push(index.clone());
1054                    }
1055                }
1056            }
1057            return fresh;
1058        };
1059
1060        for index in &mut fresh {
1061            if let Some(native) = registry
1062                .indexes
1063                .iter()
1064                .find(|candidate| candidate.name == index.name)
1065            {
1066                index.enabled = native.enabled;
1067                index.last_refresh_ms = native.last_refresh_ms;
1068                index.backend = native.backend.clone();
1069                index.entries = native.entries as usize;
1070                index.estimated_memory_bytes = native.estimated_memory_bytes;
1071                if index.artifact_kind.is_none() {
1072                    index.artifact_kind = Self::native_artifact_kind_for_index(index.kind)
1073                        .map(|value| value.to_string());
1074                }
1075                if index.build_state == "catalog-derived" {
1076                    index.build_state = "registry-loaded".to_string();
1077                }
1078            }
1079            if index.backend == "memory-budget-deferred"
1080                || previous.is_some_and(|metadata| {
1081                    metadata.indexes.iter().any(|previous| {
1082                        previous.name == index.name && previous.backend == "memory-budget-deferred"
1083                    })
1084                })
1085            {
1086                index.backend = "memory-budget-deferred".to_string();
1087                index.build_state = "deferred-memory-budget".to_string();
1088                index.artifact_root_page = None;
1089                index.artifact_checksum = None;
1090            }
1091        }
1092
1093        for native in &registry.indexes {
1094            if fresh.iter().any(|index| index.name == native.name) {
1095                continue;
1096            }
1097            let Some(kind) = Self::index_kind_from_str(&native.kind) else {
1098                continue;
1099            };
1100            fresh.push(PhysicalIndexState {
1101                name: native.name.clone(),
1102                kind,
1103                collection: native.collection.clone(),
1104                enabled: native.enabled,
1105                entries: native.entries as usize,
1106                estimated_memory_bytes: native.estimated_memory_bytes,
1107                last_refresh_ms: native.last_refresh_ms,
1108                backend: native.backend.clone(),
1109                artifact_kind: Self::native_artifact_kind_for_index(kind)
1110                    .map(|value| value.to_string()),
1111                artifact_root_page: None,
1112                artifact_checksum: None,
1113                build_state: "registry-loaded".to_string(),
1114            });
1115        }
1116
1117        if !registry.indexes_complete {
1118            if let Some(previous) = previous {
1119                for index in &previous.indexes {
1120                    if !fresh.iter().any(|candidate| candidate.name == index.name) {
1121                        fresh.push(index.clone());
1122                    }
1123                }
1124            }
1125        }
1126
1127        fresh
1128    }
1129
1130    pub(crate) fn graph_projections_from_native_state(
1131        &self,
1132        native_state: &NativePhysicalState,
1133    ) -> Vec<PhysicalGraphProjection> {
1134        native_state
1135            .registry
1136            .as_ref()
1137            .map(|registry| {
1138                registry
1139                    .graph_projections
1140                    .iter()
1141                    .map(|projection| PhysicalGraphProjection {
1142                        name: projection.name.clone(),
1143                        created_at_unix_ms: projection.created_at_unix_ms,
1144                        updated_at_unix_ms: projection.updated_at_unix_ms,
1145                        state: "materialized".to_string(),
1146                        source: projection.source.clone(),
1147                        node_labels: projection.node_labels.clone(),
1148                        node_types: projection.node_types.clone(),
1149                        edge_labels: projection.edge_labels.clone(),
1150                        last_materialized_sequence: projection.last_materialized_sequence,
1151                    })
1152                    .collect()
1153            })
1154            .unwrap_or_default()
1155    }
1156
1157    pub(crate) fn analytics_jobs_from_native_state(
1158        &self,
1159        native_state: &NativePhysicalState,
1160    ) -> Vec<PhysicalAnalyticsJob> {
1161        native_state
1162            .registry
1163            .as_ref()
1164            .map(|registry| {
1165                registry
1166                    .analytics_jobs
1167                    .iter()
1168                    .map(|job| PhysicalAnalyticsJob {
1169                        id: job.id.clone(),
1170                        kind: job.kind.clone(),
1171                        state: job.state.clone(),
1172                        projection: job.projection.clone(),
1173                        created_at_unix_ms: job.created_at_unix_ms,
1174                        updated_at_unix_ms: job.updated_at_unix_ms,
1175                        last_run_sequence: job.last_run_sequence,
1176                        metadata: job.metadata.clone(),
1177                    })
1178                    .collect()
1179            })
1180            .unwrap_or_default()
1181    }
1182
1183    pub(crate) fn exports_from_native_state(
1184        &self,
1185        native_state: &NativePhysicalState,
1186    ) -> Vec<ExportDescriptor> {
1187        native_state
1188            .recovery
1189            .as_ref()
1190            .map(|recovery| {
1191                recovery
1192                    .exports
1193                    .iter()
1194                    .map(|export| ExportDescriptor {
1195                        name: export.name.clone(),
1196                        created_at_unix_ms: export.created_at_unix_ms,
1197                        snapshot_id: export.snapshot_id,
1198                        superblock_sequence: export.superblock_sequence,
1199                        data_path: self
1200                            .path()
1201                            .map(|path| {
1202                                crate::physical::PhysicalMetadataFile::export_data_path_for(
1203                                    path,
1204                                    &export.name,
1205                                )
1206                                .display()
1207                                .to_string()
1208                            })
1209                            .unwrap_or_default(),
1210                        metadata_path: self
1211                            .path()
1212                            .map(|path| {
1213                                let export_data_path =
1214                                    crate::physical::PhysicalMetadataFile::export_data_path_for(
1215                                        path,
1216                                        &export.name,
1217                                    );
1218                                crate::physical::PhysicalMetadataFile::metadata_path_for(
1219                                    &export_data_path,
1220                                )
1221                                .display()
1222                                .to_string()
1223                            })
1224                            .unwrap_or_default(),
1225                        collection_count: export.collection_count as usize,
1226                        total_entities: export.total_entities as usize,
1227                    })
1228                    .collect()
1229            })
1230            .unwrap_or_default()
1231    }
1232
1233    pub(crate) fn snapshots_from_native_state(
1234        &self,
1235        native_state: &NativePhysicalState,
1236    ) -> Vec<crate::physical::SnapshotDescriptor> {
1237        let snapshots: Vec<_> = native_state
1238            .recovery
1239            .as_ref()
1240            .map(|recovery| {
1241                recovery
1242                    .snapshots
1243                    .iter()
1244                    .map(|snapshot| crate::physical::SnapshotDescriptor {
1245                        snapshot_id: snapshot.snapshot_id,
1246                        created_at_unix_ms: snapshot.created_at_unix_ms,
1247                        superblock_sequence: snapshot.superblock_sequence,
1248                        collection_count: snapshot.collection_count as usize,
1249                        total_entities: snapshot.total_entities as usize,
1250                    })
1251                    .collect()
1252            })
1253            .unwrap_or_default();
1254        if !snapshots.is_empty() {
1255            return snapshots;
1256        }
1257
1258        let now = SystemTime::now()
1259            .duration_since(UNIX_EPOCH)
1260            .unwrap_or_default()
1261            .as_millis();
1262        let (collection_count, total_entities) = native_state
1263            .catalog
1264            .as_ref()
1265            .map(|catalog| {
1266                (
1267                    catalog.collection_count as usize,
1268                    catalog.total_entities as usize,
1269                )
1270            })
1271            .unwrap_or_else(|| {
1272                let catalog = self.catalog_snapshot();
1273                (catalog.total_collections, catalog.total_entities)
1274            });
1275
1276        vec![crate::physical::SnapshotDescriptor {
1277            snapshot_id: native_state.header.sequence,
1278            created_at_unix_ms: now,
1279            superblock_sequence: native_state.header.sequence,
1280            collection_count,
1281            total_entities,
1282        }]
1283    }
1284
1285    fn index_kind_from_str(value: &str) -> Option<crate::index::IndexKind> {
1286        match value {
1287            "btree" => Some(crate::index::IndexKind::BTree),
1288            "vector.hnsw" => Some(crate::index::IndexKind::VectorHnsw),
1289            "vector.inverted" => Some(crate::index::IndexKind::VectorInverted),
1290            "vector.turbo" => Some(crate::index::IndexKind::VectorTurbo),
1291            "graph.adjacency" => Some(crate::index::IndexKind::GraphAdjacency),
1292            "text.fulltext" => Some(crate::index::IndexKind::FullText),
1293            "document.pathvalue" => Some(crate::index::IndexKind::DocumentPathValue),
1294            "search.hybrid" => Some(crate::index::IndexKind::HybridSearch),
1295            _ => None,
1296        }
1297    }
1298
1299    pub(crate) fn native_artifact_kind_for_index(kind: IndexKind) -> Option<&'static str> {
1300        match kind {
1301            IndexKind::VectorHnsw => Some("hnsw"),
1302            IndexKind::VectorInverted => Some("ivf"),
1303            IndexKind::VectorTurbo => Some("turboquant"),
1304            IndexKind::GraphAdjacency => Some("graph.adjacency"),
1305            IndexKind::FullText => Some("text.fulltext"),
1306            IndexKind::DocumentPathValue => Some("document.pathvalue"),
1307            _ => None,
1308        }
1309    }
1310
1311    fn index_is_declared(&self, name: &str) -> bool {
1312        self.physical_metadata()
1313            .map(|metadata| metadata.indexes.iter().any(|index| index.name == name))
1314            .unwrap_or(false)
1315    }
1316
1317    pub(crate) fn graph_projection_is_declared(&self, name: &str) -> bool {
1318        self.physical_metadata()
1319            .map(|metadata| {
1320                metadata
1321                    .graph_projections
1322                    .iter()
1323                    .any(|projection| projection.name == name)
1324            })
1325            .unwrap_or(false)
1326    }
1327
1328    pub(crate) fn graph_projection_is_operational(&self, name: &str) -> bool {
1329        self.operational_graph_projections()
1330            .into_iter()
1331            .any(|projection| projection.name == name && projection.state == "materialized")
1332    }
1333
1334    pub(crate) fn analytics_job_id(kind: &str, projection: Option<&str>) -> String {
1335        match projection {
1336            Some(projection) => format!("{kind}::{projection}"),
1337            None => format!("{kind}::global"),
1338        }
1339    }
1340
1341    pub(crate) fn update_physical_metadata<T, F>(
1342        &self,
1343        mutator: F,
1344    ) -> Result<T, Box<dyn std::error::Error>>
1345    where
1346        F: FnOnce(&mut PhysicalMetadataFile) -> T,
1347    {
1348        if self.options.mode != StorageMode::Persistent {
1349            return Err("physical metadata operations require persistent mode".into());
1350        }
1351        if self.options.read_only {
1352            return Err("physical metadata operations are not allowed in read-only mode".into());
1353        }
1354        let Some(path) = self.path() else {
1355            return Err("database path is not available".into());
1356        };
1357
1358        // #2376: the metadata sidecar and the native header pages are
1359        // published read-modify-write. Two unserialised publishers lost each
1360        // other's collection contracts and double-freed the header pages
1361        // (a freed page came back as a B-tree page). Every publisher takes
1362        // the store's reentrant publication lock, which `persist()` already
1363        // takes, so this adds no lock-order edge.
1364        let _publication = self.store.vault_publication_lock.lock();
1365        let mut metadata = self.load_or_bootstrap_physical_metadata(true)?;
1366
1367        if metadata.indexes.is_empty() {
1368            metadata.indexes = self.physical_index_state();
1369        }
1370        metadata.superblock.collection_roots = self.physical_collection_roots();
1371
1372        let result = mutator(&mut metadata);
1373        metadata.save_for_data_path(path)?;
1374        self.persist_native_physical_header(&metadata)?;
1375        Ok(result)
1376    }
1377
1378    pub(crate) fn persist_native_physical_header(
1379        &self,
1380        metadata: &PhysicalMetadataFile,
1381    ) -> Result<(), Box<dyn std::error::Error>> {
1382        if !self.paged_mode {
1383            return Ok(());
1384        }
1385
1386        let _publication = self.store.vault_publication_lock.lock();
1387        let existing_page = self
1388            .store
1389            .physical_file_header()
1390            .map(|header| header.collection_roots_page)
1391            .filter(|page| *page != 0);
1392        let existing_registry_page = self
1393            .store
1394            .physical_file_header()
1395            .map(|header| header.registry_page)
1396            .filter(|page| *page != 0);
1397        let existing_recovery_page = self
1398            .store
1399            .physical_file_header()
1400            .map(|header| header.recovery_page)
1401            .filter(|page| *page != 0);
1402        let existing_catalog_page = self
1403            .store
1404            .physical_file_header()
1405            .map(|header| header.catalog_page)
1406            .filter(|page| *page != 0);
1407        let existing_metadata_state_page = self
1408            .store
1409            .physical_file_header()
1410            .map(|header| header.metadata_state_page)
1411            .filter(|page| *page != 0);
1412        let existing_vector_artifact_page = self
1413            .store
1414            .physical_file_header()
1415            .map(|header| header.vector_artifact_page)
1416            .filter(|page| *page != 0);
1417        let existing_manifest_page = self
1418            .store
1419            .physical_file_header()
1420            .map(|header| header.manifest_page)
1421            .filter(|page| *page != 0);
1422        let (manifest_page, manifest_checksum) = self.store.write_native_manifest_summary(
1423            metadata.superblock.sequence,
1424            &metadata.manifest_events,
1425            existing_manifest_page,
1426        )?;
1427        let (collection_roots_page, collection_roots_checksum) = self
1428            .store
1429            .write_native_collection_roots(&metadata.superblock.collection_roots, existing_page)?;
1430        // Build payload artifacts once, on the publication path. Observers use
1431        // the persisted summaries instead of rebuilding search indexes.
1432        let build = self.native_vector_artifact_records()?;
1433        let vector_artifact_records = build.records;
1434        let mut metadata = metadata.clone();
1435        for index in &mut metadata.indexes {
1436            let Some(kind) = Self::native_artifact_kind_for_index(index.kind) else {
1437                continue;
1438            };
1439            let Some(collection) = &index.collection else {
1440                continue;
1441            };
1442            if build.deferred_collections.contains(collection) {
1443                index.backend = "memory-budget-deferred".to_string();
1444                index.build_state = "deferred-memory-budget".to_string();
1445                index.artifact_kind = Some(kind.to_string());
1446                index.artifact_root_page = None;
1447                index.artifact_checksum = None;
1448            } else if let Some((summary, _)) =
1449                vector_artifact_records.iter().find(|(summary, _)| {
1450                    &summary.collection == collection && summary.artifact_kind == kind
1451                })
1452            {
1453                index.backend = format!("{}+native-artifact", index_backend_name(index.kind));
1454                index.build_state = "artifact-published".to_string();
1455                index.artifact_kind = Some(kind.to_string());
1456                index.artifact_checksum = Some(summary.checksum);
1457            }
1458        }
1459        let metadata = &metadata;
1460        let artifact_summaries = vector_artifact_records
1461            .iter()
1462            .map(|(summary, _)| summary.clone())
1463            .collect::<Vec<_>>();
1464        let registry_summary = self.native_registry_summary_from_metadata(
1465            metadata,
1466            &artifact_summaries,
1467            artifact_summaries.len(),
1468        );
1469        let (registry_page, registry_checksum) = self
1470            .store
1471            .write_native_registry_summary(&registry_summary, existing_registry_page)?;
1472        let recovery_summary = Self::native_recovery_summary_from_metadata(metadata);
1473        let (recovery_page, recovery_checksum) = self
1474            .store
1475            .write_native_recovery_summary(&recovery_summary, existing_recovery_page)?;
1476        let catalog_summary = Self::native_catalog_summary_from_metadata(metadata);
1477        let (catalog_page, catalog_checksum) = self
1478            .store
1479            .write_native_catalog_summary(&catalog_summary, existing_catalog_page)?;
1480        let metadata_state_summary = Self::native_metadata_state_summary_from_metadata(metadata);
1481        let (metadata_state_page, metadata_state_checksum) =
1482            self.store.write_native_metadata_state_summary(
1483                &metadata_state_summary,
1484                existing_metadata_state_page,
1485            )?;
1486        let vector_artifact_payloads = vector_artifact_records
1487            .iter()
1488            .map(|(summary, bytes)| {
1489                (
1490                    summary.collection.clone(),
1491                    summary.artifact_kind.clone(),
1492                    bytes.clone(),
1493                )
1494            })
1495            .collect::<Vec<_>>();
1496        let (vector_artifact_page, vector_artifact_checksum, _vector_artifact_pages) =
1497            self.store.write_native_vector_artifact_store(
1498                &vector_artifact_payloads,
1499                existing_vector_artifact_page,
1500            )?;
1501        let mut header = Self::native_header_from_metadata(metadata);
1502        header.manifest_page = manifest_page;
1503        header.manifest_checksum = manifest_checksum;
1504        header.collection_roots_page = collection_roots_page;
1505        header.collection_roots_checksum = collection_roots_checksum;
1506        header.registry_page = registry_page;
1507        header.registry_checksum = registry_checksum;
1508        header.recovery_page = recovery_page;
1509        header.recovery_checksum = recovery_checksum;
1510        header.catalog_page = catalog_page;
1511        header.catalog_checksum = catalog_checksum;
1512        header.metadata_state_page = metadata_state_page;
1513        header.metadata_state_checksum = metadata_state_checksum;
1514        header.vector_artifact_page = vector_artifact_page;
1515        header.vector_artifact_checksum = vector_artifact_checksum;
1516        if let Some(path) = self.path() {
1517            metadata.save_for_data_path(path)?;
1518        }
1519        self.store.update_physical_file_header(header)?;
1520        self.store.persist()?;
1521        self.store.publish_detached_native_blob_pages()?;
1522        Ok(())
1523    }
1524
1525    pub(crate) fn native_header_from_metadata(
1526        metadata: &PhysicalMetadataFile,
1527    ) -> PhysicalFileHeader {
1528        PhysicalFileHeader {
1529            format_version: metadata.superblock.format_version,
1530            sequence: metadata.superblock.sequence,
1531            manifest_oldest_root: metadata.superblock.manifest.oldest.index,
1532            manifest_root: metadata.superblock.manifest.newest.index,
1533            free_set_root: metadata.superblock.free_set.index,
1534            manifest_page: 0,
1535            manifest_checksum: 0,
1536            collection_roots_page: 0,
1537            collection_roots_checksum: 0,
1538            collection_root_count: metadata.superblock.collection_roots.len() as u32,
1539            snapshot_count: metadata.snapshots.len() as u32,
1540            index_count: metadata.indexes.len() as u32,
1541            catalog_collection_count: metadata.catalog.total_collections as u32,
1542            catalog_total_entities: metadata.catalog.total_entities as u64,
1543            export_count: metadata.exports.len() as u32,
1544            graph_projection_count: metadata.graph_projections.len() as u32,
1545            analytics_job_count: metadata.analytics_jobs.len() as u32,
1546            manifest_event_count: metadata.manifest_events.len() as u32,
1547            registry_page: 0,
1548            registry_checksum: 0,
1549            recovery_page: 0,
1550            recovery_checksum: 0,
1551            catalog_page: 0,
1552            catalog_checksum: 0,
1553            metadata_state_page: 0,
1554            metadata_state_checksum: 0,
1555            vector_artifact_page: 0,
1556            vector_artifact_checksum: 0,
1557        }
1558    }
1559}
1560
1561fn current_unix_ms() -> u64 {
1562    SystemTime::now()
1563        .duration_since(UNIX_EPOCH)
1564        .unwrap_or_default()
1565        .as_millis()
1566        .min(u128::from(u64::MAX)) as u64
1567}
1568
1569#[cfg(test)]
1570mod catalog_observation_tests {
1571    use super::*;
1572
1573    #[test]
1574    fn legacy_paged_drop_does_not_resurrect_derived_index_descriptors() {
1575        let directory = tempfile::tempdir().expect("temporary database");
1576        let path = directory.path().join("drop-descriptors.rdb");
1577        let options = RedDBOptions::persistent(&path)
1578            .with_memory_budget(64 * 1024 * 1024)
1579            .with_storage_profile(crate::StorageDeployPreset::PrimaryReplicaDev.selection())
1580            .expect("paged profile");
1581        {
1582            let runtime = crate::RedDBRuntime::with_options(options).expect("paged runtime");
1583            runtime
1584                .execute_query("CREATE TABLE retained_source (value INTEGER)")
1585                .expect("retained table");
1586            runtime
1587                .execute_query("INSERT INTO retained_source (value) VALUES (17)")
1588                .expect("retained row");
1589            runtime.checkpoint().expect("initial checkpoint");
1590        }
1591        // Managed legacy files also reopen with the default embedded options.
1592        // That path edits contracts without rebuilding every native artifact.
1593        let options = RedDBOptions::persistent(&path).with_memory_budget(64 * 1024 * 1024);
1594        {
1595            let runtime =
1596                crate::RedDBRuntime::with_options(options.clone()).expect("legacy reopen");
1597            assert!(runtime.db().is_paged());
1598            assert_eq!(
1599                runtime.db().options().storage_profile.deploy_profile,
1600                crate::storage::DeployProfile::Embedded
1601            );
1602            // Exercise both distinct names and recreation after a removal proof.
1603            for cycle in 0..6 {
1604                let table = format!("discarded_source_{}", cycle % 4);
1605                runtime
1606                    .execute_query(&format!("CREATE TABLE {table} (value INTEGER)"))
1607                    .expect("temporary table");
1608                runtime.checkpoint().expect("publish derived descriptors");
1609                let before = runtime.db().physical_metadata().expect("physical metadata");
1610                assert!(before
1611                    .indexes
1612                    .iter()
1613                    .any(|index| index.collection.as_deref() == Some(table.as_str())));
1614                runtime
1615                    .execute_query(&format!("DROP TABLE {table}"))
1616                    .expect("drop temporary table");
1617                for _ in 0..2 {
1618                    runtime.checkpoint().expect("checkpoint after drop");
1619                    let metadata = runtime.db().physical_metadata().expect("physical metadata");
1620                    assert!(
1621                        !metadata.indexes.iter().any(|index| index
1622                            .collection
1623                            .as_deref()
1624                            .is_some_and(|name| name.starts_with("discarded_source_"))),
1625                        "a dropped collection's derived descriptors were resurrected"
1626                    );
1627                    let native = runtime
1628                        .db()
1629                        .native_registry_summary()
1630                        .expect("native registry");
1631                    assert!(!native.indexes.iter().any(|index| index
1632                        .collection
1633                        .as_deref()
1634                        .is_some_and(|name| name.starts_with("discarded_source_"))));
1635                }
1636                if cycle == 0 {
1637                    // Model the consistent sidecar/native pair left by an older
1638                    // writer. Its final Remove event is a durable removal proof.
1639                    let ghost = before
1640                        .indexes
1641                        .iter()
1642                        .find(|index| index.collection.as_deref() == Some(table.as_str()))
1643                        .expect("old derived descriptor")
1644                        .clone();
1645                    runtime
1646                        .db()
1647                        .update_physical_metadata(|metadata| {
1648                            assert!(metadata.manifest_events.iter().any(|event| {
1649                                event.collection == table
1650                                    && matches!(
1651                                        &event.kind,
1652                                        crate::physical::ManifestEventKind::Remove
1653                                    )
1654                            }));
1655                            metadata.indexes.push(ghost.clone());
1656                            let mut orphan = ghost.clone();
1657                            orphan.name = "audited_legacy_orphan".into();
1658                            orphan.collection = Some("audited_missing_source".into());
1659                            orphan.artifact_root_page = None;
1660                            orphan.artifact_checksum = None;
1661                            metadata.indexes.push(orphan.clone());
1662                            orphan.name = "global_descriptor".into();
1663                            orphan.collection = None;
1664                            metadata.indexes.push(orphan);
1665                        })
1666                        .expect("simulate old metadata without deleting any rows");
1667                    runtime
1668                        .checkpoint()
1669                        .expect("repair a confirmed old removal");
1670                }
1671                let metadata = runtime.db().physical_metadata().expect("physical metadata");
1672                assert!(metadata
1673                    .indexes
1674                    .iter()
1675                    .any(|index| index.name == "audited_legacy_orphan"));
1676                assert!(metadata
1677                    .indexes
1678                    .iter()
1679                    .any(|index| index.name == "global_descriptor"));
1680            }
1681        }
1682        let runtime = crate::RedDBRuntime::with_options(options).expect("second legacy reopen");
1683        assert_eq!(
1684            runtime
1685                .db()
1686                .store()
1687                .get_collection("retained_source")
1688                .expect("retained source")
1689                .count(),
1690            1
1691        );
1692        let metadata = runtime.db().physical_metadata().expect("physical metadata");
1693        assert!(!metadata.indexes.iter().any(|index| index
1694            .collection
1695            .as_deref()
1696            .is_some_and(|name| name.starts_with("discarded_source_"))));
1697        assert!(metadata
1698            .indexes
1699            .iter()
1700            .any(|index| index.collection.as_deref() == Some("retained_source")));
1701        assert!(metadata
1702            .indexes
1703            .iter()
1704            .any(|index| index.name == "audited_legacy_orphan"));
1705        assert!(metadata
1706            .indexes
1707            .iter()
1708            .any(|index| index.name == "global_descriptor"));
1709    }
1710
1711    #[test]
1712    fn index_reconciliation_reads_one_native_observation() {
1713        let directory = tempfile::tempdir().expect("temporary database");
1714        let runtime = crate::RedDBRuntime::with_options(
1715            RedDBOptions::persistent(directory.path().join("catalog.rdb"))
1716                .with_storage_profile(crate::StorageDeployPreset::Serverless.selection())
1717                .expect("paged profile")
1718                .with_capability(Capability::FullText),
1719        )
1720        .expect("runtime");
1721        for number in 0..4 {
1722            runtime
1723                .execute_query(&format!("CREATE TABLE source_{number} (payload TEXT)"))
1724                .expect("table");
1725            runtime
1726                .execute_query(&format!(
1727                    "INSERT INTO source_{number} (payload) VALUES ('searchable payload')"
1728                ))
1729                .expect("row");
1730        }
1731        runtime.checkpoint().expect("published artifacts");
1732        let db = runtime.db();
1733        let pager = db.store.pager().expect("paged store");
1734        let reads = || {
1735            let stats = pager.cache_stats();
1736            stats.hits + stats.misses
1737        };
1738        let before = reads();
1739        let native = db.native_physical_state().expect("native state");
1740        let observation_reads = reads() - before;
1741        let artifacts = native.registry.expect("registry").vector_artifacts;
1742        let pages = native.vector_artifact_pages.expect("artifact pages");
1743        let indexes: Vec<_> = artifacts
1744            .iter()
1745            .filter(|artifact| artifact.artifact_kind == "text.fulltext")
1746            .map(|artifact| PhysicalIndexState {
1747                name: format!("{}::fulltext", artifact.collection),
1748                kind: IndexKind::FullText,
1749                collection: Some(artifact.collection.clone()),
1750                enabled: true,
1751                entries: 0,
1752                estimated_memory_bytes: 0,
1753                last_refresh_ms: None,
1754                backend: "catalog-derived".into(),
1755                artifact_kind: None,
1756                artifact_root_page: None,
1757                artifact_checksum: None,
1758                build_state: "catalog-derived".into(),
1759            })
1760            .collect();
1761        assert!(indexes.len() >= 4, "fixture must publish all four indexes");
1762        let declarations = indexes.clone();
1763        let before = reads();
1764        let indexes = db.reconcile_index_states_with_native_artifacts(indexes);
1765        let reconciliation_reads = reads() - before;
1766        for index in indexes {
1767            let artifact = artifacts
1768                .iter()
1769                .find(|artifact| {
1770                    Some(&artifact.collection) == index.collection.as_ref()
1771                        && artifact.artifact_kind == "text.fulltext"
1772                })
1773                .expect("published artifact");
1774            let page = pages
1775                .iter()
1776                .find(|page| {
1777                    page.collection == artifact.collection
1778                        && page.artifact_kind == artifact.artifact_kind
1779                })
1780                .expect("artifact root");
1781            assert_eq!(index.artifact_root_page, Some(page.root_page));
1782            assert_eq!(index.artifact_checksum, Some(artifact.checksum));
1783            assert_eq!(index.entries, artifact.vector_count as usize);
1784            assert_eq!(index.build_state, "artifact-published");
1785        }
1786        assert_eq!(
1787            reconciliation_reads, observation_reads,
1788            "catalog reconciliation must not reread artifact pages per index"
1789        );
1790
1791        // An undecodable artifact directory must retain the existing fallback,
1792        // not reuse the successful observation from the preceding call.
1793        let page_id = native.header.vector_artifact_page;
1794        let mut page = pager.read_page(page_id).expect("artifact directory");
1795        page.as_bytes_mut()[crate::storage::engine::HEADER_SIZE..].fill(0);
1796        pager
1797            .write_page(page_id, page)
1798            .expect("corrupt artifact directory");
1799        assert!(db.native_vector_artifact_pages().is_none());
1800        let indexes = db.reconcile_index_states_with_native_artifacts(declarations);
1801        for index in indexes {
1802            assert_eq!(index.artifact_root_page, None);
1803            assert!(
1804                index.artifact_checksum.is_some(),
1805                "registry remains available"
1806            );
1807            assert_eq!(index.build_state, "artifact-published");
1808        }
1809    }
1810}