Vix.cpp docs moved to vixcpp.com
Skip to content

Store

store is the local key-value state module of Softadastra Engine.

It represents the current local state of a node.

The core rule is:

txt
WAL records history.
Store exposes current state.

The store is where local values become readable after operations are applied.

Why store exists

Softadastra is local-first.

A local-first runtime needs a place where the current local state can be read and written without requiring the network.

The store provides that local state.

It allows the engine to:

  • write values locally
  • read values locally
  • remove values locally
  • recover state from WAL
  • track entry versions
  • build snapshots from operation history
  • produce operations for sync

The store is used by higher-level systems such as the SDK, CLI, sync engine, and local-first applications.

What store provides

The store module provides:

  • StoreEngine
  • StoreConfig
  • Key
  • Value
  • Entry
  • Operation
  • OperationEncoder
  • OperationDecoder
  • SnapshotBuilderStore
  • put
  • get
  • remove
  • entries
  • size
  • recovery from WAL

It gives the engine a simple local key-value model:

txt
Key -> Value

With operation history and version tracking.

What store does not do

store must not:

  • connect peers
  • send network messages
  • discover nodes
  • own sync retry policy
  • own transport delivery
  • own CLI parsing
  • decide peer availability
  • hide WAL failures

The rule is:

txt
store applies local operations.
sync tracks propagation.
transport sends messages.
discovery finds peers.

Include

Use the top-level include:

cpp
#include <softadastra/store/Store.hpp>

Module location

The module lives in:

txt
modules/store/

Typical structure:

txt
modules/store/
├── include/
│   └── softadastra/store/
│       ├── core/
│       ├── encoding/
│       ├── engine/
│       ├── snapshot/
│       ├── types/
│       └── Store.hpp
├── src/
├── examples/
├── tests/
├── README.md
├── CMakeLists.txt
└── CHANGELOG.md

The exact structure can evolve, but the responsibility should stay stable:

txt
store current local key-value state and recover it from operation history

Main concepts

The store module is built around these concepts:

  • StoreConfig
  • StoreEngine
  • Key
  • Value
  • Entry
  • Operation
  • Snapshot
  • Recovery

The normal flow is:

txt
StoreConfig

StoreEngine

put / get / remove

current local state

With WAL enabled:

txt
StoreConfig::durable

StoreEngine

operation encoded

WAL append

store apply

recoverable local state

StoreConfig

StoreConfig configures how the store runs.

The main modes are:

  • memory-only
  • durable WAL-backed

Memory-only is useful for temporary state.

Durable mode is useful when state must survive restart.

Memory-only store

Memory-only mode keeps state in memory.

Example:

cpp
store::engine::StoreEngine engine{
    store::core::StoreConfig::memory_only()};

This mode is useful for:

  • tests
  • temporary state
  • small demos
  • non-durable local tools

Memory-only state can be lost when the process exits.

Durable store

Durable mode uses a WAL path.

Example:

cpp
store::engine::StoreEngine engine{
    store::core::StoreConfig::durable("store.wal")};

This mode is useful when local operations must be recoverable.

The flow is:

txt
put / remove

operation written to WAL

operation applied to store

On restart:

txt
open store

read WAL

replay operations

rebuild current state

Key

Key identifies a value in the store.

Example:

cpp
store::types::Key{"user:1"}

Good keys are:

  • non-empty
  • stable
  • human-readable when possible
  • structured by domain

Good examples:

txt
user:1
settings:theme
profile:name
files/docs/readme.txt
session:1
product:1

Avoid empty keys.

Value

Value stores the data associated with a key.

Example:

cpp
store::types::Value::from_string("Gaspard")

A value can represent string data, and the module can evolve toward binary values when needed.

Example usage:

cpp
auto value =
    store::types::Value::from_string("Softadastra");

Read it back:

cpp
value.to_string()

Entry

Entry represents a value currently stored under a key.

An entry can contain:

  • key
  • value
  • version
  • timestamp, if supported
  • metadata, if supported

Conceptually:

cpp
Entry {
    key
    value
    version
}

The version changes when the entry is updated.

Operation

Operation represents a store change.

Common operation types:

  • put
  • update
  • delete

Example:

cpp
auto operation = store::core::Operation::put(
    store::types::Key{"user:1"},
    store::types::Value::from_string("Gaspard"));

Operations are important because they can be:

  • encoded
  • written to WAL
  • replayed
  • tracked by sync
  • sent to peers
  • applied remotely

StoreEngine

StoreEngine is the main runtime object of the store module.

It provides:

  • put
  • get
  • remove
  • contains, if exposed
  • entries
  • size
  • last recovered sequence

It owns the current local state.

Basic store example

cpp
#include <filesystem>
#include <iostream>

#include <softadastra/store/Store.hpp>

using namespace softadastra;

int main()
{
    std::cout << "== STORE BASIC EXAMPLE ==\n";

    const std::string wal_path = "basic_store.wal";
    std::filesystem::remove(wal_path);

    store::engine::StoreEngine engine{
        store::core::StoreConfig::durable(wal_path)};

    auto put_result = engine.put(
        store::types::Key{"user:1"},
        store::types::Value::from_string("Gaspard"));

    if (put_result.is_err())
    {
        std::cerr << "put failed: "
                  << put_result.error().message()
                  << "\n";

        return 1;
    }

    std::cout << "Put version="
              << put_result.value().version
              << "\n";

    auto entry = engine.get(store::types::Key{"user:1"});

    if (!entry.has_value())
    {
        std::cerr << "entry not found\n";
        return 1;
    }

    std::cout << "Entry key="
              << entry->key.str()
              << " value="
              << entry->value.to_string()
              << " version="
              << entry->version
              << "\n";

    auto remove_result = engine.remove(
        store::types::Key{"user:1"});

    if (remove_result.is_err())
    {
        std::cerr << "remove failed: "
                  << remove_result.error().message()
                  << "\n";

        return 1;
    }

    std::cout << "Removed="
              << remove_result.value().deleted
              << "\n";

    std::filesystem::remove(wal_path);

    return 0;
}

Put flow

A put operation writes or updates a value.

Flow:

txt
put requested

validate key

create Operation::Put

encode operation, if WAL-backed

append to WAL, if durable

apply operation to current store

create or update Entry

return PutResult

The important rule is:

txt
If WAL append fails in durable mode, do not pretend the write is durably accepted.

Get flow

A get operation reads the current local state.

Flow:

txt
get requested

validate key

lookup entry

return entry or not found

A local read should not require:

  • WAL
  • sync
  • transport
  • discovery
  • peer
  • network

The store is local.

Remove flow

A remove operation deletes a key.

Flow:

txt
remove requested

validate key

create Operation::Delete

append to WAL, if durable

remove entry from current store

return RemoveResult

After remove:

txt
get(key) -> not found

If the remove operation is written to WAL, recovery should preserve the deleted state.

Memory-only example

cpp
#include <iostream>

#include <softadastra/store/Store.hpp>

using namespace softadastra;

int main()
{
    std::cout << "== STORE MEMORY ONLY EXAMPLE ==\n";

    store::engine::StoreEngine engine{
        store::core::StoreConfig::memory_only()};

    auto result = engine.put(
        store::types::Key{"session:1"},
        store::types::Value::from_string("temporary-value"));

    if (result.is_err())
    {
        std::cerr << "put failed: "
                  << result.error().message()
                  << "\n";

        return 1;
    }

    std::cout << "Created="
              << result.value().created
              << " version="
              << result.value().version
              << "\n";

    auto entry = engine.get(
        store::types::Key{"session:1"});

    if (entry.has_value())
    {
        std::cout << "session:1 => "
                  << entry->value.to_string()
                  << "\n";
    }

    return 0;
}

Durable WAL example

cpp
#include <filesystem>
#include <iostream>

#include <softadastra/store/Store.hpp>

using namespace softadastra;

int main()
{
    std::cout << "== STORE WAL USAGE EXAMPLE ==\n";

    const std::string wal_path = "store_wal_usage.wal";
    std::filesystem::remove(wal_path);

    auto config =
        store::core::StoreConfig::durable(wal_path);

    store::engine::StoreEngine engine{config};

    auto first = engine.put(
        store::types::Key{"product:1"},
        store::types::Value::from_string("Laptop"));

    auto second = engine.put(
        store::types::Key{"product:2"},
        store::types::Value::from_string("Phone"));

    auto third = engine.put(
        store::types::Key{"product:1"},
        store::types::Value::from_string("Laptop Pro"));

    if (first.is_err() || second.is_err() || third.is_err())
    {
        std::cerr << "failed to write one or more entries\n";
        return 1;
    }

    std::cout << "Current store size: "
              << engine.size()
              << "\n";

    for (const auto &[key, entry] : engine.entries())
    {
        std::cout << key
                  << " => "
                  << entry.value.to_string()
                  << " version="
                  << entry.version
                  << "\n";
    }

    std::filesystem::remove(wal_path);

    return 0;
}

Recovery

Recovery rebuilds store state from WAL history.

Flow:

txt
StoreEngine starts with durable config

WAL is opened

records are read

operations are decoded

operations are applied in sequence

current state is rebuilt

Recovery must be deterministic.

txt
same WAL

same replay order

same final store state

Recovery example

cpp
#include <filesystem>
#include <iostream>

#include <softadastra/store/Store.hpp>

using namespace softadastra;

int main()
{
    std::cout << "== STORE RECOVERY DEMO ==\n";

    const std::string wal_path = "store_recovery_demo.wal";
    std::filesystem::remove(wal_path);

    {
        store::engine::StoreEngine engine{
            store::core::StoreConfig::durable(wal_path)};

        auto first = engine.put(
            store::types::Key{"user:1"},
            store::types::Value::from_string("Gaspard"));

        auto second = engine.put(
            store::types::Key{"user:2"},
            store::types::Value::from_string("Softadastra"));

        auto third = engine.put(
            store::types::Key{"user:1"},
            store::types::Value::from_string("Gaspard Kirira"));

        if (first.is_err() || second.is_err() || third.is_err())
        {
            std::cerr << "initial writes failed\n";
            return 1;
        }

        std::cout << "Before restart size: "
                  << engine.size()
                  << "\n";
    }

    {
        store::engine::StoreEngine recovered{
            store::core::StoreConfig::durable(wal_path)};

        std::cout << "After recovery size: "
                  << recovered.size()
                  << "\n";

        auto entry = recovered.get(
            store::types::Key{"user:1"});

        if (!entry.has_value())
        {
            std::cerr << "recovered entry missing\n";
            return 1;
        }

        std::cout << "Recovered user:1 => "
                  << entry->value.to_string()
                  << " version="
                  << entry->version
                  << "\n";

        std::cout << "Last recovered sequence: "
                  << recovered.last_recovered_sequence()
                  << "\n";
    }

    std::filesystem::remove(wal_path);

    return 0;
}

Snapshot from WAL

A store snapshot can be built from WAL history.

This is useful for diagnostics and recovery tests.

Flow:

txt
read WAL

decode store operations

apply operations in sequence

produce snapshot

Snapshot example

cpp
#include <filesystem>
#include <iostream>

#include <softadastra/store/Store.hpp>

using namespace softadastra;

int main()
{
    std::cout << "== STORE SNAPSHOT FROM WAL EXAMPLE ==\n";

    const std::string wal_path = "snapshot_from_wal.wal";
    std::filesystem::remove(wal_path);

    {
        store::engine::StoreEngine engine{
            store::core::StoreConfig::durable(wal_path)};

        auto first = engine.put(
            store::types::Key{"item:1"},
            store::types::Value::from_string("alpha"));

        auto second = engine.put(
            store::types::Key{"item:2"},
            store::types::Value::from_string("beta"));

        auto third = engine.put(
            store::types::Key{"item:1"},
            store::types::Value::from_string("alpha-updated"));

        if (first.is_err() || second.is_err() || third.is_err())
        {
            std::cerr << "failed to write WAL-backed store entries\n";
            return 1;
        }
    }

    auto snapshot_result =
        store::snapshot::SnapshotBuilderStore::build(wal_path);

    if (snapshot_result.is_err())
    {
        std::cerr << "snapshot build failed: "
                  << snapshot_result.error().message()
                  << "\n";

        return 1;
    }

    auto snapshot = std::move(snapshot_result.value());

    std::cout << "Snapshot size: "
              << snapshot.size()
              << "\n";

    for (const auto &[key, value] : snapshot.all())
    {
        std::cout << key
                  << " => "
                  << value.to_string()
                  << "\n";
    }

    std::filesystem::remove(wal_path);

    return 0;
}

Operation encoding

Store operations can be encoded into payloads.

This is useful for:

  • WAL records
  • sync messages
  • transport payloads
  • replay
  • diagnostics

Operation codec example

cpp
#include <iostream>

#include <softadastra/store/Store.hpp>

using namespace softadastra;

int main()
{
    std::cout << "== STORE OPERATION CODEC EXAMPLE ==\n";

    auto operation = store::core::Operation::put(
        store::types::Key{"user:1"},
        store::types::Value::from_string("Gaspard"));

    auto payload =
        store::encoding::OperationEncoder::encode(operation);

    if (payload.empty())
    {
        std::cerr << "encoding failed\n";
        return 1;
    }

    auto decoded =
        store::encoding::OperationDecoder::decode(payload);

    if (!decoded.has_value())
    {
        std::cerr << "decoding failed\n";
        return 1;
    }

    std::cout << "Decoded operation type="
              << store::types::to_string(decoded->type)
              << " key="
              << decoded->key.str()
              << " value="
              << decoded->value.to_string()
              << "\n";

    return 0;
}

Encoding flow

Encoding follows this model:

txt
Operation

validate operation

serialize type

serialize key

serialize value, when present

payload bytes

Decoding flow

Decoding follows this model:

txt
payload bytes

parse operation type

parse key

parse value, when present

Operation

Invalid payloads should not produce fake operations.

They should fail clearly.

Store and WAL

Store and WAL work together in durable mode.

The relationship is:

txt
WAL   -> operation history
Store -> current local state

Example sequence:

txt
put user:1 = Gaspard
put user:2 = Softadastra
put user:1 = Gaspard Kirira
remove user:2

The WAL stores all operations.

The store exposes final state:

txt
user:1 -> Gaspard Kirira
user:2 -> not found

Store and sync

Sync tracks store operations for propagation.

The relationship is:

txt
Store -> applies operation locally
Sync  -> tracks operation for remote propagation

A local operation can become a sync operation:

txt
store Operation::Put

sync SyncOperation

outbox

queue

The store does not own sync policy.

It only provides operations and current state.

Store and transport

Transport should not be part of store logic.

Wrong:

txt
StoreEngine sends TCP message

Better:

txt
StoreEngine applies operation
SyncEngine tracks operation
Transport sends sync message

Transport failure should not corrupt store state.

Store and discovery

Discovery is unrelated to local key-value state.

Discovery finds peers.

Store holds local values.

They should remain separate.

Store and metadata

Metadata describes the node.

Store contains application state.

Example:

txt
metadata.node_id -> node-a
store["profile/name"] -> Ada

These are different responsibilities.

Store and CLI

CLI can expose store commands.

Correct direction:

txt
CLI command

StoreEngine

Wrong direction:

txt
StoreEngine

CLI output

Store should return structured data and errors.

The CLI should format them.

Store and SDK

The SDK wraps StoreEngine behind a simple developer API.

C++ SDK:

cpp
client.put()
client.get()
client.remove()
client.size()

JavaScript SDK:

js
client.put()
client.get()
client.remove()
client.size()

The engine store is lower-level.

The SDK makes it easier to use.

Local-first behavior

Store operations are local.

A put should not require:

  • network
  • peer
  • transport
  • discovery
  • cloud server
  • remote ACK

This is the core local-first behavior.

Offline-first behavior

If the network is unavailable, the store can still work.

Flow:

txt
network offline

store put still works

value readable locally

sync can retry later

This is why store must remain independent from transport.

Durability behavior

In durable mode, accepted operations should be recorded.

Flow:

txt
put requested

WAL append

store apply

return success

If WAL append fails, the store should return an error and avoid pretending the operation is safely accepted.

Versioning

Entries can have versions.

Versioning helps with:

  • updates
  • sync ordering
  • conflict detection
  • diagnostics
  • recovery checks

Example output:

txt
Entry key=user:1 value=Gaspard version=1

After updating the same key:

txt
Entry key=user:1 value=Gaspard Kirira version=2

The exact versioning policy depends on the implementation.

The important rule is:

txt
updates should be observable

Entry lifecycle

An entry can move through states:

txt
missing

created

updated

removed

Example:

txt
get user:1 -> missing
put user:1 -> created
put user:1 again -> updated
remove user:1 -> removed

Remove semantics

Remove should be explicit.

Possible result fields:

  • deleted
  • key
  • version, if supported

If the key does not exist, the behavior should be documented.

Possible policies:

  • return deleted=false
  • or return not_found error

The important rule is:

txt
missing key during remove should be clear

Store errors

Store operations should return explicit errors.

Possible errors:

  • invalid key
  • key not found
  • WAL append failed
  • WAL flush failed
  • operation encoding failed
  • operation decoding failed
  • recovery failed
  • invalid payload
  • permission denied
  • IO error

Do not hide store errors.

Invalid key

An empty key should be rejected.

Bad:

cpp
store::types::Key{""}

Good:

cpp
store::types::Key{"user:1"}

The error should explain the problem:

txt
invalid key: key is empty

Key not found

A missing key is normal.

Example:

cpp
auto entry = engine.get(store::types::Key{"missing:key"});

if (!entry.has_value())
{
    std::cout << "not found\n";
}

This should not crash the application.

WAL append failure

In durable mode, WAL append failure is serious.

Flow:

txt
put requested

WAL append fails

return error

do not report durable success

Possible causes:

  • directory missing
  • permission denied
  • disk full
  • invalid path
  • write failed

Recovery failure

Recovery can fail when:

  • WAL file is corrupted
  • operation payload cannot be decoded
  • unsupported operation type
  • read fails
  • permission denied

The store should return a clear recovery error.

It should not silently apply invalid operations.

Decode failure

Operation decoding should fail clearly for invalid payloads.

Bad:

txt
invalid bytes

fake default operation

Better:

txt
invalid bytes

decode failed

return no operation or error

Store API reference

Main areas

AreaPurpose
coreStore config, entry, operation
typesKey, Value, operation type
engineStoreEngine
encodingOperation encoding and decoding
snapshotSnapshot building from WAL

Main types

TypePurpose
StoreConfigConfigures store behavior
StoreEngineOwns current local state
KeyIdentifies an entry
ValueStores entry data
EntryCurrent stored key-value entry
OperationStore mutation
OperationEncoderEncodes operations
OperationDecoderDecodes operations
SnapshotBuilderStoreBuilds store snapshot from WAL

Common methods

MethodPurpose
put(key, value)Write or update a value
get(key)Read a value
remove(key)Remove a value
entries()Return current entries
size()Return number of entries
last_recovered_sequence()Return last recovered WAL sequence

Only document a method as stable when it exists in the current public API.

Examples

Current useful examples include:

  • basic_store.cpp
  • memory_only.cpp
  • operation_codec.cpp
  • recovery_demo.cpp
  • snapshot_from_wal.cpp
  • wal_usage.cpp

Recommended order:

  1. memory_only.cpp
  2. basic_store.cpp
  3. wal_usage.cpp
  4. operation_codec.cpp
  5. recovery_demo.cpp
  6. snapshot_from_wal.cpp

This order moves from simple local state to WAL-backed recovery.

Run examples

From the engine repository:

bash
cd ~/softadastra/softadastra

Build:

bash
vix build

Or with CMake:

bash
cmake --preset dev-ninja
cmake --build --preset build-ninja

Find binaries:

bash
find build-ninja -type f -executable

Run the relevant store example binary from the build output.

Testing store

Store tests should verify:

  • memory-only put
  • memory-only get
  • memory-only remove
  • durable put
  • durable update
  • durable remove
  • WAL append integration
  • operation encoding
  • operation decoding
  • recovery from WAL
  • snapshot from WAL
  • invalid key behavior
  • missing key behavior
  • decode failure behavior

Good store test flow

Memory-only test:

txt
create memory store
put key
get key
expect value
remove key
get key
expect missing

Durable test:

txt
create temp WAL path
create durable store
put values
destroy store
create store again with same WAL
get values
expect recovery
cleanup

Codec test:

txt
create operation
encode operation
decode payload
expect same key and value

Design rules

The store module should follow these rules:

  1. Store owns current local state.
  2. WAL owns operation history.
  3. Store must not own transport.
  4. Store must not own discovery.
  5. Store must return explicit errors.
  6. Durable mode must not hide WAL failures.
  7. Recovery must be deterministic.
  8. Operation encoding must be strict.
  9. Store examples should stay small.
  10. Store should be usable without network.

Common mistakes

Treating store as sync

Wrong:

txt
StoreEngine sends operations to peers

Better:

txt
StoreEngine applies local operations
SyncEngine tracks propagation
Transport sends messages

Treating WAL as current state

Wrong:

txt
read latest WAL record as current state directly

Better:

txt
replay WAL operations into store state
then read current state

Ignoring WAL errors

Wrong:

cpp
engine.put(key, value);
std::cout << "saved\n";

Better:

cpp
auto result = engine.put(key, value);

if (result.is_err())
{
    std::cerr << result.error().message() << "\n";
    return 1;
}

Using empty keys

Wrong:

cpp
store::types::Key{""}

Better:

cpp
store::types::Key{"settings:theme"}

Making store depend on CLI

Wrong:

txt
StoreEngine prints formatted terminal tables

Better:

txt
StoreEngine returns entries
CLI formats table

Making remove ambiguous

A remove should clearly say whether something was deleted or not.

Memory-only:

cpp
store::engine::StoreEngine engine{
    store::core::StoreConfig::memory_only()};

auto result = engine.put(
    store::types::Key{"session:1"},
    store::types::Value::from_string("temporary"));

if (result.is_err())
{
    return 1;
}

Durable:

cpp
store::engine::StoreEngine engine{
    store::core::StoreConfig::durable("data/node-a.wal")};

auto result = engine.put(
    store::types::Key{"user:1"},
    store::types::Value::from_string("Gaspard"));

if (result.is_err())
{
    return 1;
}

Read:

cpp
auto entry = engine.get(store::types::Key{"user:1"});

if (entry.has_value())
{
    std::cout << entry->value.to_string() << "\n";
}

Remove:

cpp
auto removed = engine.remove(store::types::Key{"user:1"});

if (removed.is_err())
{
    return 1;
}

Summary

store is the current local state module of Softadastra Engine.

It provides:

  • StoreEngine
  • StoreConfig
  • Key
  • Value
  • Entry
  • Operation
  • OperationEncoder
  • OperationDecoder
  • SnapshotBuilderStore

The key idea is:

txt
Store applies operations locally and exposes current state.

It does not sync data by itself, connect peers, discover nodes, or own transport.

Next step

Continue with sync:

Go to Sync

Released under the Apache License 2.0.