Nine Phase 2 sub-plans operationalising ADR 0002 against the Phase 2 master plan, each sized to fit a focused implementation session and handed to Claude Code as a /goal brief without requiring the agent to load the master plan. Order of execution (each depends on the previous unless noted): - 0002a-skeleton-and-feature-gate.md -- postgres-backend Cargo feature + PgMemoryStore skeleton with todo!() bodies. D1+D2. - 0002b-pool-and-config.md -- PgPool builder, VestigeConfig/ PostgresConfig, vestige.toml loader wired into vestige-mcp. D3+D7 (master plan numbering). - 0002c-migrations.md -- sqlx migrations 0001_init/0002_hnsw including D7 (users/groups/memberships, owner/visibility/shared_with_groups) and D8 (codebase column). SQLite V15 parity migration. D4. - 0002d-store-impl-bodies.md -- real CRUD + registry bodies; trivial fts_search/vector_search bodies. D2+D6. - 0002e-hybrid-search.md -- one-statement RRF query. D5. - 0002f-migrate-cli.md -- vestige migrate copy (SQLite -> Postgres), --dry-run, idempotent re-runs, --allow-source-upgrade for pre-V15 sources. D8+D10. - 0002g-reembed.md -- vestige migrate reembed (offline rebuild). D9 + D10 reembed arm. Ships resolve_embedder helper as a workaround for the missing Embedder::from_name(&str) constructor. - 0002h-testing-and-benches.md -- testcontainers harness, six integration test files, Criterion bench at 1k/100k. D14+D15. - 0002i-runbook.md -- operator-facing deployment + day-2 runbook. D16. Supersession notice added to the master plan (0002-phase-2-postgres- backend.md) pointing at ADR 0002; body retained as archival reference. PR B carries this commit plus the previous two (ADR 0002 + Phase 1 amendment sub-plans); no code change.
28 KiB
Sub-plan 0002b -- Pool construction and VestigeConfig
Status: Draft Master plan: 0002-phase-2-postgres-backend.md ADR: 0002-phase-2-execution.md Predecessor: 0002a-skeleton-and-feature-gate.md
Context
This sub-plan delivers two of the master plan's deliverables now that the
0002a skeleton has landed:
- D3 -- pool construction in
crates/vestige-core/src/storage/postgres/pool.rs. Replaces thetodo!()body ofPgMemoryStore::connectwith a realPgPoolbuilder that reads aPostgresConfig. Registry/migration calls remaintodo!(); those are filled in by sub-plans0002c(migrations) and0002d(store bodies + registry). - D7 -- new module
crates/vestige-core/src/config.rscontainingVestigeConfig,StorageConfig,SqliteConfig,PostgresConfig,EmbeddingsConfig, plus aConfigErrorenum and a loader that readsvestige.toml. The loader is wired intovestige-mcpso the running server picks SQLite or Postgres at startup based on the config file.
After this sub-plan:
cargo build(default features, nopostgres-backend) compiles and the MCP server still defaults to SQLite when novestige.tomlis present.cargo build --features postgres-backendcompiles, withPgMemoryStore::connectnow wiring throughpool.rs(registry/migration stilltodo!()until0002cand0002d).- A
vestige.tomlexample can be round-tripped throughVestigeConfig::loadin a unit test.
This sub-plan deliberately does NOT:
- Add migrations (
0002c). - Fill in real CRUD/search bodies on
PgMemoryStore(0002d,0002e). - Add env-var override support (Phase 3 concern, called out in master plan D7 behaviour notes).
Dependencies
0002a-skeleton-and-feature-gate.mdmust be merged. That sub-plan createscrates/vestige-core/src/storage/postgres/mod.rswith:PgMemoryStorestruct holdingpool: PgPool.PgMemoryStore::connect(url: &str, max_connections: u32) -> MemoryStoreResult<Self>body =todo!().PgMemoryStore::from_pool(pool: PgPool) -> MemoryStoreResult<Self>body =todo!().- The trait impl block with all methods routed to
todo!(). - The
postgres-backendfeature gate on the module declaration instorage/mod.rs.
This sub-plan extends those bodies and adds two siblings: pool.rs and
registry.rs (the latter is a stub here, real body in 0002d).
Audit step (do this first)
Before adding config.rs, confirm there is no existing top-level config
loader. Run from the repo root:
rg -nF 'VestigeConfig' crates/
rg -nF 'toml::from_str' crates/
rg -n '#\[derive.*Deserialize.*\]' crates/vestige-core/src/
If a VestigeConfig struct already exists from Phase 1, treat the "Config
module" section below as additive: extend the existing struct rather than
creating a new file. The cross-cut additions in that case are:
- Add the
StorageConfigenum (gated and ungated branches). - Add
SqliteConfig,PostgresConfig. - Add the
default_path()helper if missing. - Add
ConfigErrorif a different error enum is used today (rename/extend instead of duplicating).
As of the audit at the time of this writing, no VestigeConfig exists in
vestige-core. directories::ProjectDirs is already used in
vestige-core/src/embeddings/local.rs and in
vestige-mcp/src/protocol/auth.rs, so the directories crate is already a
workspace dependency -- no new dep there.
Cargo manifest additions
Add toml to vestige-core. serde and thiserror are already present
from Phase 1; directories is already a transitive dep but we add it
explicitly so default_path() is supported.
cd crates/vestige-core
cargo add toml@0.8
cargo add directories@5
No new deps on vestige-mcp; it already depends on vestige-core.
sqlx is already added by 0002a behind the postgres-backend feature
with runtime-tokio, tls-rustls, postgres, uuid, chrono,
json, macros, migrate features. The pool module only uses what is
already pulled in.
Config module
File: crates/vestige-core/src/config.rs (new).
Re-exported from crates/vestige-core/src/lib.rs as pub mod config; plus
pub use config::{VestigeConfig, StorageConfig, SqliteConfig, EmbeddingsConfig, ConfigError};
and #[cfg(feature = "postgres-backend")] pub use config::PostgresConfig;.
Full content:
//! Vestige top-level configuration.
//!
//! Loaded from `~/.vestige/vestige.toml` by default; the path is overridable
//! via `VestigeConfig::load(Some(&path))`. Parsing uses serde + toml; the
//! `[storage]` section is internally-tagged on a `backend` field so a single
//! enum dispatch picks SQLite or Postgres.
use std::path::{Path, PathBuf};
use serde::Deserialize;
/// Top-level configuration as parsed from `vestige.toml`.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct VestigeConfig {
pub embeddings: EmbeddingsConfig,
pub storage: StorageConfig,
/// Reserved for Phase 3. Empty in Phase 2.
pub server: ServerConfig,
/// Reserved for Phase 3. Empty in Phase 2.
pub auth: AuthConfig,
}
/// Embedding provider selection.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EmbeddingsConfig {
/// Provider key. Phase 2 ships `"fastembed"` only.
pub provider: String,
/// Model name. For fastembed this is e.g. `"nomic-ai/nomic-embed-text-v1.5"`.
pub model: String,
}
impl Default for EmbeddingsConfig {
fn default() -> Self {
Self {
provider: "fastembed".to_string(),
model: crate::DEFAULT_EMBEDDING_MODEL.to_string(),
}
}
}
/// Storage backend selection. Internally tagged on the `backend` field:
///
/// ```toml
/// [storage]
/// backend = "sqlite"
///
/// [storage.sqlite]
/// path = "/home/user/.vestige/vestige.db"
/// ```
///
/// or, when compiled with `--features postgres-backend`:
///
/// ```toml
/// [storage]
/// backend = "postgres"
///
/// [storage.postgres]
/// url = "postgres://vestige:secret@localhost:5432/vestige"
/// max_connections = 10
/// acquire_timeout_secs = 30
/// ```
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "backend", rename_all = "lowercase", deny_unknown_fields)]
pub enum StorageConfig {
Sqlite(SqliteConfig),
#[cfg(feature = "postgres-backend")]
Postgres(PostgresConfig),
}
impl Default for StorageConfig {
fn default() -> Self {
StorageConfig::Sqlite(SqliteConfig::default())
}
}
/// SQLite backend configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SqliteConfig {
/// Path to the `vestige.db` file. If unset, the SqliteMemoryStore
/// constructor picks its platform default location.
#[serde(default)]
pub path: Option<PathBuf>,
}
impl Default for SqliteConfig {
fn default() -> Self {
Self { path: None }
}
}
/// Postgres backend configuration. Only present when the `postgres-backend`
/// Cargo feature is enabled.
#[cfg(feature = "postgres-backend")]
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PostgresConfig {
/// `postgres://user:pass@host:port/db` -- forwarded to
/// `PgConnectOptions::from_str`.
pub url: String,
/// Pool size. Default `10`.
#[serde(default)]
pub max_connections: Option<u32>,
/// Acquire timeout in seconds. Default `30`. Set above 30 so
/// testcontainer-based test fixtures do not race.
#[serde(default)]
pub acquire_timeout_secs: Option<u64>,
}
/// Reserved for Phase 3 (bind address, ports, TLS).
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ServerConfig {}
/// Reserved for Phase 3 (API keys, claims).
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AuthConfig {}
/// Errors raised while locating, reading, or parsing `vestige.toml`.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("config io: {0}")]
Io(#[from] std::io::Error),
#[error("config toml: {0}")]
Toml(#[from] toml::de::Error),
#[error("config dir: could not locate user home")]
NoHome,
#[error("invalid config: {0}")]
Invalid(String),
}
impl VestigeConfig {
/// Load config from `path` or from `default_path()` when `None`.
///
/// Returns `VestigeConfig::default()` (SQLite + fastembed defaults) when
/// the file does not exist. Any other I/O or parse failure is surfaced
/// as a `ConfigError`.
pub fn load(path: Option<&Path>) -> Result<Self, ConfigError> {
let resolved: PathBuf = match path {
Some(p) => p.to_path_buf(),
None => Self::default_path()?,
};
match std::fs::read_to_string(&resolved) {
Ok(text) => {
let cfg: VestigeConfig = toml::from_str(&text)?;
cfg.validate()?;
Ok(cfg)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Ok(Self::default())
}
Err(e) => Err(ConfigError::Io(e)),
}
}
/// `~/.vestige/vestige.toml`. The directory is NOT created here; loading
/// a missing file falls back to defaults.
pub fn default_path() -> Result<PathBuf, ConfigError> {
let dirs = directories::ProjectDirs::from("", "vestige", "vestige")
.ok_or(ConfigError::NoHome)?;
// ProjectDirs::config_dir() varies per OS. Vestige convention is
// ~/.vestige/vestige.toml on Linux/macOS regardless of XDG, so we
// build the path off the home dir explicitly.
let home = directories::UserDirs::new()
.ok_or(ConfigError::NoHome)?
.home_dir()
.to_path_buf();
let _ = dirs; // keep the dep wired; future Phase 3 may use it
Ok(home.join(".vestige").join("vestige.toml"))
}
/// Light cross-field validation. Heavy validation (URL parsing,
/// directory existence) is left to the backend constructors.
fn validate(&self) -> Result<(), ConfigError> {
if self.embeddings.provider.is_empty() {
return Err(ConfigError::Invalid(
"embeddings.provider must not be empty".into(),
));
}
if self.embeddings.model.is_empty() {
return Err(ConfigError::Invalid(
"embeddings.model must not be empty".into(),
));
}
match &self.storage {
StorageConfig::Sqlite(_) => {}
#[cfg(feature = "postgres-backend")]
StorageConfig::Postgres(cfg) => {
if cfg.url.is_empty() {
return Err(ConfigError::Invalid(
"storage.postgres.url must not be empty".into(),
));
}
}
}
Ok(())
}
}
Serde behaviour with postgres-backend off
StorageConfig is generated by serde only for the variants that are
compiled in. When postgres-backend is off and the user writes:
[storage]
backend = "postgres"
[storage.postgres]
url = "..."
serde returns a toml::de::Error of the form
unknown variant postgres, expected sqlite``. That error path goes
through From<toml::de::Error> for ConfigError, surfacing as
ConfigError::Toml(..). The MCP server prints this once at startup and
exits with a non-zero code; there is no panic.
To make the error friendlier we wrap that specific case in a clearer
message via a thin post-parse check. Add this small helper after parsing
in load():
// (Inside the Ok(text) arm in load(), wrapping the parse step.)
let cfg: VestigeConfig = match toml::from_str(&text) {
Ok(c) => c,
Err(e) => {
let msg = e.to_string();
if msg.contains("unknown variant `postgres`") {
return Err(ConfigError::Invalid(
"storage.backend = \"postgres\" requires building with --features postgres-backend".into(),
));
}
return Err(ConfigError::Toml(e));
}
};
This keeps the strict default deny_unknown_fields behaviour while giving the user a one-line action item.
Pool module
File: crates/vestige-core/src/storage/postgres/pool.rs (new).
#![cfg(feature = "postgres-backend")]
//! `PgPool` construction for the Postgres backend.
//!
//! Pool defaults follow ADR 0002 D2 + master plan D3:
//! - max_connections = 10
//! - acquire_timeout = 30s (must exceed testcontainer warmup)
//! - idle_timeout = 600s
//! - max_lifetime = 1800s
//! - test_before_acquire = false (cheap queries; saves a roundtrip)
use std::str::FromStr;
use std::time::Duration;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use sqlx::{ConnectOptions, PgPool};
use crate::config::PostgresConfig;
use crate::storage::memory_store::{MemoryStoreError, MemoryStoreResult};
const DEFAULT_MAX_CONNECTIONS: u32 = 10;
const DEFAULT_ACQUIRE_TIMEOUT_SECS: u64 = 30;
const IDLE_TIMEOUT_SECS: u64 = 600;
const MAX_LIFETIME_SECS: u64 = 1800;
const STATEMENT_CACHE_CAPACITY: usize = 256;
/// Build a Postgres connection pool from a `PostgresConfig`. Does NOT run
/// migrations or stamp the embedding registry; those are the caller's job
/// (`PgMemoryStore::connect`).
pub async fn build_pool(cfg: &PostgresConfig) -> MemoryStoreResult<PgPool> {
let opts = PgConnectOptions::from_str(&cfg.url)
.map_err(MemoryStoreError::from)?
.application_name("vestige")
.statement_cache_capacity(STATEMENT_CACHE_CAPACITY)
.log_statements(tracing::log::LevelFilter::Debug);
let max_conn = cfg.max_connections.unwrap_or(DEFAULT_MAX_CONNECTIONS);
let acquire = cfg
.acquire_timeout_secs
.unwrap_or(DEFAULT_ACQUIRE_TIMEOUT_SECS);
let pool = PgPoolOptions::new()
.max_connections(max_conn)
.min_connections(0)
.acquire_timeout(Duration::from_secs(acquire))
.idle_timeout(Some(Duration::from_secs(IDLE_TIMEOUT_SECS)))
.max_lifetime(Some(Duration::from_secs(MAX_LIFETIME_SECS)))
.test_before_acquire(false)
.connect_with(opts)
.await
.map_err(MemoryStoreError::from)?;
Ok(pool)
}
Wiring into PgMemoryStore::connect
In crates/vestige-core/src/storage/postgres/mod.rs, replace the
todo!() body left by 0002a for connect and from_pool with:
// In crates/vestige-core/src/storage/postgres/mod.rs
use sqlx::PgPool;
use crate::config::PostgresConfig;
use crate::storage::memory_store::{MemoryStoreError, MemoryStoreResult};
mod pool;
mod registry; // see "Registry stub" section below
pub struct PgMemoryStore {
pool: PgPool,
}
impl PgMemoryStore {
/// Convenience constructor matching `SqliteMemoryStore::new` shape.
/// Takes a URL + pool size for the common case.
pub async fn connect(url: &str, max_connections: u32) -> MemoryStoreResult<Self> {
let cfg = PostgresConfig {
url: url.to_string(),
max_connections: Some(max_connections),
acquire_timeout_secs: None,
};
Self::connect_with(&cfg).await
}
/// Full-config constructor.
pub async fn connect_with(cfg: &PostgresConfig) -> MemoryStoreResult<Self> {
let pool = pool::build_pool(cfg).await?;
Self::from_pool(pool).await
}
/// Construct from an already-built pool (used by tests and the migrate
/// CLI to share a pool across operations).
pub async fn from_pool(pool: PgPool) -> MemoryStoreResult<Self> {
// Migrations are added by 0002c.
// todo!("run sqlx::migrate! once 0002c lands")
registry::ensure_registry_stub(&pool).await?;
Ok(Self { pool })
}
}
connect_with is the long-lived API; connect becomes a thin shim that
stays compatible with the master-plan-mandated signature.
Registry stub
File: crates/vestige-core/src/storage/postgres/registry.rs (new, stub).
#![cfg(feature = "postgres-backend")]
//! Embedding registry. Real body lands in sub-plan 0002d.
use sqlx::PgPool;
use crate::storage::memory_store::MemoryStoreResult;
/// Placeholder. Real implementation in 0002d reads/writes `embedding_model`
/// and stamps `ALTER TABLE memories ALTER COLUMN embedding TYPE vector($N)`.
pub(crate) async fn ensure_registry_stub(_pool: &PgPool) -> MemoryStoreResult<()> {
// Intentionally a no-op until 0002c lands the table + 0002d lands the
// real body. Leaving this as todo!() would crash the MCP server at
// startup the moment a user switches `backend = "postgres"`, which is
// not what we want for the build verification step in this sub-plan.
Ok(())
}
The no-op keeps cargo build --features postgres-backend not just
compiling but also allowing the MCP server to boot against a Postgres
URL pointing at an already-migrated database (the local-dev-postgres-setup
docs cover bringing up such a DB by hand). Real init lands in 0002d.
Error variants
File: crates/vestige-core/src/storage/memory_store.rs (edit).
The Phase 1 enum MemoryStoreError gains two feature-gated variants. These
were deferred in 0002a and become required as soon as pool.rs calls
.map_err(MemoryStoreError::from) on sqlx::Error.
// Within enum MemoryStoreError { ... } in memory_store.rs
#[cfg(feature = "postgres-backend")]
#[error("postgres error: {0}")]
Postgres(#[from] sqlx::Error),
#[cfg(feature = "postgres-backend")]
#[error("postgres migration error: {0}")]
Migrate(#[from] sqlx::migrate::MigrateError),
Both use thiserror's #[from] attribute so the ? operator works in
pool.rs, the migrate module (0002c), and registry code (0002d).
Default-features build (no postgres-backend) sees neither variant; the
enum stays exhaustive on stable.
If clippy fires on non_exhaustive due to the gated variants, add
#[non_exhaustive] on the enum. That has no caller-side effect since the
enum is constructed only inside the crate.
vestige-mcp wiring
Cargo feature passthrough
File: crates/vestige-mcp/Cargo.toml (edit).
Add a feature that forwards through to vestige-core. Default features in
vestige-mcp stay unchanged.
[features]
default = ["embeddings", "vector-search"]
embeddings = ["vestige-core/embeddings"]
vector-search = ["vestige-core/vector-search"]
postgres-backend = ["vestige-core/postgres-backend"]
Verify with:
cargo build -p vestige-mcp --features postgres-backend
Backend dispatch at startup
File: crates/vestige-mcp/src/main.rs (edit around the existing
Storage::new(storage_path) call -- see audit note above; in the current
worktree this is around line 285).
The current code is roughly:
let storage_path = match prepare_storage_path(config.data_dir) { ... };
let storage = match Storage::new(storage_path) { ... };
Replace that with a dispatch driven by VestigeConfig:
use std::sync::Arc;
use vestige_core::config::{StorageConfig, VestigeConfig};
use vestige_core::storage::SqliteMemoryStore;
#[cfg(feature = "postgres-backend")]
use vestige_core::storage::postgres::PgMemoryStore;
use vestige_core::storage::MemoryStore;
// Earlier: still call prepare_storage_path to honour --data-dir override.
let storage_path = match prepare_storage_path(config.data_dir.clone()) { ... };
// New: load vestige.toml (or fall back to defaults).
let vestige_cfg = match VestigeConfig::load(config.config_path.as_deref()) {
Ok(c) => c,
Err(e) => {
eprintln!("vestige: failed to load config: {e}");
std::process::exit(2);
}
};
let storage: Arc<dyn MemoryStore> = match &vestige_cfg.storage {
StorageConfig::Sqlite(sqlite_cfg) => {
// CLI flag --data-dir wins over the config file path.
let path = storage_path.clone().or_else(|| sqlite_cfg.path.clone());
let s = SqliteMemoryStore::new(path).unwrap_or_else(|e| {
eprintln!("vestige: sqlite init failed: {e}");
std::process::exit(3);
});
Arc::new(s)
}
#[cfg(feature = "postgres-backend")]
StorageConfig::Postgres(pg_cfg) => {
let s = PgMemoryStore::connect_with(pg_cfg).await.unwrap_or_else(|e| {
eprintln!("vestige: postgres init failed: {e}");
std::process::exit(3);
});
Arc::new(s)
}
};
The config_path: Option<PathBuf> field on the local Config (or
clap-derived Args) struct must be added if not present; it accepts
--config <path>. Default behaviour (no flag) goes through
VestigeConfig::default_path().
If the existing main wires Storage through a concrete type rather than
Arc<dyn MemoryStore>, the dispatch above lives behind a helper:
async fn build_store(cfg: &VestigeConfig, cli_path: Option<PathBuf>)
-> Result<Arc<dyn MemoryStore>, anyhow::Error>
{ ... }
and the caller chains .into() as needed. Phase 1 already moved
cognitive modules to Arc<dyn MemoryStore> so this should be a pure
substitution; if a concrete-type holdout is found, fix it locally in this
sub-plan (separate commit) rather than punting.
vestige.toml example
The canonical example to ship in docs/ (Phase 2 docs land in 0002i,
runbook), shown here for reference and used verbatim by the unit test
below.
# vestige.toml -- top-level configuration
#
# Default location: ~/.vestige/vestige.toml
# Override: vestige-mcp --config /path/to/vestige.toml
[embeddings]
provider = "fastembed"
model = "nomic-ai/nomic-embed-text-v1.5"
# --- SQLite backend (default) ---
[storage]
backend = "sqlite"
[storage.sqlite]
path = "/home/user/.vestige/vestige.db"
# --- Postgres backend (requires --features postgres-backend) ---
# [storage]
# backend = "postgres"
#
# [storage.postgres]
# url = "postgres://vestige:secret@localhost:5432/vestige"
# max_connections = 10
# acquire_timeout_secs = 30
[server]
# Reserved for Phase 3 (bind address, ports, TLS).
[auth]
# Reserved for Phase 3 (API keys, claims).
Verification
Run all of these from the repo root. The first three are the gates that must pass before this sub-plan is considered done.
1. Default build (no Postgres)
cargo build -p vestige-core
cargo build -p vestige-mcp
cargo test -p vestige-core --lib
Expected: clean build. VestigeConfig::default() selects SQLite; the MCP
server boots the same way it did pre-sub-plan.
2. Postgres-feature build
cargo build -p vestige-core --features postgres-backend
cargo build -p vestige-mcp --features postgres-backend
Expected: clean build. PgMemoryStore::connect_with resolves to
pool::build_pool + registry::ensure_registry_stub; no todo!() is
reachable on the build path. connect and from_pool are exported.
3. Clippy across both feature sets
cargo clippy -p vestige-core -- -D warnings
cargo clippy -p vestige-core --features postgres-backend -- -D warnings
cargo clippy -p vestige-mcp --features postgres-backend -- -D warnings
4. Unit test: round-trip the example
Add this test to crates/vestige-core/src/config.rs:
#[cfg(test)]
mod tests {
use super::*;
const EXAMPLE_SQLITE: &str = r#"
[embeddings]
provider = "fastembed"
model = "nomic-ai/nomic-embed-text-v1.5"
[storage]
backend = "sqlite"
[storage.sqlite]
path = "/home/user/.vestige/vestige.db"
"#;
#[cfg(feature = "postgres-backend")]
const EXAMPLE_POSTGRES: &str = r#"
[embeddings]
provider = "fastembed"
model = "nomic-ai/nomic-embed-text-v1.5"
[storage]
backend = "postgres"
[storage.postgres]
url = "postgres://vestige:secret@localhost:5432/vestige"
max_connections = 10
acquire_timeout_secs = 30
"#;
#[test]
fn parses_sqlite_example() {
let cfg: VestigeConfig = toml::from_str(EXAMPLE_SQLITE).expect("parse");
match cfg.storage {
StorageConfig::Sqlite(s) => assert!(s.path.is_some()),
#[cfg(feature = "postgres-backend")]
StorageConfig::Postgres(_) => panic!("wrong variant"),
}
assert_eq!(cfg.embeddings.provider, "fastembed");
}
#[cfg(feature = "postgres-backend")]
#[test]
fn parses_postgres_example() {
let cfg: VestigeConfig = toml::from_str(EXAMPLE_POSTGRES).expect("parse");
match cfg.storage {
StorageConfig::Postgres(p) => {
assert_eq!(p.url, "postgres://vestige:secret@localhost:5432/vestige");
assert_eq!(p.max_connections, Some(10));
assert_eq!(p.acquire_timeout_secs, Some(30));
}
StorageConfig::Sqlite(_) => panic!("wrong variant"),
}
}
#[cfg(not(feature = "postgres-backend"))]
#[test]
fn rejects_postgres_when_feature_off() {
let toml_text = r#"
[storage]
backend = "postgres"
[storage.postgres]
url = "postgres://x/y"
"#;
let res: Result<VestigeConfig, _> = toml::from_str(toml_text);
assert!(res.is_err(), "must fail without postgres-backend feature");
}
#[test]
fn defaults_pick_sqlite() {
let cfg = VestigeConfig::default();
assert!(matches!(cfg.storage, StorageConfig::Sqlite(_)));
}
#[test]
fn load_missing_file_returns_default() {
let tmp = std::env::temp_dir().join("vestige-no-such-file.toml");
let _ = std::fs::remove_file(&tmp);
let cfg = VestigeConfig::load(Some(&tmp)).expect("missing file is OK");
assert!(matches!(cfg.storage, StorageConfig::Sqlite(_)));
}
#[test]
fn load_roundtrip_from_disk() {
let tmp = std::env::temp_dir().join("vestige-roundtrip.toml");
std::fs::write(&tmp, EXAMPLE_SQLITE).unwrap();
let cfg = VestigeConfig::load(Some(&tmp)).expect("load");
assert!(matches!(cfg.storage, StorageConfig::Sqlite(_)));
let _ = std::fs::remove_file(&tmp);
}
}
Run:
cargo test -p vestige-core --lib config::
cargo test -p vestige-core --lib config:: --features postgres-backend
5. Smoke: server boots with default config
# default build, no vestige.toml on disk
cargo run -p vestige-mcp -- --help
# should print help, no panic
Acceptance criteria
cargo build -p vestige-core(default features) succeeds.cargo build -p vestige-core --features postgres-backendsucceeds.cargo build -p vestige-mcp(default features) succeeds.cargo build -p vestige-mcp --features postgres-backendsucceeds.cargo clippywith and withoutpostgres-backendis clean on both crates.crates/vestige-core/src/config.rsexists, exposesVestigeConfig,StorageConfig,SqliteConfig,EmbeddingsConfig,ConfigError, plusPostgresConfigwhen the feature is on.VestigeConfig::load(None)returnsOk(default)when~/.vestige/vestige.tomlis missing.VestigeConfig::load(Some(&path))round-trips both the SQLite and Postgres example blocks above.- With
postgres-backendoff, parsingbackend = "postgres"returns a clearConfigError::Invalidmentioning the feature flag, NOT a panic. crates/vestige-core/src/storage/postgres/pool.rsexists, implementingbuild_pool(&PostgresConfig) -> MemoryStoreResult<PgPool>with the documented defaults.PgMemoryStore::connect,connect_with, andfrom_poolall wire throughpool::build_pool. None of them istodo!(). The registry step is a no-op stub documented as filled in by0002d.MemoryStoreError::Postgres(sqlx::Error)andMemoryStoreError::Migrate(sqlx::migrate::MigrateError)exist behind#[cfg(feature = "postgres-backend")]with#[from].vestige-mcphas apostgres-backendfeature that forwards tovestige-core/postgres-backend.vestige-mcp/src/main.rsselects SQLite vs Postgres at startup based onVestigeConfig. SQLite is the default when no config file is present.- Unit tests in the "Verification" section pass on both feature sets.
Out of scope (handled by other sub-plans)
- Migrations (
crates/vestige-core/migrations/postgres/*.sql) --0002c. - Real
PgMemoryStoreCRUD/search/scheduling/edges bodies --0002d,0002e. ensure_registryreal body withALTER COLUMN TYPE vector(N)--0002d.vestige migrate --from sqlite --to postgresCLI --0002f.- Re-embed flow --
0002g. - Env-var override (
VESTIGE_POSTGRES_URL, etc.) -- Phase 3. - RLS, multi-tenant column population -- Phase 3.