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:
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:
StoreEngineStoreConfigKeyValueEntryOperationOperationEncoderOperationDecoderSnapshotBuilderStoreputgetremoveentriessize- recovery from WAL
It gives the engine a simple local key-value model:
Key -> ValueWith 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:
store applies local operations.
sync tracks propagation.
transport sends messages.
discovery finds peers.Include
Use the top-level include:
#include <softadastra/store/Store.hpp>Module location
The module lives in:
modules/store/Typical structure:
modules/store/
├── include/
│ └── softadastra/store/
│ ├── core/
│ ├── encoding/
│ ├── engine/
│ ├── snapshot/
│ ├── types/
│ └── Store.hpp
├── src/
├── examples/
├── tests/
├── README.md
├── CMakeLists.txt
└── CHANGELOG.mdThe exact structure can evolve, but the responsibility should stay stable:
store current local key-value state and recover it from operation historyMain concepts
The store module is built around these concepts:
StoreConfigStoreEngineKeyValueEntryOperationSnapshotRecovery
The normal flow is:
StoreConfig
↓
StoreEngine
↓
put / get / remove
↓
current local stateWith WAL enabled:
StoreConfig::durable
↓
StoreEngine
↓
operation encoded
↓
WAL append
↓
store apply
↓
recoverable local stateStoreConfig
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:
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:
store::engine::StoreEngine engine{
store::core::StoreConfig::durable("store.wal")};This mode is useful when local operations must be recoverable.
The flow is:
put / remove
↓
operation written to WAL
↓
operation applied to storeOn restart:
open store
↓
read WAL
↓
replay operations
↓
rebuild current stateKey
Key identifies a value in the store.
Example:
store::types::Key{"user:1"}Good keys are:
- non-empty
- stable
- human-readable when possible
- structured by domain
Good examples:
user:1
settings:theme
profile:name
files/docs/readme.txt
session:1
product:1Avoid empty keys.
Value
Value stores the data associated with a key.
Example:
store::types::Value::from_string("Gaspard")A value can represent string data, and the module can evolve toward binary values when needed.
Example usage:
auto value =
store::types::Value::from_string("Softadastra");Read it back:
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:
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:
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:
putgetremovecontains, if exposedentriessize- last recovered sequence
It owns the current local state.
Basic store example
#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:
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 PutResultThe important rule is:
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:
get requested
↓
validate key
↓
lookup entry
↓
return entry or not foundA local read should not require:
- WAL
- sync
- transport
- discovery
- peer
- network
The store is local.
Remove flow
A remove operation deletes a key.
Flow:
remove requested
↓
validate key
↓
create Operation::Delete
↓
append to WAL, if durable
↓
remove entry from current store
↓
return RemoveResultAfter remove:
get(key) -> not foundIf the remove operation is written to WAL, recovery should preserve the deleted state.
Memory-only example
#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
#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:
StoreEngine starts with durable config
↓
WAL is opened
↓
records are read
↓
operations are decoded
↓
operations are applied in sequence
↓
current state is rebuiltRecovery must be deterministic.
same WAL
↓
same replay order
↓
same final store stateRecovery example
#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:
read WAL
↓
decode store operations
↓
apply operations in sequence
↓
produce snapshotSnapshot example
#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
#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:
Operation
↓
validate operation
↓
serialize type
↓
serialize key
↓
serialize value, when present
↓
payload bytesDecoding flow
Decoding follows this model:
payload bytes
↓
parse operation type
↓
parse key
↓
parse value, when present
↓
OperationInvalid payloads should not produce fake operations.
They should fail clearly.
Store and WAL
Store and WAL work together in durable mode.
The relationship is:
WAL -> operation history
Store -> current local stateExample sequence:
put user:1 = Gaspard
put user:2 = Softadastra
put user:1 = Gaspard Kirira
remove user:2The WAL stores all operations.
The store exposes final state:
user:1 -> Gaspard Kirira
user:2 -> not foundStore and sync
Sync tracks store operations for propagation.
The relationship is:
Store -> applies operation locally
Sync -> tracks operation for remote propagationA local operation can become a sync operation:
store Operation::Put
↓
sync SyncOperation
↓
outbox
↓
queueThe 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:
StoreEngine sends TCP messageBetter:
StoreEngine applies operation
SyncEngine tracks operation
Transport sends sync messageTransport 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:
metadata.node_id -> node-a
store["profile/name"] -> AdaThese are different responsibilities.
Store and CLI
CLI can expose store commands.
Correct direction:
CLI command
↓
StoreEngineWrong direction:
StoreEngine
↓
CLI outputStore 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:
client.put()
client.get()
client.remove()
client.size()JavaScript SDK:
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:
network offline
↓
store put still works
↓
value readable locally
↓
sync can retry laterThis is why store must remain independent from transport.
Durability behavior
In durable mode, accepted operations should be recorded.
Flow:
put requested
↓
WAL append
↓
store apply
↓
return successIf 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:
Entry key=user:1 value=Gaspard version=1After updating the same key:
Entry key=user:1 value=Gaspard Kirira version=2The exact versioning policy depends on the implementation.
The important rule is:
updates should be observableEntry lifecycle
An entry can move through states:
missing
↓
created
↓
updated
↓
removedExample:
get user:1 -> missing
put user:1 -> created
put user:1 again -> updated
remove user:1 -> removedRemove 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_founderror
The important rule is:
missing key during remove should be clearStore 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:
store::types::Key{""}Good:
store::types::Key{"user:1"}The error should explain the problem:
invalid key: key is emptyKey not found
A missing key is normal.
Example:
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:
put requested
↓
WAL append fails
↓
return error
↓
do not report durable successPossible 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:
invalid bytes
↓
fake default operationBetter:
invalid bytes
↓
decode failed
↓
return no operation or errorStore API reference
Main areas
| Area | Purpose |
|---|---|
core | Store config, entry, operation |
types | Key, Value, operation type |
engine | StoreEngine |
encoding | Operation encoding and decoding |
snapshot | Snapshot building from WAL |
Main types
| Type | Purpose |
|---|---|
StoreConfig | Configures store behavior |
StoreEngine | Owns current local state |
Key | Identifies an entry |
Value | Stores entry data |
Entry | Current stored key-value entry |
Operation | Store mutation |
OperationEncoder | Encodes operations |
OperationDecoder | Decodes operations |
SnapshotBuilderStore | Builds store snapshot from WAL |
Common methods
| Method | Purpose |
|---|---|
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.cppmemory_only.cppoperation_codec.cpprecovery_demo.cppsnapshot_from_wal.cppwal_usage.cpp
Recommended order:
memory_only.cppbasic_store.cppwal_usage.cppoperation_codec.cpprecovery_demo.cppsnapshot_from_wal.cpp
This order moves from simple local state to WAL-backed recovery.
Run examples
From the engine repository:
cd ~/softadastra/softadastraBuild:
vix buildOr with CMake:
cmake --preset dev-ninja
cmake --build --preset build-ninjaFind binaries:
find build-ninja -type f -executableRun 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:
create memory store
put key
get key
expect value
remove key
get key
expect missingDurable test:
create temp WAL path
create durable store
put values
destroy store
create store again with same WAL
get values
expect recovery
cleanupCodec test:
create operation
encode operation
decode payload
expect same key and valueDesign rules
The store module should follow these rules:
- Store owns current local state.
- WAL owns operation history.
- Store must not own transport.
- Store must not own discovery.
- Store must return explicit errors.
- Durable mode must not hide WAL failures.
- Recovery must be deterministic.
- Operation encoding must be strict.
- Store examples should stay small.
- Store should be usable without network.
Common mistakes
Treating store as sync
Wrong:
StoreEngine sends operations to peersBetter:
StoreEngine applies local operations
SyncEngine tracks propagation
Transport sends messagesTreating WAL as current state
Wrong:
read latest WAL record as current state directlyBetter:
replay WAL operations into store state
then read current stateIgnoring WAL errors
Wrong:
engine.put(key, value);
std::cout << "saved\n";Better:
auto result = engine.put(key, value);
if (result.is_err())
{
std::cerr << result.error().message() << "\n";
return 1;
}Using empty keys
Wrong:
store::types::Key{""}Better:
store::types::Key{"settings:theme"}Making store depend on CLI
Wrong:
StoreEngine prints formatted terminal tablesBetter:
StoreEngine returns entries
CLI formats tableMaking remove ambiguous
A remove should clearly say whether something was deleted or not.
Recommended usage pattern
Memory-only:
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:
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:
auto entry = engine.get(store::types::Key{"user:1"});
if (entry.has_value())
{
std::cout << entry->value.to_string() << "\n";
}Remove:
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:
StoreEngineStoreConfigKeyValueEntryOperationOperationEncoderOperationDecoderSnapshotBuilderStore
The key idea is:
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: