Skip to main content

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

1use super::*;
2
3impl RedDB {
4    pub(crate) fn write_column_block_page(
5        &self,
6        bytes: &[u8],
7    ) -> Result<crate::storage::engine::PageLocation, Box<dyn std::error::Error>> {
8        use crate::storage::engine::PageType;
9
10        let store = self.store();
11        let Some(pager) = store.pager() else {
12            return Ok(crate::storage::engine::PageLocation::new(
13                0,
14                0,
15                bytes.len() as u32,
16            ));
17        };
18        let content_size = crate::storage::engine::page::CONTENT_SIZE;
19        let page_count = bytes.len().max(1).div_ceil(content_size);
20        let mut first_page_id = None;
21        for page_index in 0..page_count {
22            let mut page = pager.allocate_page(PageType::ColumnBlock)?;
23            let page_id = page.page_id();
24            if let Some(first) = first_page_id {
25                let expected = first + page_index as u32;
26                if page_id != expected {
27                    return Err(format!(
28                        "column block page allocation was not contiguous: got {page_id}, expected {expected}"
29                    )
30                    .into());
31                }
32            } else {
33                first_page_id = Some(page_id);
34            }
35            let start = page_index * content_size;
36            let end = (start + content_size).min(bytes.len());
37            if start < end {
38                page.content_mut()[..end - start].copy_from_slice(&bytes[start..end]);
39            }
40            pager.write_page_encrypted(page_id, page)?;
41        }
42        Ok(crate::storage::engine::PageLocation::new(
43            first_page_id.expect("page_count is at least one"),
44            0,
45            bytes.len() as u32,
46        ))
47    }
48
49    pub(crate) fn read_column_block_page(
50        &self,
51        loc: crate::storage::engine::PageLocation,
52    ) -> Result<Option<Vec<u8>>, Box<dyn std::error::Error>> {
53        let store = self.store();
54        let Some(pager) = store.pager() else {
55            return Ok(None);
56        };
57        let content_size = crate::storage::engine::page::CONTENT_SIZE;
58        let mut remaining = loc.length as usize;
59        let mut page_id = loc.page_id;
60        let mut offset = loc.offset as usize;
61        let mut out = Vec::with_capacity(remaining);
62        while remaining > 0 {
63            let page = pager.read_page_decrypted(page_id)?;
64            if page.page_type()? != crate::storage::engine::PageType::ColumnBlock {
65                return Err(format!("page {page_id} is not a ColumnBlock page").into());
66            }
67            if offset > content_size {
68                return Err(format!(
69                    "column block page location offset out of bounds: {offset} > {content_size}"
70                )
71                .into());
72            }
73            let available = content_size - offset;
74            let take = remaining.min(available);
75            out.extend_from_slice(&page.content()[offset..offset + take]);
76            remaining -= take;
77            page_id = page_id.saturating_add(1);
78            offset = 0;
79        }
80        Ok(Some(out))
81    }
82
83    pub fn tree_definitions(&self) -> Vec<crate::physical::PhysicalTreeDefinition> {
84        self.physical_metadata()
85            .map(|metadata| metadata.tree_definitions)
86            .unwrap_or_default()
87    }
88
89    pub fn tree_definition(
90        &self,
91        collection: &str,
92        name: &str,
93    ) -> Option<crate::physical::PhysicalTreeDefinition> {
94        self.tree_definitions()
95            .into_iter()
96            .find(|definition| definition.collection == collection && definition.name == name)
97    }
98
99    pub fn save_tree_definition(
100        &self,
101        definition: crate::physical::PhysicalTreeDefinition,
102    ) -> Result<crate::physical::PhysicalTreeDefinition, Box<dyn std::error::Error>> {
103        self.update_physical_metadata(|metadata| {
104            if let Some(existing) = metadata.tree_definitions.iter_mut().find(|existing| {
105                existing.collection == definition.collection && existing.name == definition.name
106            }) {
107                *existing = definition.clone();
108            } else {
109                metadata.tree_definitions.push(definition.clone());
110            }
111            metadata.tree_definitions.sort_by(|left, right| {
112                left.collection
113                    .cmp(&right.collection)
114                    .then_with(|| left.name.cmp(&right.name))
115            });
116            definition.clone()
117        })
118    }
119
120    pub fn remove_tree_definition(
121        &self,
122        collection: &str,
123        name: &str,
124    ) -> Result<Option<crate::physical::PhysicalTreeDefinition>, Box<dyn std::error::Error>> {
125        self.update_physical_metadata(|metadata| {
126            metadata
127                .tree_definitions
128                .iter()
129                .position(|definition| {
130                    definition.collection == collection && definition.name == name
131                })
132                .map(|index| metadata.tree_definitions.remove(index))
133        })
134    }
135
136    pub fn collection_default_ttl_ms(&self, collection: &str) -> Option<u64> {
137        self.collection_ttl_defaults_ms
138            .read()
139            .ok()
140            .and_then(|defaults| defaults.get(collection).copied())
141    }
142
143    pub fn set_collection_default_ttl_ms(&self, collection: impl Into<String>, ttl_ms: u64) {
144        if let Ok(mut defaults) = self.collection_ttl_defaults_ms.write() {
145            defaults.insert(collection.into(), ttl_ms);
146        }
147    }
148
149    pub fn clear_collection_default_ttl_ms(&self, collection: &str) {
150        if let Ok(mut defaults) = self.collection_ttl_defaults_ms.write() {
151            defaults.remove(collection);
152        }
153    }
154
155    pub fn collection_contracts(&self) -> Vec<crate::physical::CollectionContract> {
156        self.contract_cache_map()
157            .values()
158            .map(|arc| (**arc).clone())
159            .collect()
160    }
161
162    pub fn collection_contract(
163        &self,
164        collection: &str,
165    ) -> Option<crate::physical::CollectionContract> {
166        self.contract_cache_map()
167            .get(collection)
168            .map(|arc| (**arc).clone())
169    }
170
171    /// Arc-bumping variant — callers that only need read access skip
172    /// the per-call full `CollectionContract` clone. Deep clones cost
173    /// ~200-400 ns each (vec of declared columns + string allocs);
174    /// the UPDATE fast path hits this 2-3× per mutation.
175    pub fn collection_contract_arc(
176        &self,
177        collection: &str,
178    ) -> Option<std::sync::Arc<crate::physical::CollectionContract>> {
179        self.contract_cache_map()
180            .get(collection)
181            .map(std::sync::Arc::clone)
182    }
183
184    /// Return (or lazily build) the in-memory contract map.
185    /// Reparses PhysicalMetadataFile JSON only once per invalidation.
186    /// Called 3× per insert (ensure_model, enforce_uniqueness, normalize_fields)
187    /// so the cache is load-bearing for insert throughput.
188    fn contract_cache_map(
189        &self,
190    ) -> std::sync::Arc<
191        std::collections::HashMap<String, std::sync::Arc<crate::physical::CollectionContract>>,
192    > {
193        self.collection_contract_cache.get_or_load(|| {
194            self.physical_metadata()
195                .map(|metadata| metadata.collection_contracts)
196                .unwrap_or_default()
197        })
198    }
199
200    pub(crate) fn invalidate_collection_contract_cache(&self) {
201        self.collection_contract_cache.invalidate();
202    }
203
204    /// Single-file artifact only: mirror the current contract set into the
205    /// store's auxiliary-metadata blob so it rides the binary dump. Without
206    /// this the contracts live only in the in-memory cache and a collection's
207    /// declared model (e.g. `kv`) is lost on reopen — recovery would re-infer
208    /// it from the stored entities and a KV collection would come back as a
209    /// table. No-op for other profiles, which persist contracts via the
210    /// metadata sidecar.
211    pub(crate) fn sync_single_file_contract_blob(&self) {
212        let contracts = self.collection_contracts();
213        let bytes = crate::physical::serialize_collection_contracts(&contracts);
214        self.store().set_aux_metadata(bytes);
215    }
216
217    /// Seed the in-memory contract cache from the store's auxiliary-metadata
218    /// blob (single-file artifact). Called on open so a collection's declared
219    /// model survives a restart. Does nothing when the blob is absent (older
220    /// V9 dumps) or empty.
221    pub(crate) fn seed_contract_cache_from_store_aux(&self) -> std::io::Result<()> {
222        let bytes = self.store().aux_metadata();
223        if bytes.is_empty() {
224            return Ok(());
225        }
226        let contracts = crate::physical::deserialize_collection_contracts(&bytes)?;
227        let map: std::collections::HashMap<_, _> = contracts
228            .into_iter()
229            .map(|contract| (contract.name.clone(), std::sync::Arc::new(contract)))
230            .collect();
231        // Restore the per-collection state derived from contracts (context
232        // index enablement + default TTLs) so the full collection definition —
233        // not just the declared model — survives a restart.
234        let store = self.store();
235        let index = store.context_index();
236        if let Ok(mut defaults) = self.collection_ttl_defaults_ms.write() {
237            for contract in map.values() {
238                index.set_collection_enabled(&contract.name, contract.context_index_enabled);
239                if let Some(ttl_ms) = contract.default_ttl_ms {
240                    defaults.insert(contract.name.clone(), ttl_ms);
241                }
242            }
243        }
244        self.collection_contract_cache.replace(map);
245        Ok(())
246    }
247
248    fn update_collection_contract_metadata<T, F>(
249        &self,
250        mutator: F,
251    ) -> Result<T, Box<dyn std::error::Error>>
252    where
253        F: FnOnce(&mut PhysicalMetadataFile) -> T,
254    {
255        if !self.paged_mode
256            || self.options.storage_profile.deploy_profile
257                != crate::storage::DeployProfile::Embedded
258            || self.options.storage_profile.packaging
259                != crate::storage::StoragePackaging::SingleFile
260        {
261            return self.update_physical_metadata(mutator);
262        }
263        if self.options.read_only {
264            return Err("physical metadata operations are not allowed in read-only mode".into());
265        }
266        let Some(path) = self.path() else {
267            return Err("database path is not available".into());
268        };
269
270        // Legacy paged files keep contracts in the durable metadata sidecar,
271        // not the embedded dump's auxiliary blob or native artifact summaries.
272        // A definition edit must not rebuild every collection's search indexes.
273        // Retain publication serialization and the canonical atomic writer.
274        let _publication = self.store.vault_publication_lock.lock();
275        let mut metadata = self.load_or_bootstrap_physical_metadata(false)?;
276        let result = mutator(&mut metadata);
277        metadata.save_for_data_path(path)?;
278        Ok(result)
279    }
280
281    pub fn save_collection_contract(
282        &self,
283        contract: crate::physical::CollectionContract,
284    ) -> Result<crate::physical::CollectionContract, Box<dyn std::error::Error>> {
285        if let Ok(mut defaults) = self.collection_ttl_defaults_ms.write() {
286            if let Some(ttl_ms) = contract.default_ttl_ms {
287                defaults.insert(contract.name.clone(), ttl_ms);
288            } else {
289                defaults.remove(&contract.name);
290            }
291        }
292
293        self.store()
294            .context_index()
295            .set_collection_enabled(&contract.name, contract.context_index_enabled);
296        if let Some(manager) = self.store().get_collection(&contract.name) {
297            let columns = contract
298                .declared_columns
299                .iter()
300                .map(|column| column.name.clone())
301                .collect();
302            manager.set_column_schema_if_empty(columns);
303        }
304
305        // Existing paged files can be opened with embedded options. Their
306        // checkpoints do not encode the binary dump's auxiliary contract blob.
307        if self.options.storage_profile.deploy_profile == crate::storage::DeployProfile::Embedded
308            && self.options.storage_profile.packaging
309                == crate::storage::StoragePackaging::SingleFile
310            && !self.paged_mode
311        {
312            self.collection_contract_cache.insert(contract.clone());
313            self.sync_single_file_contract_blob();
314            return Ok(contract);
315        }
316
317        self.invalidate_collection_contract_cache();
318
319        let saved = self.update_collection_contract_metadata(|metadata| {
320            if let Some(existing) = metadata
321                .collection_contracts
322                .iter_mut()
323                .find(|existing| existing.name == contract.name)
324            {
325                *existing = contract.clone();
326            } else {
327                metadata.collection_contracts.push(contract.clone());
328            }
329            metadata
330                .collection_contracts
331                .sort_by(|left, right| left.name.cmp(&right.name));
332
333            if let Some(ttl_ms) = contract.default_ttl_ms {
334                metadata
335                    .collection_ttl_defaults_ms
336                    .insert(contract.name.clone(), ttl_ms);
337            } else {
338                metadata.collection_ttl_defaults_ms.remove(&contract.name);
339            }
340
341            contract.clone()
342        })?;
343        self.invalidate_collection_contract_cache();
344        Ok(saved)
345    }
346
347    pub fn remove_collection_contract(
348        &self,
349        collection: &str,
350    ) -> Result<Option<crate::physical::CollectionContract>, Box<dyn std::error::Error>> {
351        if let Ok(mut defaults) = self.collection_ttl_defaults_ms.write() {
352            defaults.remove(collection);
353        }
354
355        self.store()
356            .context_index()
357            .set_collection_enabled(collection, false);
358
359        if self.options.storage_profile.deploy_profile == crate::storage::DeployProfile::Embedded
360            && self.options.storage_profile.packaging
361                == crate::storage::StoragePackaging::SingleFile
362            && !self.paged_mode
363        {
364            let removed = self.collection_contract_cache.remove(collection);
365            self.sync_single_file_contract_blob();
366            return Ok(removed);
367        }
368
369        self.invalidate_collection_contract_cache();
370
371        let removed = self.update_collection_contract_metadata(|metadata| {
372            let removed = metadata
373                .collection_contracts
374                .iter()
375                .position(|contract| contract.name == collection)
376                .map(|index| metadata.collection_contracts.remove(index));
377            metadata.collection_ttl_defaults_ms.remove(collection);
378            metadata
379                .indexes
380                .retain(|index| index.collection.as_deref() != Some(collection));
381            removed
382        })?;
383        self.invalidate_collection_contract_cache();
384        Ok(removed)
385    }
386
387    pub(crate) fn collection_ttl_defaults_snapshot(&self) -> BTreeMap<String, u64> {
388        self.collection_ttl_defaults_ms
389            .read()
390            .map(|defaults| {
391                defaults
392                    .iter()
393                    .map(|(collection, ttl_ms)| (collection.clone(), *ttl_ms))
394                    .collect()
395            })
396            .unwrap_or_default()
397    }
398
399    pub(crate) fn load_collection_ttl_defaults_from_metadata(&self) {
400        let metadata = self.physical_metadata();
401        let defaults = metadata
402            .as_ref()
403            .map(|m| m.collection_ttl_defaults_ms.clone())
404            .unwrap_or_default();
405
406        if let Ok(mut current) = self.collection_ttl_defaults_ms.write() {
407            current.clear();
408            current.extend(defaults);
409        }
410
411        if let Some(metadata) = metadata {
412            let store = self.store();
413            let index = store.context_index();
414            for contract in &metadata.collection_contracts {
415                index.set_collection_enabled(&contract.name, contract.context_index_enabled);
416            }
417        }
418    }
419
420    /// Snapshot the live hypertable registry (specs + every chunk)
421    /// into the sidecar-serialisable form. Called from
422    /// [`persist_metadata`] so the chunk routing spine is durable on
423    /// the same metadata path as collection contracts — issue #866.
424    /// Returns an empty vec (and touches nothing) when no hypertable
425    /// has ever been declared, so non-hypertable databases pay no
426    /// persist cost.
427    pub(crate) fn hypertable_registry_snapshot(&self) -> Vec<crate::physical::PhysicalHypertable> {
428        let registry = self.hypertables();
429        if registry.is_empty() {
430            return Vec::new();
431        }
432        use std::collections::BTreeMap;
433        let mut chunks_by_table: BTreeMap<String, Vec<crate::physical::PhysicalHypertableChunk>> =
434            BTreeMap::new();
435        for chunk in registry.snapshot_chunks() {
436            chunks_by_table
437                .entry(chunk.id.hypertable.clone())
438                .or_default()
439                .push(crate::physical::PhysicalHypertableChunk {
440                    start_ns: chunk.id.start_ns,
441                    end_ns_exclusive: chunk.end_ns_exclusive,
442                    row_count: chunk.row_count,
443                    min_ts_ns: chunk.min_ts_ns,
444                    max_ts_ns: chunk.max_ts_ns,
445                    sealed: chunk.sealed,
446                    ttl_override_ns: chunk.ttl_override_ns,
447                    columnar_page: chunk.columnar_page,
448                    columnar_derived: chunk.columnar_page.is_some(),
449                });
450        }
451        registry
452            .list()
453            .into_iter()
454            .map(|spec| crate::physical::PhysicalHypertable {
455                chunks: chunks_by_table.remove(&spec.name).unwrap_or_default(),
456                name: spec.name,
457                time_column: spec.time_column,
458                chunk_interval_ns: spec.chunk_interval_ns,
459                default_ttl_ns: spec.default_ttl_ns,
460            })
461            .collect()
462    }
463
464    /// Rehydrate the hypertable registry from the physical metadata
465    /// sidecar at boot. Mirror of
466    /// [`load_collection_ttl_defaults_from_metadata`] — specs and
467    /// chunks are restored verbatim so bounds / routing / TTL are
468    /// identical to the pre-restart state (issue #866).
469    pub(crate) fn load_hypertables_from_metadata(&self) {
470        let Some(metadata) = self.physical_metadata() else {
471            return;
472        };
473        if metadata.hypertables.is_empty() {
474            return;
475        }
476        let registry = self.hypertables();
477        for hypertable in &metadata.hypertables {
478            let mut spec = crate::storage::timeseries::HypertableSpec::new(
479                hypertable.name.clone(),
480                hypertable.time_column.clone(),
481                hypertable.chunk_interval_ns,
482            );
483            if let Some(ttl) = hypertable.default_ttl_ns {
484                spec = spec.with_ttl_ns(ttl);
485            }
486            registry.register(spec);
487            for chunk in &hypertable.chunks {
488                let meta = crate::storage::timeseries::ChunkMeta {
489                    id: crate::storage::timeseries::ChunkId {
490                        hypertable: hypertable.name.clone(),
491                        start_ns: chunk.start_ns,
492                    },
493                    end_ns_exclusive: chunk.end_ns_exclusive,
494                    row_count: chunk.row_count,
495                    min_ts_ns: chunk.min_ts_ns,
496                    max_ts_ns: chunk.max_ts_ns,
497                    sealed: chunk.sealed,
498                    ttl_override_ns: chunk.ttl_override_ns,
499                    columnar_page: chunk.columnar_page,
500                };
501                let id = meta.id.clone();
502                let columnar_page = meta.columnar_page;
503                registry.restore_chunk(meta);
504                if let Some(loc) = columnar_page {
505                    if let Ok(Some(bytes)) = self.read_column_block_page(loc) {
506                        registry.restore_columnar_block(&id, bytes);
507                    }
508                }
509            }
510        }
511    }
512
513    pub fn run_maintenance(&self) -> Result<(), Box<dyn std::error::Error>> {
514        self.store.run_maintenance()?;
515        self.persist_metadata()?;
516        Ok(())
517    }
518
519    /// Path to the physical metadata sidecar, if persistent.
520    pub fn metadata_path(&self) -> Option<PathBuf> {
521        self.path
522            .as_ref()
523            .map(|path| PhysicalMetadataFile::metadata_path_for(path))
524    }
525
526    /// Inspect the current physical metadata view without publishing repairs.
527    pub fn physical_metadata(&self) -> Option<PhysicalMetadataFile> {
528        self.load_or_bootstrap_physical_metadata(false).ok()
529    }
530
531    /// Physical index registry derived for the current database state.
532    pub fn physical_indexes(&self) -> Vec<PhysicalIndexState> {
533        let indexes = self
534            .physical_metadata()
535            .map(|metadata| metadata.indexes)
536            .unwrap_or_default();
537        self.physical_indexes_from_declarations(indexes)
538    }
539
540    pub(crate) fn physical_indexes_from_declarations(
541        &self,
542        indexes: Vec<PhysicalIndexState>,
543    ) -> Vec<PhysicalIndexState> {
544        let indexes = Some(indexes)
545            .filter(|indexes| !indexes.is_empty())
546            .or_else(|| {
547                self.native_physical_state()
548                    .map(|state| self.physical_index_state_from_native_state(&state, None))
549            })
550            .unwrap_or_else(|| self.physical_index_state());
551        self.reconcile_index_states_with_native_artifacts(indexes)
552    }
553
554    /// List registered named exports from the current physical metadata view.
555    pub fn exports(&self) -> Vec<ExportDescriptor> {
556        self.physical_metadata()
557            .map(|metadata| metadata.exports)
558            .or_else(|| {
559                self.native_physical_state()
560                    .map(|state| self.exports_from_native_state(&state))
561            })
562            .unwrap_or_default()
563    }
564
565    /// List recorded snapshots from the current physical metadata view.
566    pub fn snapshots(&self) -> Vec<crate::physical::SnapshotDescriptor> {
567        if let Some(metadata) = self.physical_metadata() {
568            if !metadata.snapshots.is_empty() {
569                return metadata.snapshots;
570            }
571        }
572        self.native_physical_state()
573            .map(|state| self.snapshots_from_native_state(&state))
574            .unwrap_or_default()
575    }
576
577    /// List persisted named graph projections from the current physical metadata view.
578    pub fn graph_projections(&self) -> Vec<PhysicalGraphProjection> {
579        self.physical_metadata()
580            .map(|metadata| metadata.graph_projections)
581            .or_else(|| {
582                self.native_physical_state()
583                    .map(|state| self.graph_projections_from_native_state(&state))
584            })
585            .unwrap_or_default()
586    }
587
588    /// List graph projections declared in the catalog view.
589    pub fn declared_graph_projections(&self) -> Vec<PhysicalGraphProjection> {
590        self.catalog_model_snapshot().declared_graph_projections
591    }
592
593    /// List graph projections currently observed in the operational view.
594    pub fn operational_graph_projections(&self) -> Vec<PhysicalGraphProjection> {
595        self.graph_projections()
596            .into_iter()
597            .filter(|projection| {
598                projection.last_materialized_sequence.is_some()
599                    || matches!(projection.state.as_str(), "materialized" | "stale")
600            })
601            .collect()
602    }
603
604    /// List persisted analytics job metadata from the current physical metadata view.
605    pub fn analytics_jobs(&self) -> Vec<PhysicalAnalyticsJob> {
606        self.physical_metadata()
607            .map(|metadata| metadata.analytics_jobs)
608            .or_else(|| {
609                self.native_physical_state()
610                    .map(|state| self.analytics_jobs_from_native_state(&state))
611            })
612            .unwrap_or_default()
613    }
614
615    /// List analytics jobs declared in the catalog view.
616    pub fn declared_analytics_jobs(&self) -> Vec<PhysicalAnalyticsJob> {
617        self.catalog_model_snapshot().declared_analytics_jobs
618    }
619
620    /// List analytics jobs currently observed in the operational view.
621    pub fn operational_analytics_jobs(&self) -> Vec<PhysicalAnalyticsJob> {
622        self.analytics_jobs()
623            .into_iter()
624            .filter(|job| {
625                job.last_run_sequence.is_some()
626                    || matches!(
627                        job.state.as_str(),
628                        "running" | "completed" | "failed" | "queued" | "stale"
629                    )
630            })
631            .collect()
632    }
633
634    /// List indexes declared in the catalog view.
635    pub fn declared_indexes(&self) -> Vec<PhysicalIndexState> {
636        self.catalog_model_snapshot().declared_indexes
637    }
638
639    /// List indexes currently observed in the operational view.
640    pub fn operational_indexes(&self) -> Vec<PhysicalIndexState> {
641        self.catalog_model_snapshot().operational_indexes
642    }
643
644    /// List reconciled index status entries from the catalog snapshot.
645    pub fn index_statuses(&self) -> Vec<CatalogIndexStatus> {
646        self.catalog_model_snapshot().index_statuses
647    }
648
649    /// Resolve one index status entry from the catalog snapshot.
650    pub fn index_status(&self, name: &str) -> Option<CatalogIndexStatus> {
651        self.catalog_model_snapshot()
652            .index_statuses
653            .into_iter()
654            .find(|status| status.name == name)
655    }
656
657    /// Upsert a named graph projection in the persisted physical metadata.
658    pub fn save_graph_projection(
659        &self,
660        name: impl Into<String>,
661        node_labels: Vec<String>,
662        node_types: Vec<String>,
663        edge_labels: Vec<String>,
664        source: impl Into<String>,
665    ) -> Result<PhysicalGraphProjection, Box<dyn std::error::Error>> {
666        let name = name.into();
667        let source = source.into();
668        self.update_physical_metadata(|metadata| {
669            let now = SystemTime::now()
670                .duration_since(UNIX_EPOCH)
671                .unwrap_or_default()
672                .as_millis();
673            let projection = if let Some(existing) = metadata
674                .graph_projections
675                .iter_mut()
676                .find(|projection| projection.name == name)
677            {
678                existing.updated_at_unix_ms = now;
679                existing.state = "declared".to_string();
680                existing.source = source.clone();
681                existing.node_labels = node_labels.clone();
682                existing.node_types = node_types.clone();
683                existing.edge_labels = edge_labels.clone();
684                existing.last_materialized_sequence = None;
685                existing.clone()
686            } else {
687                let projection = PhysicalGraphProjection {
688                    name: name.clone(),
689                    created_at_unix_ms: now,
690                    updated_at_unix_ms: now,
691                    state: "declared".to_string(),
692                    source: source.clone(),
693                    node_labels: node_labels.clone(),
694                    node_types: node_types.clone(),
695                    edge_labels: edge_labels.clone(),
696                    last_materialized_sequence: None,
697                };
698                metadata.graph_projections.push(projection.clone());
699                projection
700            };
701
702            Self::mark_projection_dependent_jobs_stale(metadata, &name, now);
703
704            metadata
705                .graph_projections
706                .sort_by(|left, right| left.name.cmp(&right.name));
707            projection
708        })
709    }
710
711    /// Mark a declared graph projection as materialized in the current physical metadata.
712    pub fn materialize_graph_projection(
713        &self,
714        name: &str,
715    ) -> Result<Option<PhysicalGraphProjection>, Box<dyn std::error::Error>> {
716        self.update_physical_metadata(|metadata| {
717            let now = SystemTime::now()
718                .duration_since(UNIX_EPOCH)
719                .unwrap_or_default()
720                .as_millis();
721            let idx = metadata
722                .graph_projections
723                .iter()
724                .position(|projection| projection.name == name);
725            if let Some(idx) = idx {
726                metadata.graph_projections[idx].updated_at_unix_ms = now;
727                metadata.graph_projections[idx].state = "materialized".to_string();
728                metadata.graph_projections[idx].last_materialized_sequence =
729                    Some(metadata.superblock.sequence);
730                let result = metadata.graph_projections[idx].clone();
731                Self::rearm_projection_dependent_jobs_declared(metadata, name, now);
732                return Some(result);
733            }
734            None
735        })
736    }
737
738    /// Mark a declared graph projection as materializing.
739    pub fn mark_graph_projection_materializing(
740        &self,
741        name: &str,
742    ) -> Result<Option<PhysicalGraphProjection>, Box<dyn std::error::Error>> {
743        self.update_physical_metadata(|metadata| {
744            let now = SystemTime::now()
745                .duration_since(UNIX_EPOCH)
746                .unwrap_or_default()
747                .as_millis();
748            let idx = metadata
749                .graph_projections
750                .iter()
751                .position(|projection| projection.name == name);
752            if let Some(idx) = idx {
753                metadata.graph_projections[idx].updated_at_unix_ms = now;
754                metadata.graph_projections[idx].state = "materializing".to_string();
755                metadata.graph_projections[idx].last_materialized_sequence = None;
756                let result = metadata.graph_projections[idx].clone();
757                Self::mark_projection_dependent_jobs_stale(metadata, name, now);
758                return Some(result);
759            }
760            None
761        })
762    }
763
764    /// Mark a graph projection as failed.
765    pub fn fail_graph_projection(
766        &self,
767        name: &str,
768    ) -> Result<Option<PhysicalGraphProjection>, Box<dyn std::error::Error>> {
769        self.update_physical_metadata(|metadata| {
770            let now = SystemTime::now()
771                .duration_since(UNIX_EPOCH)
772                .unwrap_or_default()
773                .as_millis();
774            let idx = metadata
775                .graph_projections
776                .iter()
777                .position(|projection| projection.name == name);
778            if let Some(idx) = idx {
779                metadata.graph_projections[idx].updated_at_unix_ms = now;
780                metadata.graph_projections[idx].state = "failed".to_string();
781                metadata.graph_projections[idx].last_materialized_sequence = None;
782                let result = metadata.graph_projections[idx].clone();
783                Self::mark_projection_dependent_jobs_stale(metadata, name, now);
784                return Some(result);
785            }
786            None
787        })
788    }
789
790    /// Mark a graph projection as stale while preserving any last materialized sequence.
791    pub fn mark_graph_projection_stale(
792        &self,
793        name: &str,
794    ) -> Result<Option<PhysicalGraphProjection>, Box<dyn std::error::Error>> {
795        self.update_physical_metadata(|metadata| {
796            let now = SystemTime::now()
797                .duration_since(UNIX_EPOCH)
798                .unwrap_or_default()
799                .as_millis();
800            let idx = metadata
801                .graph_projections
802                .iter()
803                .position(|projection| projection.name == name);
804            if let Some(idx) = idx {
805                metadata.graph_projections[idx].updated_at_unix_ms = now;
806                metadata.graph_projections[idx].state = "stale".to_string();
807                let result = metadata.graph_projections[idx].clone();
808                Self::mark_projection_dependent_jobs_stale(metadata, name, now);
809                return Some(result);
810            }
811            None
812        })
813    }
814
815    fn mark_projection_dependent_jobs_stale(
816        metadata: &mut PhysicalMetadataFile,
817        projection_name: &str,
818        now: u128,
819    ) {
820        for job in metadata.analytics_jobs.iter_mut() {
821            if job.projection.as_deref() == Some(projection_name) && job.state != "declared" {
822                job.state = "stale".to_string();
823                job.updated_at_unix_ms = now;
824            }
825        }
826    }
827
828    fn rearm_projection_dependent_jobs_declared(
829        metadata: &mut PhysicalMetadataFile,
830        projection_name: &str,
831        now: u128,
832    ) {
833        for job in metadata.analytics_jobs.iter_mut() {
834            if job.projection.as_deref() == Some(projection_name) && job.state == "stale" {
835                job.state = "declared".to_string();
836                job.last_run_sequence = None;
837                job.updated_at_unix_ms = now;
838            }
839        }
840    }
841
842    /// Declare or update analytics job metadata without marking it as executed.
843    pub fn save_analytics_job(
844        &self,
845        kind: impl Into<String>,
846        projection: Option<String>,
847        metadata_entries: BTreeMap<String, String>,
848    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
849        let kind = kind.into();
850        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
851        if let Some(projection_name) = projection.as_deref() {
852            if !self.graph_projection_is_declared(projection_name) {
853                return Err(format!(
854                    "graph projection '{projection_name}' is not declared in physical metadata"
855                )
856                .into());
857            }
858        }
859
860        self.update_physical_metadata(|metadata| {
861            let now = SystemTime::now()
862                .duration_since(UNIX_EPOCH)
863                .unwrap_or_default()
864                .as_millis();
865
866            let job = if let Some(existing) = metadata
867                .analytics_jobs
868                .iter_mut()
869                .find(|job| job.id == job_id)
870            {
871                existing.kind = kind.clone();
872                existing.projection = projection.clone();
873                existing.updated_at_unix_ms = now;
874                existing.metadata = metadata_entries.clone();
875                if existing.last_run_sequence.is_none() {
876                    existing.state = "declared".to_string();
877                }
878                existing.clone()
879            } else {
880                let job = PhysicalAnalyticsJob {
881                    id: job_id.clone(),
882                    kind: kind.clone(),
883                    state: "declared".to_string(),
884                    projection: projection.clone(),
885                    created_at_unix_ms: now,
886                    updated_at_unix_ms: now,
887                    last_run_sequence: None,
888                    metadata: metadata_entries.clone(),
889                };
890                metadata.analytics_jobs.push(job.clone());
891                job
892            };
893
894            metadata
895                .analytics_jobs
896                .sort_by(|left, right| left.id.cmp(&right.id));
897            job
898        })
899    }
900
901    /// Record or update analytics job metadata in the persisted physical metadata.
902    pub fn record_analytics_job(
903        &self,
904        kind: impl Into<String>,
905        projection: Option<String>,
906        metadata_entries: BTreeMap<String, String>,
907    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
908        let kind = kind.into();
909        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
910        if let Some(projection_name) = projection.as_deref() {
911            if !self.graph_projection_is_declared(projection_name) {
912                return Err(format!(
913                    "graph projection '{projection_name}' is not declared in physical metadata"
914                )
915                .into());
916            }
917            if !self.graph_projection_is_operational(projection_name) {
918                return Err(format!(
919                    "graph projection '{projection_name}' is declared but not operationally materialized"
920                )
921                .into());
922            }
923        }
924
925        self.update_physical_metadata(|metadata| {
926            let now = SystemTime::now()
927                .duration_since(UNIX_EPOCH)
928                .unwrap_or_default()
929                .as_millis();
930
931            let existing = metadata
932                .analytics_jobs
933                .iter_mut()
934                .find(|job| job.id == job_id)?;
935            existing.state = "completed".to_string();
936            existing.kind = kind.clone();
937            existing.projection = projection.clone();
938            existing.updated_at_unix_ms = now;
939            existing.last_run_sequence = Some(metadata.superblock.sequence);
940            existing.metadata = metadata_entries.clone();
941            let job = existing.clone();
942
943            metadata
944                .analytics_jobs
945                .sort_by(|left, right| left.id.cmp(&right.id));
946            Some(job)
947        })
948        .and_then(|job| {
949            job.ok_or_else(|| {
950                format!("analytics job '{job_id}' is not declared in physical metadata").into()
951            })
952        })
953    }
954
955    /// Mark a declared analytics job as running.
956    pub fn queue_analytics_job(
957        &self,
958        kind: impl Into<String>,
959        projection: Option<String>,
960        metadata_entries: BTreeMap<String, String>,
961    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
962        let kind = kind.into();
963        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
964        if let Some(projection_name) = projection.as_deref() {
965            if !self.graph_projection_is_declared(projection_name) {
966                return Err(format!(
967                    "graph projection '{projection_name}' is not declared in physical metadata"
968                )
969                .into());
970            }
971            if !self.graph_projection_is_operational(projection_name) {
972                return Err(format!(
973                    "graph projection '{projection_name}' is declared but not operationally materialized"
974                )
975                .into());
976            }
977        }
978
979        self.update_physical_metadata(|metadata| {
980            let now = SystemTime::now()
981                .duration_since(UNIX_EPOCH)
982                .unwrap_or_default()
983                .as_millis();
984
985            let existing = metadata
986                .analytics_jobs
987                .iter_mut()
988                .find(|job| job.id == job_id)?;
989            existing.state = "queued".to_string();
990            existing.kind = kind.clone();
991            existing.projection = projection.clone();
992            existing.updated_at_unix_ms = now;
993            existing.metadata = metadata_entries.clone();
994            let job = existing.clone();
995
996            metadata
997                .analytics_jobs
998                .sort_by(|left, right| left.id.cmp(&right.id));
999            Some(job)
1000        })
1001        .and_then(|job| {
1002            job.ok_or_else(|| {
1003                format!("analytics job '{job_id}' is not declared in physical metadata").into()
1004            })
1005        })
1006    }
1007
1008    /// Mark a declared analytics job as running.
1009    pub fn start_analytics_job(
1010        &self,
1011        kind: impl Into<String>,
1012        projection: Option<String>,
1013        metadata_entries: BTreeMap<String, String>,
1014    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
1015        let kind = kind.into();
1016        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
1017        if let Some(projection_name) = projection.as_deref() {
1018            if !self.graph_projection_is_declared(projection_name) {
1019                return Err(format!(
1020                    "graph projection '{projection_name}' is not declared in physical metadata"
1021                )
1022                .into());
1023            }
1024            if !self.graph_projection_is_operational(projection_name) {
1025                return Err(format!(
1026                    "graph projection '{projection_name}' is declared but not operationally materialized"
1027                )
1028                .into());
1029            }
1030        }
1031
1032        self.update_physical_metadata(|metadata| {
1033            let now = SystemTime::now()
1034                .duration_since(UNIX_EPOCH)
1035                .unwrap_or_default()
1036                .as_millis();
1037
1038            let existing = metadata
1039                .analytics_jobs
1040                .iter_mut()
1041                .find(|job| job.id == job_id)?;
1042            existing.state = "running".to_string();
1043            existing.kind = kind.clone();
1044            existing.projection = projection.clone();
1045            existing.updated_at_unix_ms = now;
1046            existing.metadata = metadata_entries.clone();
1047            let job = existing.clone();
1048
1049            metadata
1050                .analytics_jobs
1051                .sort_by(|left, right| left.id.cmp(&right.id));
1052            Some(job)
1053        })
1054        .and_then(|job| {
1055            job.ok_or_else(|| {
1056                format!("analytics job '{job_id}' is not declared in physical metadata").into()
1057            })
1058        })
1059    }
1060
1061    /// Mark a declared analytics job as failed.
1062    pub fn fail_analytics_job(
1063        &self,
1064        kind: impl Into<String>,
1065        projection: Option<String>,
1066        metadata_entries: BTreeMap<String, String>,
1067    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
1068        let kind = kind.into();
1069        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
1070
1071        self.update_physical_metadata(|metadata| {
1072            let now = SystemTime::now()
1073                .duration_since(UNIX_EPOCH)
1074                .unwrap_or_default()
1075                .as_millis();
1076
1077            let existing = metadata
1078                .analytics_jobs
1079                .iter_mut()
1080                .find(|job| job.id == job_id)?;
1081            existing.state = "failed".to_string();
1082            existing.kind = kind.clone();
1083            existing.projection = projection.clone();
1084            existing.updated_at_unix_ms = now;
1085            existing.metadata = metadata_entries.clone();
1086            let job = existing.clone();
1087
1088            metadata
1089                .analytics_jobs
1090                .sort_by(|left, right| left.id.cmp(&right.id));
1091            Some(job)
1092        })
1093        .and_then(|job| {
1094            job.ok_or_else(|| {
1095                format!("analytics job '{job_id}' is not declared in physical metadata").into()
1096            })
1097        })
1098    }
1099
1100    /// Mark a declared analytics job as stale.
1101    pub fn mark_analytics_job_stale(
1102        &self,
1103        kind: impl Into<String>,
1104        projection: Option<String>,
1105        metadata_entries: BTreeMap<String, String>,
1106    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
1107        let kind = kind.into();
1108        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
1109
1110        self.update_physical_metadata(|metadata| {
1111            let now = SystemTime::now()
1112                .duration_since(UNIX_EPOCH)
1113                .unwrap_or_default()
1114                .as_millis();
1115
1116            let existing = metadata
1117                .analytics_jobs
1118                .iter_mut()
1119                .find(|job| job.id == job_id)?;
1120            existing.state = "stale".to_string();
1121            existing.kind = kind.clone();
1122            existing.projection = projection.clone();
1123            existing.updated_at_unix_ms = now;
1124            existing.metadata = metadata_entries.clone();
1125            let job = existing.clone();
1126
1127            metadata
1128                .analytics_jobs
1129                .sort_by(|left, right| left.id.cmp(&right.id));
1130            Some(job)
1131        })
1132        .and_then(|job| {
1133            job.ok_or_else(|| {
1134                format!("analytics job '{job_id}' is not declared in physical metadata").into()
1135            })
1136        })
1137    }
1138
1139    /// Create a named export by copying the current database file and metadata sidecar.
1140    pub fn create_named_export(
1141        &self,
1142        name: impl Into<String>,
1143    ) -> Result<ExportDescriptor, Box<dyn std::error::Error>> {
1144        let name = name.into();
1145        if self.options.mode != StorageMode::Persistent {
1146            return Err("exports require persistent mode".into());
1147        }
1148        let Some(path) = self.path() else {
1149            return Err("database path is not available".into());
1150        };
1151
1152        self.flush()?;
1153
1154        let _publication = self.store.vault_publication_lock.lock();
1155        let mut metadata = match self.load_or_bootstrap_physical_metadata(true) {
1156            Ok(metadata) => metadata,
1157            Err(_)
1158                if self.options.storage_profile.deploy_profile
1159                    == crate::storage::DeployProfile::Embedded
1160                    && self.options.storage_profile.packaging
1161                        == crate::storage::StoragePackaging::SingleFile =>
1162            {
1163                crate::physical::PhysicalMetadataFile::from_state(
1164                    self.options.clone(),
1165                    self.catalog_snapshot(),
1166                    self.physical_collection_roots(),
1167                    self.physical_index_state(),
1168                    None,
1169                )
1170            }
1171            Err(err) => return Err(err),
1172        };
1173        let export_data_path = reddb_file::copy_physical_export_data_file(path, &name)?;
1174        let export_metadata_path = PhysicalMetadataFile::metadata_path_for(&export_data_path);
1175        let export_metadata_binary_path =
1176            PhysicalMetadataFile::metadata_binary_path_for(&export_data_path);
1177
1178        let descriptor = ExportDescriptor {
1179            name: name.clone(),
1180            created_at_unix_ms: SystemTime::now()
1181                .duration_since(UNIX_EPOCH)
1182                .unwrap_or_default()
1183                .as_millis(),
1184            snapshot_id: metadata
1185                .snapshots
1186                .last()
1187                .map(|snapshot| snapshot.snapshot_id),
1188            superblock_sequence: metadata.superblock.sequence,
1189            data_path: export_data_path.display().to_string(),
1190            metadata_path: export_metadata_path.display().to_string(),
1191            collection_count: metadata.catalog.total_collections,
1192            total_entities: metadata.catalog.total_entities,
1193        };
1194
1195        metadata
1196            .exports
1197            .retain(|export| export.name != descriptor.name);
1198        metadata.exports.push(descriptor.clone());
1199        self.prune_export_registry(&mut metadata.exports);
1200        metadata.save_for_data_path(path)?;
1201        metadata.save_to_binary_path(&export_metadata_binary_path)?;
1202        metadata.save_to_path(&export_metadata_path)?;
1203
1204        Ok(descriptor)
1205    }
1206
1207    /// Enable or disable a physical index entry in the persisted registry.
1208    pub fn set_index_enabled(
1209        &self,
1210        name: &str,
1211        enabled: bool,
1212    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1213        let Some(status) = self.index_status(name) else {
1214            return Err(format!("index '{name}' is not present in catalog status").into());
1215        };
1216        if !status.declared {
1217            return Err(format!("index '{name}' is not declared in physical metadata").into());
1218        }
1219        self.update_physical_metadata(|metadata| {
1220            let now = SystemTime::now()
1221                .duration_since(UNIX_EPOCH)
1222                .unwrap_or_default()
1223                .as_millis();
1224            if let Some(index) = metadata.indexes.iter_mut().find(|index| index.name == name) {
1225                index.enabled = enabled;
1226                if !enabled {
1227                    index.build_state = "disabled".to_string();
1228                } else if index.build_state == "disabled" {
1229                    index.build_state = if index.artifact_root_page.is_some() {
1230                        "ready".to_string()
1231                    } else {
1232                        "declared-unbuilt".to_string()
1233                    };
1234                }
1235                index.last_refresh_ms = Some(now);
1236                return Some(index.clone());
1237            }
1238            None
1239        })
1240    }
1241
1242    /// Mark a declared physical index as building in the persisted registry.
1243    pub fn mark_index_building(
1244        &self,
1245        name: &str,
1246    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1247        let Some(status) = self.index_status(name) else {
1248            return Err(format!("index '{name}' is not present in catalog status").into());
1249        };
1250        if !status.declared {
1251            return Err(format!("index '{name}' is not declared in physical metadata").into());
1252        }
1253        if status.lifecycle_state == "disabled" {
1254            return Err(format!("index '{name}' is disabled").into());
1255        }
1256        self.update_physical_metadata(|metadata| {
1257            let now = SystemTime::now()
1258                .duration_since(UNIX_EPOCH)
1259                .unwrap_or_default()
1260                .as_millis();
1261            if let Some(index) = metadata.indexes.iter_mut().find(|index| index.name == name) {
1262                index.build_state = "building".to_string();
1263                index.last_refresh_ms = Some(now);
1264                return Some(index.clone());
1265            }
1266            None
1267        })
1268    }
1269
1270    /// Mark a declared physical index as failed in the persisted registry.
1271    pub fn fail_index(
1272        &self,
1273        name: &str,
1274    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1275        let Some(status) = self.index_status(name) else {
1276            return Err(format!("index '{name}' is not present in catalog status").into());
1277        };
1278        if !status.declared {
1279            return Err(format!("index '{name}' is not declared in physical metadata").into());
1280        }
1281        self.update_physical_metadata(|metadata| {
1282            let now = SystemTime::now()
1283                .duration_since(UNIX_EPOCH)
1284                .unwrap_or_default()
1285                .as_millis();
1286            if let Some(index) = metadata.indexes.iter_mut().find(|index| index.name == name) {
1287                index.build_state = "failed".to_string();
1288                index.last_refresh_ms = Some(now);
1289                return Some(index.clone());
1290            }
1291            None
1292        })
1293    }
1294
1295    /// Mark a declared physical index as stale in the persisted registry.
1296    pub fn mark_index_stale(
1297        &self,
1298        name: &str,
1299    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1300        let Some(status) = self.index_status(name) else {
1301            return Err(format!("index '{name}' is not present in catalog status").into());
1302        };
1303        if !status.declared {
1304            return Err(format!("index '{name}' is not declared in physical metadata").into());
1305        }
1306        self.update_physical_metadata(|metadata| {
1307            let now = SystemTime::now()
1308                .duration_since(UNIX_EPOCH)
1309                .unwrap_or_default()
1310                .as_millis();
1311            if let Some(index) = metadata.indexes.iter_mut().find(|index| index.name == name) {
1312                index.build_state = if index.enabled {
1313                    "stale".to_string()
1314                } else {
1315                    "disabled".to_string()
1316                };
1317                index.last_refresh_ms = Some(now);
1318                return Some(index.clone());
1319            }
1320            None
1321        })
1322    }
1323
1324    /// Mark a declared physical index as ready in the persisted registry.
1325    pub fn mark_index_ready(
1326        &self,
1327        name: &str,
1328    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1329        self.warmup_index(name)
1330    }
1331
1332    /// Mark a physical index as warmed up/refreshed in the persisted registry.
1333    pub fn warmup_index(
1334        &self,
1335        name: &str,
1336    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1337        let Some(status) = self.index_status(name) else {
1338            return Err(format!("index '{name}' is not present in catalog status").into());
1339        };
1340        if !status.declared {
1341            return Err(format!("index '{name}' is not declared in physical metadata").into());
1342        }
1343        if status.lifecycle_state == "disabled" {
1344            return Err(format!("index '{name}' is disabled").into());
1345        }
1346        if !status.operational {
1347            return Err(
1348                format!("index '{name}' is declared but not operationally materialized").into(),
1349            );
1350        }
1351        let warmed_artifact = self
1352            .physical_indexes()
1353            .into_iter()
1354            .find(|index| index.name == name)
1355            .map(|mut index| {
1356                self.warmup_native_vector_artifact_for_index(&index)?;
1357                self.apply_runtime_native_artifact_to_index_state(&mut index)?;
1358                Ok::<_, String>(index)
1359            })
1360            .transpose()
1361            .map_err(|err| -> Box<dyn std::error::Error> { err.into() })?;
1362
1363        self.update_physical_metadata(|metadata| {
1364            let now = SystemTime::now()
1365                .duration_since(UNIX_EPOCH)
1366                .unwrap_or_default()
1367                .as_millis();
1368            if let Some(index) = metadata.indexes.iter_mut().find(|index| index.name == name) {
1369                if let Some(warmed) = warmed_artifact.as_ref() {
1370                    index.entries = warmed.entries;
1371                    index.estimated_memory_bytes = warmed.estimated_memory_bytes;
1372                    index.backend = warmed.backend.clone();
1373                    index.build_state = "ready".to_string();
1374                }
1375                index.last_refresh_ms = Some(now);
1376                return Some(index.clone());
1377            }
1378            None
1379        })
1380    }
1381
1382    /// Rebuild physical index metadata from the current catalog, optionally restricted to one collection.
1383    pub fn rebuild_index_registry(
1384        &self,
1385        collection: Option<&str>,
1386    ) -> Result<Vec<PhysicalIndexState>, Box<dyn std::error::Error>> {
1387        let fresh = self.reconcile_index_states_with_native_artifacts(self.physical_index_state());
1388        self.update_physical_metadata(|metadata| {
1389            let now = SystemTime::now()
1390                .duration_since(UNIX_EPOCH)
1391                .unwrap_or_default()
1392                .as_millis();
1393
1394            let mut affected = Vec::new();
1395            let declared = metadata.indexes.clone();
1396            for declared_index in declared {
1397                let matches_collection = collection.is_none_or(|collection_name| {
1398                    declared_index.collection.as_deref() == Some(collection_name)
1399                });
1400                if !matches_collection {
1401                    continue;
1402                }
1403
1404                let mut rebuilt = fresh
1405                    .iter()
1406                    .find(|index| index.name == declared_index.name)
1407                    .cloned()
1408                    .unwrap_or_else(|| {
1409                        let mut index = declared_index.clone();
1410                        index.build_state = "declared-unbuilt".to_string();
1411                        index
1412                    });
1413                rebuilt.enabled = declared_index.enabled;
1414                rebuilt.artifact_kind = rebuilt
1415                    .artifact_kind
1416                    .or_else(|| declared_index.artifact_kind.clone());
1417                rebuilt.artifact_root_page = rebuilt
1418                    .artifact_root_page
1419                    .or(declared_index.artifact_root_page);
1420                rebuilt.artifact_checksum = rebuilt
1421                    .artifact_checksum
1422                    .or(declared_index.artifact_checksum);
1423                rebuilt.build_state =
1424                    Self::finalize_rebuilt_index_build_state(&declared_index, &rebuilt);
1425                rebuilt.last_refresh_ms = Some(now);
1426
1427                if let Some(existing) = metadata
1428                    .indexes
1429                    .iter_mut()
1430                    .find(|index| index.name == rebuilt.name)
1431                {
1432                    *existing = rebuilt.clone();
1433                } else {
1434                    metadata.indexes.push(rebuilt.clone());
1435                }
1436
1437                affected.push(rebuilt);
1438            }
1439
1440            affected
1441        })
1442    }
1443
1444    fn finalize_rebuilt_index_build_state(
1445        declared: &PhysicalIndexState,
1446        rebuilt: &PhysicalIndexState,
1447    ) -> String {
1448        if !rebuilt.enabled {
1449            return "disabled".to_string();
1450        }
1451
1452        if declared.build_state == "failed" || rebuilt.build_state == "failed" {
1453            return "failed".to_string();
1454        }
1455
1456        let native_artifact_family = Self::native_artifact_kind_for_index(rebuilt.kind).is_some();
1457        if native_artifact_family {
1458            if rebuilt.artifact_root_page.is_some() && rebuilt.artifact_checksum.is_some() {
1459                return "ready".to_string();
1460            }
1461            if declared.artifact_root_page.is_some()
1462                || declared.artifact_checksum.is_some()
1463                || declared.artifact_kind.is_some()
1464            {
1465                return "stale".to_string();
1466            }
1467            return "declared-unbuilt".to_string();
1468        }
1469
1470        if rebuilt.entries > 0 {
1471            return "ready".to_string();
1472        }
1473
1474        if matches!(
1475            declared.build_state.as_str(),
1476            "stale" | "artifact-published" | "registry-loaded"
1477        ) {
1478            return "stale".to_string();
1479        }
1480
1481        "declared-unbuilt".to_string()
1482    }
1483}