Skip to main content

akar_main/
database.rs

1//! Database — the main entry point for Akar.
2
3use akar_catalog::Catalog;
4use akar_common::file_system::VirtualFileSystemRegistry;
5use akar_common::memory::MemoryManager;
6use akar_common::task_system::TaskSystem;
7use akar_extension::{ExtensionContext, ExtensionRegistry};
8use akar_function::FunctionRegistry;
9use akar_storage::StorageManager;
10use akar_storage::stats::StatsStore;
11use akar_storage::table::ColumnDefinition;
12use akar_transaction::TransactionManager;
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
16use std::sync::{Arc, Mutex, OnceLock};
17
18/// Name of the file that holds the serialized system catalog.
19///
20/// The catalog file is the source of truth for DDL (schema changes survive
21/// restarts via this file); WAL records only carry DML.
22pub const CATALOG_FILE_NAME: &str = "catalog.json";
23
24/// Name of the file holding the cross-process lock.
25///
26/// The lock file is created with an exclusive lock by the first process to
27/// open a database directory and prevents a second process from opening the
28/// same directory concurrently (P45.4). Read-only opens take a shared lock.
29pub const LOCK_FILE_NAME: &str = "akar.lock";
30
31/// Configuration for the database.
32#[derive(Debug, Clone)]
33pub struct SystemConfig {
34    pub buffer_pool_size: u64,
35    pub max_num_threads: u64,
36    pub enable_compression: bool,
37    pub read_only: bool,
38    pub max_db_size: u64,
39    pub auto_checkpoint: bool,
40    pub checkpoint_threshold: i64,
41    /// When true, multiple write transactions can run concurrently.
42    /// When false, only one write transaction at a time is allowed.
43    pub concurrent_writes: bool,
44    /// Memory threshold (in bytes) for triggering disk spilling during
45    /// bulk ingest (COPY FROM, large batch inserts).
46    ///
47    /// When a NodeGroup's estimated in-memory size exceeds this value,
48    /// its data is spilled to a temp file and the in-memory buffer is
49    /// cleared. After all rows are ingested, spilled files are merged
50    /// back into persistent storage.
51    ///
52    /// Default: 80% of `buffer_pool_size`. Set to 0 to disable spilling.
53    pub spill_threshold: u64,
54}
55
56impl Default for SystemConfig {
57    fn default() -> Self {
58        Self {
59            buffer_pool_size: 0,
60            max_num_threads: 0,
61            enable_compression: true,
62            read_only: false,
63            max_db_size: u64::from(u32::MAX),
64            auto_checkpoint: true,
65            checkpoint_threshold: -1,
66            concurrent_writes: true,
67            // Default: 80% of buffer_pool_size, or 0 if not set
68            spill_threshold: 0,
69        }
70    }
71}
72
73/// The main database instance.
74///
75/// Manages the storage engine, catalog, transaction manager, and all subsystem
76/// instances. Create via [`Database::new`] with a path and [`SystemConfig`].
77///
78/// # Examples
79///
80/// ```no_run
81/// use akar_main::database::{Database, SystemConfig};
82/// use akar_main::connection::Connection;
83///
84/// let db = std::sync::Arc::new(Database::new("./my_db", SystemConfig::default())?);
85/// let conn = Connection::new(&db);
86/// conn.query("CREATE NODE TABLE Person(name STRING, age INT64, PRIMARY KEY(name))")?;
87/// # Ok::<(), String>(())
88/// ```
89/// Cross-process path lock, reentrant within this process (P53.35, E3).
90///
91/// The first `Database` on a path takes the OS-level lock and keeps the file
92/// handle here; later opens on the *same path in this process* share that
93/// handle (refcount) instead of failing — this is what allows the kairos
94/// harness pattern of a fixture store and a fresh store on one path in the
95/// same process. The last `Database` to drop removes the entry, closing the
96/// handle and releasing the OS lock. Cross-process exclusion is unchanged:
97/// every process owns a private registry, so a second process still fails to
98/// acquire the OS lock while the first holds it.
99static PROCESS_PATH_LOCKS: OnceLock<Mutex<HashMap<PathBuf, (std::fs::File, u32)>>> = OnceLock::new();
100
101fn process_path_locks() -> &'static Mutex<HashMap<PathBuf, (std::fs::File, u32)>> {
102    PROCESS_PATH_LOCKS.get_or_init(|| Mutex::new(HashMap::new()))
103}
104
105/// Slot guard in `PROCESS_PATH_LOCKS`. On drop it decrements the refcount for
106/// the path and removes the entry (dropping the shared OS lock handle) once
107/// the count reaches zero.
108struct PathLock {
109    key: PathBuf,
110}
111
112impl Drop for PathLock {
113    fn drop(&mut self) {
114        let mut reg = process_path_locks().lock().unwrap();
115        if let Some((_, count)) = reg.get_mut(&self.key) {
116            *count -= 1;
117            if *count == 0 {
118                reg.remove(&self.key);
119            }
120        }
121    }
122}
123
124/// Resolve the canonical lock-file path for a database directory so that
125/// alternative spellings of the same directory share one registry slot.
126fn lock_key(db_path: &Path) -> PathBuf {
127    std::fs::canonicalize(db_path)
128        .unwrap_or_else(|_| db_path.to_path_buf())
129        .join(LOCK_FILE_NAME)
130}
131
132#[allow(dead_code)]
133pub struct Database {
134    pub(crate) storage_manager: Arc<StorageManager>,
135    pub(crate) catalog: Arc<Mutex<Catalog>>,
136    pub(crate) transaction_manager: Arc<TransactionManager>,
137    pub(crate) function_registry: Arc<Mutex<FunctionRegistry>>,
138    pub(crate) task_system: Arc<TaskSystem>,
139    pub(crate) memory_manager: Arc<MemoryManager>,
140    pub(crate) extension_registry: Mutex<ExtensionRegistry>,
141    pub(crate) stats_store: Arc<Mutex<StatsStore>>,
142    pub(crate) vfs: Arc<VirtualFileSystemRegistry>,
143    /// Configuration used at database creation time.
144    pub(crate) config: SystemConfig,
145    /// Runtime-overridable spill threshold via `SET spill_threshold`.
146    /// The override is tracked explicitly so `SET spill_threshold=0` disables
147    /// spilling instead of falling back to the config default (P52.50).
148    spill_threshold_override: AtomicU64,
149    spill_threshold_overridden: AtomicBool,
150    /// Registry slot holding this process's share of the cross-process path
151    /// lock (see `PROCESS_PATH_LOCKS` / `PathLock`).
152    _lock: Option<PathLock>,
153}
154
155impl Database {
156    /// Override the spill threshold at runtime (via `SET spill_threshold`).
157    /// A value of `0` explicitly disables spilling.
158    pub fn set_spill_threshold(&self, bytes: u64) {
159        self.spill_threshold_override.store(bytes, Ordering::Relaxed);
160        self.spill_threshold_overridden.store(true, Ordering::Relaxed);
161        // Propagate the runtime override so bulk ingest on existing node
162        // tables spills at the new threshold (P51.44).
163        self.storage_manager.set_spiller(self.spiller());
164    }
165
166    /// Get the effective spill threshold in bytes.
167    ///
168    /// Priority:
169    /// 1. Runtime override (via `SET spill_threshold`; `0` = explicitly disabled)
170    /// 2. `config.spill_threshold`
171    /// 3. 80% of `buffer_pool_size`
172    /// 4. 0 (disabled)
173    pub fn effective_spill_threshold(&self) -> u64 {
174        if self.spill_threshold_overridden.load(Ordering::Relaxed) {
175            return self.spill_threshold_override.load(Ordering::Relaxed);
176        }
177        if self.config.spill_threshold > 0 {
178            return self.config.spill_threshold;
179        }
180        if self.config.buffer_pool_size > 0 {
181            return (self.config.buffer_pool_size as f64 * 0.8) as u64;
182        }
183        0
184    }
185
186    /// Create a `Spiller` instance using the current database configuration.
187    ///
188    /// Returns `None` if spilling is disabled (threshold is 0).
189    pub fn spiller(&self) -> Option<Arc<akar_storage::Spiller>> {
190        let threshold = self.effective_spill_threshold();
191        if threshold == 0 {
192            return None;
193        }
194        let spill_dir = self.storage_manager.db_path().join("spill");
195        Some(Arc::new(akar_storage::Spiller::new(spill_dir, threshold)))
196    }
197
198    /// Create a [`StorageDriver`] for programmatic storage-level access
199    /// (page counts, buffer stats, file sizes, table counts) without Cypher.
200    pub fn storage_driver(&self) -> crate::storage_driver::StorageDriver {
201        crate::storage_driver::StorageDriver::new(self.storage_manager.clone(), self.catalog.clone(), self.vfs.clone())
202    }
203
204    /// Get a reference to the schema catalog for programmatic metadata access.
205    pub fn catalog(&self) -> Arc<Mutex<Catalog>> {
206        self.catalog.clone()
207    }
208
209    /// Get the data table catalog for programmatic data access.
210    ///
211    /// Prefer using the unified DDL methods on `Database` instead of
212    /// accessing the table catalog directly.
213    pub fn table_catalog(&self) -> Arc<akar_storage::TableCatalog> {
214        self.storage_manager.table_catalog()
215    }
216
217    // ── Unified DDL operations ──────────────────────────────────────────
218    //
219    // These methods ensure storage-level table creation/deletion is atomic.
220    // Schema entries are managed by the binder (via `Catalog`) during the
221    // bind phase.  These methods handle the data-level side:
222    // storage table creation, serial sequences, ART indexes.
223
224    /// Create a node table: data table + serial sequences + ART index.
225    ///
226    /// The schema entry is created by the binder during `bind()`.
227    /// This method creates the storage-level table and associated resources.
228    pub fn create_node_table(&self, name: String, columns: Vec<akar_catalog::CatalogColumn>) -> Result<u64, String> {
229        // 1. Create the data-level table
230        let storage_columns: Vec<ColumnDefinition> = columns
231            .iter()
232            .map(|c| ColumnDefinition {
233                name: c.name.clone(),
234                logical_type: c.logical_type,
235                is_primary_key: c.is_primary_key,
236                compression: c.compression,
237            })
238            .collect();
239        let node_table = self.storage_manager.create_node_table(name.clone(), storage_columns);
240        let table_id = node_table.table_id;
241
242        // 2. Auto-create backing sequences for SERIAL columns
243        {
244            let mut cat = self.catalog.lock().map_err(|e| format!("Lock poisoned: {e}"))?;
245            for col in &columns {
246                if col.logical_type == akar_common::types::LogicalTypeID::Serial {
247                    if let akar_catalog::CatalogResult::Created { .. } = cat.create_serial_sequence(&name, &col.name) {
248                        tracing::info!("Created serial sequence for {name}.{}", col.name);
249                    }
250                }
251            }
252        }
253
254        // 3. Auto-create ART index for primary key
255        if columns.iter().any(|c| c.is_primary_key) {
256            let index_name = format!("{name}_pk_idx");
257            self.storage_manager
258                .create_art_index(&name, &index_name)
259                .map_err(|e| format!("Failed to create ART PK index for table '{name}': {e}"))?;
260        }
261
262        tracing::info!("Created node table '{name}'");
263        Ok(table_id)
264    }
265
266    /// Create a rel table: data table.
267    ///
268    /// The schema entry is created by the binder during `bind()`.
269    /// This method creates the storage-level table.
270    pub fn create_rel_table(
271        &self,
272        name: String,
273        src_table_id: u64,
274        dst_table_id: u64,
275        columns: Vec<akar_catalog::CatalogColumn>,
276    ) -> Result<u64, String> {
277        // 1. Create the data-level table
278        let storage_columns: Vec<ColumnDefinition> = columns
279            .iter()
280            .map(|c| ColumnDefinition {
281                name: c.name.clone(),
282                logical_type: c.logical_type,
283                is_primary_key: c.is_primary_key,
284                compression: c.compression,
285            })
286            .collect();
287        let rel_table =
288            self.storage_manager
289                .create_rel_table(name.clone(), src_table_id, dst_table_id, storage_columns);
290        let table_id = rel_table.table_id;
291
292        tracing::info!("Created rel table '{name}'");
293        Ok(table_id)
294    }
295
296    /// Drop a table: serial sequences + data table + schema entry.
297    pub fn drop_table(&self, name: &str) -> Result<(), String> {
298        // 1. Drop auto-created serial sequences. Sequences are named
299        // `{table}_{column}_serial`, so a prefix match on `{name}_` would also
300        // drop sequences owned by tables sharing the prefix (dropping `person`
301        // would remove `person_x`'s `person_x_id_serial`). Enumerate the
302        // table's own SERIAL columns and drop exactly their sequences (P51.28).
303        {
304            let mut cat = self.catalog.lock().map_err(|e| format!("Lock poisoned: {e}"))?;
305            let node_cols: Vec<String> = cat
306                .node_tables()
307                .into_iter()
308                .filter(|t| t.name == name)
309                .flat_map(|t| t.columns.iter())
310                .filter(|c| c.logical_type == akar_common::types::LogicalTypeID::Serial)
311                .map(|c| c.name.clone())
312                .collect();
313            let rel_cols: Vec<String> = cat
314                .rel_tables()
315                .into_iter()
316                .filter(|t| t.name == name)
317                .flat_map(|t| t.columns.iter())
318                .filter(|c| c.logical_type == akar_common::types::LogicalTypeID::Serial)
319                .map(|c| c.name.clone())
320                .collect();
321            for col in node_cols.into_iter().chain(rel_cols) {
322                let seq_name = akar_catalog::SequenceEntry::get_serial_name(name, &col);
323                if let akar_catalog::CatalogResult::Dropped { .. } = cat.drop_sequence(&seq_name) {
324                    tracing::info!("Dropped serial sequence '{seq_name}'");
325                }
326            }
327            // Drop schema entry
328            cat.drop_table(name);
329        }
330
331        // 2. Drop the data table
332        let table_catalog = self.storage_manager.table_catalog();
333        let node_tid = table_catalog.get_node_table_by_name(name).map(|t| t.table_id);
334        let rel_tid = table_catalog.get_rel_table_by_name(name).map(|t| t.table_id);
335        table_catalog.drop_node_table(name);
336        table_catalog.drop_rel_table(name);
337        if let Some(tid) = node_tid {
338            self.storage_manager.drop_table_persistence(tid);
339        }
340        if let Some(tid) = rel_tid {
341            self.storage_manager.drop_table_persistence(tid);
342        }
343
344        tracing::info!("Dropped table '{name}'");
345        Ok(())
346    }
347
348    /// Create a vector index: data index + auto-populate.
349    ///
350    /// The schema entry is created by the binder during `bind()`.
351    #[cfg(feature = "vector-extension")]
352    pub fn create_vector_index(
353        &self,
354        index_name: String,
355        table_name: String,
356        column_name: String,
357        metric: akar_vector::hnsw::DistanceMetric,
358        dimensions: u32,
359    ) -> Result<(), String> {
360        // 1. Create the data-level index
361        self.storage_manager.create_vector_index(
362            index_name.clone(),
363            table_name.clone(),
364            column_name.clone(),
365            metric,
366            dimensions,
367        );
368
369        // 2. Auto-populate from existing table data
370        let table_catalog = self.storage_manager.table_catalog();
371        if let Some(table) = table_catalog.get_node_table_by_name(&table_name) {
372            let col_idx = table.columns.iter().position(|c| c.name == column_name);
373            if let Some(col_idx) = col_idx {
374                for row_id in 0..table.num_rows as usize {
375                    if let Some(val) = table.get_value(row_id, col_idx) {
376                        if let Ok(vec) = akar_storage::extract_f64_list_from_value(val) {
377                            if let Some(mut vi) = table_catalog.get_vector_index_by_name_mut(&index_name) {
378                                vi.hnsw_mut().insert(vec, row_id);
379                            }
380                        }
381                    }
382                }
383            }
384        }
385
386        tracing::info!("Created vector index '{index_name}'");
387        Ok(())
388    }
389
390    /// Rebuild the HNSW graph of every vector index on the given tables.
391    ///
392    /// The index was only populated during `CREATE VECTOR INDEX`; without this
393    /// hook the graph served stale/positional row ids after INSERT/DELETE
394    /// (P52.38).
395    #[cfg(feature = "vector-extension")]
396    pub fn refresh_vector_indexes(&self, table_ids: &[u64]) {
397        self.storage_manager
398            .table_catalog()
399            .refresh_vector_indexes_for_tables(table_ids);
400    }
401
402    /// No-op when the vector extension is not compiled in.
403    #[cfg(not(feature = "vector-extension"))]
404    pub fn refresh_vector_indexes(&self, _table_ids: &[u64]) {}
405
406    /// Create an ART index on a node table: data index.
407    ///
408    /// The schema entry is created by the binder during `bind()`.
409    pub fn create_art_index(&self, table_name: &str, index_name: &str) -> Result<(), String> {
410        self.storage_manager.create_art_index(table_name, index_name)?;
411        Ok(())
412    }
413
414    /// Drop an ART index from a node table: data index + schema entry.
415    pub fn drop_art_index(&self, table_name: &str, _index_name: &str) -> Result<(), String> {
416        self.storage_manager.drop_art_index(table_name, _index_name)?;
417
418        // Update the schema entry
419        {
420            let mut cat = self.catalog.lock().map_err(|e| format!("Lock poisoned: {e}"))?;
421            if let Some(entry) = cat.get_entry_by_name_mut(table_name) {
422                if let akar_catalog::CatalogEntry::NodeTable(t) = entry {
423                    t.index_type = None;
424                    t.index_name = None;
425                }
426            }
427        }
428
429        Ok(())
430    }
431
432    /// Get the number of rows in a table by name.
433    pub fn table_num_rows(&self, name: &str) -> u64 {
434        self.storage_manager.table_catalog().node_table_num_rows(name)
435    }
436
437    /// Get table IDs for write operations (used by transaction locking).
438    ///
439    /// Propagates a poisoned catalog lock instead of silently treating it as a
440    /// missing table (Audit 2 NIT).
441    pub fn get_table_id(&self, name: &str) -> Result<Option<u64>, String> {
442        let catalog = self.catalog.lock().map_err(|e| format!("Lock poisoned: {e}"))?;
443        Ok(catalog.get_table_id(name))
444    }
445
446    /// Path of the persisted catalog file for this database.
447    pub fn catalog_file_path(&self) -> PathBuf {
448        self.storage_manager.db_path().join(CATALOG_FILE_NAME)
449    }
450
451    /// Connect to a remote Akar server (embedded server mode, P47).
452    ///
453    /// The server process owns the [`Database`] instance and its exclusive file
454    /// lock; remote clients only talk to the server over TCP and never open the
455    /// database directory themselves. Multiple processes can therefore access
456    /// the same database concurrently: one writer via the server plus any
457    /// number of read-only clients (and additional writers when
458    /// `concurrent_writes` is enabled).
459    ///
460    /// See [`crate::remote::RemoteDatabase`] for the returned handle's API.
461    pub fn connect_tcp(addr: impl Into<String>) -> Result<crate::remote::RemoteDatabase, String> {
462        crate::remote::RemoteDatabase::connect_tcp(addr)
463    }
464
465    /// Returns `true` when the database runs fully in-memory (`:memory:`),
466    /// in which case catalog persistence is skipped.
467    pub fn is_in_memory(&self) -> bool {
468        self.storage_manager.db_path().to_string_lossy() == ":memory:"
469    }
470
471    /// Persist the system catalog to disk.
472    ///
473    /// Called after every DDL statement so schema changes survive restarts.
474    /// The write is atomic (temp file + rename) and no-ops for `:memory:`
475    /// databases.
476    pub fn persist_catalog(&self) -> Result<(), String> {
477        if self.is_in_memory() {
478            return Ok(());
479        }
480        let catalog = self.catalog.lock().map_err(|e| format!("Lock poisoned: {e}"))?;
481        catalog
482            .save_to_path(&self.catalog_file_path())
483            .map_err(|e| format!("Failed to persist catalog: {e}"))
484    }
485
486    /// Restore storage-level tables from the loaded catalog.
487    ///
488    /// Called during `Database::new` after the catalog file is loaded so that
489    /// WAL DML replay and subsequent queries reference the same table IDs that
490    /// were in use when the database was shut down.
491    fn restore_storage_from_catalog(&self) {
492        let catalog = match self.catalog.lock() {
493            Ok(c) => c,
494            Err(_) => return,
495        };
496        for entry in catalog.all_entries() {
497            match entry {
498                akar_catalog::CatalogEntry::NodeTable(t) => {
499                    let columns: Vec<_> = t.columns.iter().map(ColumnDefinition::from).collect();
500                    let index_name = if t.has_art_index() {
501                        t.index_name.as_deref()
502                    } else {
503                        None
504                    };
505                    self.storage_manager
506                        .restore_node_table(t.table_id, t.name.clone(), columns, index_name);
507                }
508                akar_catalog::CatalogEntry::RelTable(t) => {
509                    let columns: Vec<_> = t.columns.iter().map(ColumnDefinition::from).collect();
510                    self.storage_manager.restore_rel_table(
511                        t.table_id,
512                        t.name.clone(),
513                        t.src_table_id,
514                        t.dst_table_id,
515                        columns,
516                    );
517                }
518                _ => {}
519            }
520        }
521    }
522
523    /// Open or create a database at the given path.
524    ///
525    /// # Arguments
526    /// * `db_path` — filesystem path where database files are stored.
527    /// * `config` — buffer pool size, thread count, etc.
528    ///
529    /// # Errors
530    /// Returns `Err` if the path is not writable or if existing data is corrupt.
531    pub fn new(db_path: impl Into<PathBuf>, config: SystemConfig) -> Result<Self, String> {
532        let db_path = db_path.into();
533        let is_memory = db_path.to_string_lossy() == ":memory:";
534
535        // Multi-process guard: take a file lock on <db_path>/akar.lock so two
536        // processes cannot open the same database directory concurrently.
537        // Read-only opens take a shared lock (multiple readers allowed); write
538        // opens take an exclusive lock. In-process reopens of the same path
539        // share the held lock via PROCESS_PATH_LOCKS (P53.35, E3).
540        let lock = if is_memory {
541            None
542        } else {
543            std::fs::create_dir_all(&db_path)
544                .map_err(|e| format!("Failed to create database directory '{}': {e}", db_path.display()))?;
545            let key = lock_key(&db_path);
546            let mut reg = process_path_locks().lock().unwrap();
547            match reg.get_mut(&key) {
548                // Already open in this process: share the held OS lock.
549                Some((_, count)) => {
550                    *count += 1;
551                }
552                None => {
553                    let file = std::fs::OpenOptions::new()
554                        .create(true)
555                        .read(true)
556                        .write(true)
557                        .truncate(false)
558                        .open(&key)
559                        .map_err(|e| format!("Failed to open lock file '{}': {e}", key.display()))?;
560                    let result = if config.read_only {
561                        file.try_lock_shared()
562                    } else {
563                        file.try_lock()
564                    };
565                    result
566                        .map_err(|_| format!("Database '{}' is already open by another process", db_path.display()))?;
567                    reg.insert(key.clone(), (file, 1));
568                }
569            }
570            Some(PathLock { key })
571        };
572
573        let memory_manager = Arc::new(MemoryManager::new(config.max_db_size));
574        let task_system = Arc::new(TaskSystem::new(config.max_num_threads as usize));
575
576        // Load a previously-persisted catalog (if any). Fresh databases and
577        // databases created before catalog persistence start with an empty
578        // catalog, preserving backward compatibility.
579        let catalog_file = db_path.join(CATALOG_FILE_NAME);
580        let catalog = Arc::new(Mutex::new(
581            Catalog::load_from_path(&catalog_file)
582                .map_err(|e| format!("Failed to load persisted catalog: {e}"))?
583                .unwrap_or_default(),
584        ));
585        let transaction_manager = {
586            let tx_config = akar_transaction::TransactionManagerConfig {
587                concurrent_writes: config.concurrent_writes,
588            };
589            Arc::new(TransactionManager::new_with_config(tx_config))
590        };
591        let function_registry = Arc::new(Mutex::new(FunctionRegistry::new()));
592        let storage_manager = Arc::new(StorageManager::new(db_path.clone(), memory_manager.clone()));
593        let stats_store = Arc::new(Mutex::new(StatsStore::new()));
594        let vfs = Arc::new(VirtualFileSystemRegistry::new());
595
596        let mut db = Self {
597            storage_manager,
598            catalog,
599            transaction_manager,
600            function_registry,
601            task_system,
602            memory_manager,
603            extension_registry: Mutex::new(ExtensionRegistry::new()),
604            stats_store,
605            vfs,
606            spill_threshold_override: AtomicU64::new(0),
607            spill_threshold_overridden: AtomicBool::new(false),
608            _lock: lock,
609            config,
610        };
611
612        // Recreate storage-level tables from the restored catalog (if any) so
613        // WAL DML replay below operates on the same table IDs.
614        db.restore_storage_from_catalog();
615
616        // Propagate the configured spiller (if any) so bulk ingest on the
617        // restored tables spills to disk once a NodeGroup exceeds the memory
618        // threshold (P51.44).
619        db.storage_manager.set_spiller(db.spiller());
620
621        // Load built-in extensions
622        db.register_builtin_extensions();
623
624        // Load all registered extensions
625        {
626            let mut ext_registry = db
627                .extension_registry
628                .lock()
629                .map_err(|e| format!("Lock poisoned: {e}"))?;
630            let context = ExtensionContext::new(db.function_registry.clone(), db.catalog.clone(), db.vfs.clone());
631            for result in ext_registry.load_all(&context) {
632                match result {
633                    (name, Ok(())) => tracing::info!("Extension '{name}' loaded successfully"),
634                    (name, Err(e)) => tracing::warn!("Extension '{name}' failed to load: {e}"),
635                }
636            }
637        }
638
639        // Register built-in sequence functions (nextval/currval)
640        {
641            let mut reg = db.function_registry.lock().map_err(|e| format!("Lock poisoned: {e}"))?;
642            crate::connection::utils::register_sequence_scalars(&mut reg, db.catalog.clone());
643        }
644
645        // Recovery (P45.4, amended P60.2): `recover()` loads the durable
646        // column mirrors FIRST — the state at the last checkpoint — then
647        // replays the WAL's typed Insert/Delete/Update records (including
648        // those decoded from LocalWALData blobs) on top. This reconstructs
649        // full state even when no checkpoint ever ran, without double-
650        // applying rows.
651        //
652        // (P61.3) Recovery MUST NOT fall back to a fresh, empty database on
653        // failure: that silently destroys every committed write in the WAL.
654        // On replay/persist error the database fails to open and the WAL file
655        // is left untouched, so an operator can still repair it.
656        if let Err(e) = db.storage_manager.recover() {
657            return Err(format!(
658                "WAL recovery failed (database may need manual repair): {e}. \
659                     Refusing to start with an empty database — check the WAL."
660            ));
661        }
662
663        Ok(db)
664    }
665
666    /// Register built-in extensions (JSON, FTS, Vector, HTTPFS, DuckDB).
667    fn register_builtin_extensions(&mut self) {
668        #[cfg(feature = "json-extension")]
669        {
670            let ext = Box::new(akar_json::JsonExtension::new());
671            if let Ok(mut reg) = self.extension_registry.lock() {
672                reg.register(ext);
673            }
674        }
675        #[cfg(feature = "fts-extension")]
676        {
677            let ext = Box::new(akar_fts::FtsExtension::new());
678            if let Ok(mut reg) = self.extension_registry.lock() {
679                reg.register(ext);
680            }
681        }
682        #[cfg(feature = "vector-extension")]
683        {
684            let ext = Box::new(akar_vector::VectorExtension::new());
685            if let Ok(mut reg) = self.extension_registry.lock() {
686                reg.register(ext);
687            }
688        }
689        #[cfg(all(feature = "httpfs-extension", not(akar_wasm)))]
690        {
691            let ext = Box::new(akar_httpfs::HttpfsExtension::new());
692            if let Ok(mut reg) = self.extension_registry.lock() {
693                reg.register(ext);
694            }
695        }
696        #[cfg(all(feature = "duckdb-extension", not(akar_wasm)))]
697        {
698            let ext = Box::new(akar_duckdb::DuckDbExtension::new());
699            if let Ok(mut reg) = self.extension_registry.lock() {
700                reg.register(ext);
701            }
702        }
703        #[cfg(feature = "algo-extension")]
704        {
705            let ext = Box::new(akar_algo::AlgoExtension::new());
706            if let Ok(mut reg) = self.extension_registry.lock() {
707                reg.register(ext);
708            }
709        }
710        #[cfg(feature = "neo4j-extension")]
711        {
712            let ext = Box::new(akar_neo4j::Neo4jExtension::new());
713            if let Ok(mut reg) = self.extension_registry.lock() {
714                reg.register(ext);
715            }
716        }
717        #[cfg(feature = "llm-extension")]
718        {
719            let ext = Box::new(akar_llm::LlmExtension::new());
720            if let Ok(mut reg) = self.extension_registry.lock() {
721                reg.register(ext);
722            }
723        }
724        #[cfg(all(feature = "ml-extension", not(akar_wasm)))]
725        {
726            let ext = Box::new(akar_ml::extension::MlExtension::new());
727            if let Ok(mut reg) = self.extension_registry.lock() {
728                reg.register(ext);
729            }
730        }
731        #[cfg(all(feature = "sqlite-extension", not(akar_wasm)))]
732        {
733            let ext = Box::new(akar_sqlite::SqliteExtension::new());
734            if let Ok(mut reg) = self.extension_registry.lock() {
735                reg.register(ext);
736            }
737        }
738        #[cfg(any(feature = "delta-extension", feature = "delta-native"))]
739        {
740            let ext = Box::new(akar_delta::DeltaExtension::new());
741            if let Ok(mut reg) = self.extension_registry.lock() {
742                reg.register(ext);
743            }
744        }
745        #[cfg(any(feature = "iceberg-extension", feature = "iceberg-native"))]
746        {
747            let ext = Box::new(akar_iceberg::IcebergExtension::new());
748            if let Ok(mut reg) = self.extension_registry.lock() {
749                reg.register(ext);
750            }
751        }
752        #[cfg(any(feature = "azure-extension", feature = "azure-native"))]
753        {
754            let ext = Box::new(akar_azure::AzureExtension::new());
755            if let Ok(mut reg) = self.extension_registry.lock() {
756                reg.register(ext);
757            }
758        }
759        #[cfg(all(feature = "postgres-extension", not(akar_wasm)))]
760        {
761            let ext = Box::new(akar_postgres::PostgresExtension::new());
762            if let Ok(mut reg) = self.extension_registry.lock() {
763                reg.register(ext);
764            }
765        }
766        #[cfg(any(feature = "unity-catalog-extension", feature = "unity-catalog-native"))]
767        {
768            let ext = Box::new(akar_unity_catalog::UnityCatalogExtension::new());
769            if let Ok(mut reg) = self.extension_registry.lock() {
770                reg.register(ext);
771            }
772        }
773    }
774}