Skip to main content

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

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