Sentinel Logo

Store

Learn about Store, the top-level container for collections in Sentinel.

The Store is the top-level container in Sentinel that manages all your collections. It represents a single database instance backed by a directory on your filesystem. This guide covers creating stores, configuring signing, listing and deleting collections, and understanding the directory structure.

Creating a Store

Create a new store by specifying a path and an optional passphrase:

use sentinel_dbms::Store;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create a store without signing
    let store = Store::new("./my-data", None).await?;

    // Or create a store with signing enabled
    let signed_store = Store::new("./secure-data", Some("my-passphrase")).await?;

    Ok(())
}

When you create a store, Sentinel creates the root directory if it doesn’t exist. This operation is idempotent, creating a store that already exists simply opens it for use.

Store Path and Structure

The store path becomes the root directory for all your data. Sentinel creates a data/ subdirectory to hold collections:

my-data/              # Store root (your specified path)
└── data/             # Data directory
    ├── users/        # A collection
    ├── audit_logs/   # Another collection
    └── .keys/        # Internal: encrypted signing key (if using passphrase)

The .keys/ directory is created automatically when you provide a passphrase. It stores your encrypted signing key in a special collection that only the passphrase can unlock.

Listing Collections

List all collections in a store using the list_collections method:

use sentinel_dbms::Store;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let store = Store::new("./my-data", None).await?;

    // List all collections
    let collections = store.list_collections().await?;

    println!("Collections:");
    for name in collections {
        println!("  - {}", name);
    }

    Ok(())
}

This method returns a vector of collection names in no particular order. If the store has no collections, an empty vector is returned.

Deleting Collections

Remove an entire collection and all its documents using delete_collection:

use sentinel_dbms::Store;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let store = Store::new("./my-data", None).await?;

    // Delete a collection and all its documents
    store.delete_collection("temp_collection").await?;

    Ok(())
}

The deletion operation is permanent and cannot be undone. If the collection doesn’t exist, the operation succeeds silently (idempotent). Use with caution.

Signing with Passphrases

When you provide a passphrase, Sentinel generates an Ed25519 signing key and encrypts it for secure storage. This key signs all documents created in the store, providing tamper-evident storage.

use sentinel_dbms::Store;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // First run: generates and stores a new signing key
    let store = Store::new("./secure-data", Some("my-secret")).await?;

    // Subsequent runs: loads of existing signing key
    // You must use the same passphrase!
    let store = Store::new("./secure-data", Some("my-secret")).await?;

    Ok(())
}

The signing key is derived and encrypted using your configured key derivation function (Argon2id by default) and encryption algorithm (XChaCha20-Poly1305 by default). Even if someone accesses your store files, they cannot sign new documents without your passphrase.

Setting a Signing Key Programmatically

You can also set a signing key directly without using a passphrase:

use sentinel_dbms::Store;
use sentinel_dbms::SigningKeyManager;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut store = Store::new("./my-data", None).await?;

    // Generate a signing key
    let signing_key = SigningKeyManager::generate_key();

    // Set it on the store
    store.set_signing_key(signing_key);

    // Now all new documents will be signed
    Ok(())
}

This approach is useful when you want to manage keys separately from passphrases, such as when integrating with external key management systems.

Key Derivation Process

When you provide a passphrase, Sentinel performs these steps:

  1. First Run: Generates a random 32-byte salt, derives an encryption key from your passphrase using the configured key derivation function, generates a new Ed25519 signing key, encrypts the signing key with the derived encryption key, and stores the encrypted key and salt in .keys/signing_key.json.

  2. Subsequent Runs: Reads the stored salt, re-derives the encryption key from your passphrase, decrypts the signing key, and uses it for signing documents.

This approach means you never store the raw passphrase, only the salt needed to re-derive the encryption key.

Opening Existing Stores

Opening an existing store is the same as creating one:

use sentinel_dbms::Store;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Opens existing store or creates if it doesn't exist
    let store = Store::new("./existing-data", None).await?;

    Ok(())
}

If the store was created with a passphrase, you must provide the same passphrase to access it with signing capabilities. You can open a signed store without a passphrase, but new documents won’t be signed:

use sentinel_dbms::Store;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Store was created with passphrase, but opened without
    let store = Store::new("./secure-data", None).await?;

    // You can still read documents, but new ones won't be signed
    let users = store.collection("users").await?;

    Ok(())
}

Accessing Collections

Use the collection method to get or create a collection:

use sentinel_dbms::Store;
use serde_json::json;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let store = Store::new("./my-data", None).await?;

    // Get or create collections
    let users = store.collection("users").await?;
    let products = store.collection("products").await?;
    let audit_logs = store.collection("audit_logs").await?;

    // Use them immediately
    users.insert("alice", json!({"name": "Alice"})).await?;
    products.insert("prod-1", json!({"name": "Widget"})).await?;

    Ok(())
}

Collection names must be valid filesystem directory names. Sentinel validates names to ensure cross-platform compatibility.

Collection Name Validation

Collection names follow strict validation rules for filesystem safety:

RuleDescription
Not emptyNames cannot be empty strings
Valid charactersOnly alphanumeric, underscore, hyphen, and dot
No path separatorsForward slash and backslash are forbidden
No control charactersASCII 0x00-0x1F and 0x7F are forbidden
Not Windows reservedCON, PRN, AUX, NUL, COM1-9, LPT1-9 are forbidden
No Windows special chars< > : " | ? * are forbidden
No leading dotsNames starting with . are forbidden (except .keys)

Examples of valid names: users, audit_logs, products-v2, data.backup

Examples of invalid names: users/admin, CON, file:name, .hidden, empty string

Error Handling

Store operations return Result<T, SentinelError>. Common errors include:

use sentinel_dbms::{Store, SentinelError};

#[tokio::main]
async fn main() {
    match Store::new("./my-data", Some("wrong-passphrase")).await {
        Ok(store) => {
            println!("Store opened successfully");
        }
        Err(SentinelError::CryptoFailed { operation }) => {
            eprintln!("Cryptographic error: {}", operation);
            // Likely wrong passphrase for existing store
        }
        Err(SentinelError::Io { source }) => {
            eprintln!("I/O error: {}", source);
            // Permission denied, disk full, etc.
        }
        Err(SentinelError::StoreCorruption { reason }) => {
            eprintln!("Store corrupted: {}", reason);
            // Signing key file is damaged or missing fields
        }
        Err(e) => {
            eprintln!("Unexpected error: {}", e);
        }
    }
}

Thread Safety

The Store struct is safe to share across threads. You can create multiple collections concurrently and perform operations from different threads. Each collection manages its own synchronization internally.

use sentinel_dbms::Store;
use serde_json::json;
use std::sync::Arc;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let store = Arc::new(Store::new("./my-data", None).await?);

    let store1 = store.clone();
    let store2 = store.clone();

    let handle1 = tokio::spawn(async move {
        let users = store1.collection("users").await.unwrap();
        users.insert("alice", json!({"name": "Alice"})).await.unwrap();
    });

    let handle2 = tokio::spawn(async move {
        let products = store2.collection("products").await.unwrap();
        products.insert("widget", json!({"name": "Widget"})).await.unwrap();
    });

    handle1.await?;
    handle2.await?;

    Ok(())
}

Store Internals

The Store manages several internal mechanisms:

Directory Creation

The store automatically creates the directory hierarchy on first use:

store-path/           # Root directory (created by Store::new)
└── data/             # Data directory (created on first collection access)
    ├── .keys/        # Internal key storage (created with passphrase)
    └── users/        # First collection (created by collection())

Signing Key Management

When a passphrase is provided, the signing key is stored securely:

  1. A random 32-byte salt is generated
  2. An encryption key is derived from the passphrase + salt using the configured KDF
  3. A new Ed25519 signing key pair is generated
  4. The private key is encrypted with the derived encryption key
  5. The encrypted private key and salt are stored in .keys/signing_key.json

On subsequent opens, the process is reversed to recover the signing key.

Best Practices

When working with stores, keep these recommendations in mind:

  • Choose meaningful paths. Use paths that describe your application’s purpose: ./audit-logs, ./certificates, ./config-store.

  • Secure your passphrase. Don’t hardcode passphrases. Load them from environment variables, secret managers, or secure configuration.

  • Use separate stores for separate purposes. Don’t mix audit logs with user data in the same store if they have different security requirements.

  • Back up regularly. Since stores are just directories, use rsync, tar, or your preferred backup tool.

  • Version with Git. Your entire store can be a Git repository for complete change history.

  • Test passphrase recovery. Make sure you can open a signed store with your passphrase before committing to production.

Next Steps

Now that you understand stores, learn about:

  • Collection: Working with collections and CRUD operations
  • Document: Understanding document structure and metadata
  • Cryptography: Configuring hashing, signing, and encryption