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