Use the C++ SDK with the Engine
This guide explains how the Softadastra C++ SDK maps to the internal Softadastra Engine.
The goal is to understand what happens behind SDK calls such as Client, ClientOptions, put, get, sync_state, tick, start_transport, start_discovery, and refresh_node_info.
The core rule is:
The C++ SDK gives a stable public API over the engine.Most applications should use the SDK first. The engine explains how the runtime works internally.
What you will learn
You will learn how the C++ SDK maps to these engine modules:
ClientOptions -> engine configuration
Client -> runtime facade
put/get -> store
persistent -> WAL + store
sync_state -> sync
tick -> sync scheduler
transport -> transport
discovery -> discovery
node info -> metadata
Result -> core error handlingThe basic model is:
C++ app
↓
Softadastra SDK
↓
Softadastra Engine modulesWhy this mapping matters
The SDK is designed to hide most internal wiring.
Instead of manually creating store engines, WAL writers, sync contexts, transport engines, discovery engines, and metadata services, an application can use:
Client client{
ClientOptions::local("node-a")};Then:
client.open();
client.put("app/name", "Softadastra");
client.sync_state();
client.tick();
client.close();The SDK keeps the public API small, while the engine remains modular internally.
SDK versus Engine
The SDK is the developer-facing layer.
The engine is the lower-level runtime layer.
SDK C++
-> Client
-> ClientOptions
-> Value
-> Peer
-> NodeInfo
-> Result
-> Error
Engine
-> core
-> wal
-> store
-> sync
-> transport
-> discovery
-> metadataUse the SDK when you are building an application.
Use the engine documentation when you want to understand or extend the runtime internals.
Main SDK entry point
Most C++ applications should include:
#include <softadastra/sdk.hpp>Then use:
using namespace softadastra::sdk;This gives access to the main SDK types:
ClientClientOptionsValuePeerNodeInfoResultErrorSyncResultTickResult
ClientOptions maps to engine configuration
ClientOptions defines how the SDK should compose the runtime.
Example:
ClientOptions options =
ClientOptions::persistent(
"node-a",
"data/node-a.wal");
options.auto_flush = true;
options.enable_transport = true;
options.transport_host = "127.0.0.1";
options.transport_port = 4041;
options.enable_discovery = false;
options.display_name = "Node A";
options.version = "0.1.0";Conceptually, these options map to several engine configurations:
node id -> metadata, sync, transport, discovery
wal path -> WAL and store persistence
auto flush -> WAL durability behavior
transport host -> transport bind host
transport port -> transport bind port
discovery config -> discovery runtime
display name -> metadata
version -> metadataThe SDK hides the conversion from ClientOptions to lower-level engine config objects.
Client maps to the runtime facade
Client is the main SDK object.
Client client{options};Conceptually, Client owns or coordinates the runtime modules needed by the selected options.
Client
↓
store
WAL, if enabled
sync
transport, if enabled
discovery, if enabled
metadataThe application does not need to create those modules manually.
Open maps to runtime initialization
Before using the SDK, call:
auto opened = client.open();
if (opened.is_err())
{
std::cerr << opened.error().message() << "\n";
return 1;
}Conceptually, open() can initialize:
validate options
↓
initialize metadata
↓
open WAL, if enabled
↓
recover store from WAL, if enabled
↓
initialize store
↓
initialize sync state
↓
prepare transport config, if enabled
↓
prepare discovery config, if enabled
↓
client readyOpening the client should not require a peer.
Opening the client should not require discovery.
Opening the client should not require transport to be connected.
The local runtime should be useful first.
put maps to store, WAL, and sync
A normal write looks like this:
auto result = client.put(
"app/name",
"Softadastra");
if (result.is_err())
{
std::cerr << result.error().message() << "\n";
client.close();
return 1;
}Conceptually:
client.put()
↓
validate key and value
↓
create store operation
↓
append to WAL, if enabled
↓
apply to store
↓
create sync operation, if sync tracking is enabled
↓
return ResultThe store makes the value readable locally.
The WAL makes the operation recoverable when persistence is enabled.
The sync layer tracks the operation for later propagation.
get maps to the local store
A read looks like this:
auto result = client.get("app/name");
if (result.is_ok())
{
std::cout << result.value().to_string() << "\n";
}Conceptually:
client.get()
↓
store lookup
↓
return Value or Errorget() is local.
It should not require a server, peer, transport, discovery, or cloud access.
If the key is missing, the result should be an explicit error, not a crash.
auto result = client.get("missing/key");
if (result.is_err())
{
std::cout << result.error().code_string() << "\n";
}Expected output style:
not_foundremove maps to a store operation
A remove operation looks like this:
auto result = client.remove("app/name");
if (result.is_err())
{
std::cerr << result.error().message() << "\n";
}Conceptually:
client.remove()
↓
create delete operation
↓
append to WAL, if enabled
↓
apply delete to store
↓
track sync operation, if enabled
↓
return ResultA remove is still local-first.
Remote peers can learn about the delete later through sync.
contains, size, and empty map to store state
These methods inspect current local state:
client.contains("app/name");
client.size();
client.empty();They map to the local store.
contains -> does this key exist locally?
size -> how many local entries exist?
empty -> is the local store empty?They do not require transport or discovery.
Persistent options map to WAL-backed store
Use persistent mode when local data should survive restart:
ClientOptions options =
ClientOptions::persistent(
"node-persistent",
"data/node-persistent.wal");
options.auto_flush = true;Conceptually:
ClientOptions::persistent
↓
enable WAL
↓
set WAL path
↓
open store with recoveryThe persistent write path is:
put
↓
WAL append
↓
store apply
↓
sync trackingThe recovery path is:
client.open()
↓
read WAL
↓
replay valid operations
↓
restore local storesync_state maps to the sync engine
Use sync_state() to inspect pending synchronization work:
auto state = client.sync_state();
if (state.is_ok())
{
std::cout << "outbox: "
<< state.value().outbox_size
<< "\n";
std::cout << "queued: "
<< state.value().queued_count
<< "\n";
std::cout << "failed: "
<< state.value().failed_count
<< "\n";
}Conceptually:
client.sync_state()
↓
sync engine state
↓
outbox count
↓
queued count
↓
in-flight count
↓
acknowledged count
↓
failed count
↓
retry countThe sync state does not mean remote delivery has completed.
It shows what the local runtime knows about propagation work.
tick maps to the sync scheduler
Use tick() to move the sync pipeline forward once:
auto tick = client.tick();
if (tick.is_ok())
{
std::cout << "retried: "
<< tick.value().retried_count
<< "\n";
std::cout << "pruned: "
<< tick.value().pruned_count
<< "\n";
std::cout << "batch: "
<< tick.value().batch_size
<< "\n";
}Conceptually:
client.tick()
↓
retry expired work
↓
select queued operations
↓
produce next batch
↓
prepare delivery through transport, if enabled
↓
return TickResultA tick is explicit.
This makes synchronization easier to test, debug, and control.
tick with pruning
If supported:
auto tick = client.tick(true);Conceptually:
tick
↓
move sync pipeline
↓
remove completed entries, if safePruning should only remove completed sync work.
It should not remove local store values.
start_transport maps to the transport module
Transport is optional.
Enable it through options:
options.enable_transport = true;
options.transport_host = "127.0.0.1";
options.transport_port = 4041;Start it explicitly:
auto result = client.start_transport();
if (result.is_err())
{
std::cerr << result.error().message() << "\n";
}Conceptually:
client.start_transport()
↓
transport config
↓
bind host and port
↓
start transport backend
↓
ready to connect peersTransport is the delivery layer.
It does not decide what a sync operation means.
connect maps to peer transport
A peer describes another node:
Peer peer{
"node-b",
"127.0.0.1",
4042};Connect:
auto connected = client.connect(peer);
if (connected.is_err())
{
std::cout << "connection failed: "
<< connected.error().message()
<< "\n";
}Conceptually:
client.connect(peer)
↓
transport connect
↓
peer registry update
↓
messages can be delivered, if connection succeedsA connection failure should not invalidate local state.
transport failure
↓
sync work remains tracked
↓
local value remains readablestart_discovery maps to discovery
Discovery is optional.
Enable it through options:
options.enable_discovery = true;
options.discovery_host = "127.0.0.1";
options.discovery_port = 5051;
options.discovery_broadcast_host = "127.0.0.1";
options.discovery_broadcast_port = 5052;Start it explicitly:
auto result = client.start_discovery();
if (result.is_err())
{
std::cerr << result.error().message() << "\n";
}Conceptually:
client.start_discovery()
↓
discovery config
↓
listen for discovery messages
↓
announce local node
↓
track discovered peersDiscovery finds peers.
Transport connects peers.
Sync sends operations.
peers maps to discovery and peer registry
Use:
auto peers = client.peers();
if (peers.is_ok())
{
for (const auto &peer : peers.value())
{
std::cout << peer.node_id << " "
<< peer.host << ":"
<< peer.port << "\n";
}
}Conceptually:
client.peers()
↓
known peer registry
↓
discovered or configured peersNo peers is a valid state.
A node can still read and write local data when no peer is known.
refresh_node_info maps to metadata
Use:
auto info = client.refresh_node_info();
if (info.is_ok())
{
std::cout << info.value().node_id << "\n";
std::cout << info.value().display_name << "\n";
std::cout << info.value().hostname << "\n";
std::cout << info.value().os_name << "\n";
std::cout << info.value().version << "\n";
}Conceptually:
client.refresh_node_info()
↓
metadata service
↓
node id
↓
display name
↓
hostname
↓
operating system
↓
version
↓
capabilities
↓
uptimeMetadata describes the node.
It does not store application data.
Result maps to core error handling
Most SDK operations return explicit results.
auto result = client.get("app/name");
if (result.is_err())
{
std::cerr << result.error().message() << "\n";
return 1;
}
std::cout << result.value().to_string() << "\n";Conceptually:
operation
↓
Result<T>
↓
ok(value) or err(error)The engine uses explicit errors because failure is normal in local-first systems.
Examples:
- missing key
- invalid WAL path
- transport failed
- peer unavailable
- discovery disabled
- sync failed
- metadata unavailable
The rule is:
Check the result before using the value.Complete C++ SDK flow
This example uses several SDK features and shows how they map to the engine.
#include <iostream>
#include <softadastra/sdk.hpp>
int main()
{
using namespace softadastra::sdk;
ClientOptions options =
ClientOptions::persistent(
"node-a",
"data/node-a.wal");
options.auto_flush = true;
options.enable_transport = true;
options.transport_host = "127.0.0.1";
options.transport_port = 4041;
options.enable_discovery = false;
options.display_name = "Node A";
options.version = "0.1.0";
Client client{options};
auto opened = client.open();
if (opened.is_err())
{
std::cerr << "open failed: "
<< opened.error().message()
<< "\n";
return 1;
}
auto written = client.put(
"app/name",
"Softadastra");
if (written.is_err())
{
std::cerr << "write failed: "
<< written.error().message()
<< "\n";
client.close();
return 1;
}
auto value = client.get("app/name");
if (value.is_ok())
{
std::cout << "value: "
<< value.value().to_string()
<< "\n";
}
auto state = client.sync_state();
if (state.is_ok())
{
std::cout << "outbox: "
<< state.value().outbox_size
<< "\n";
}
auto tick = client.tick();
if (tick.is_ok())
{
std::cout << "batch: "
<< tick.value().batch_size
<< "\n";
}
auto node = client.refresh_node_info();
if (node.is_ok())
{
std::cout << "node: "
<< node.value().node_id
<< "\n";
}
client.close();
return 0;
}Expected output style:
value: Softadastra
outbox: 1
batch: 1
node: node-aThe exact sync numbers can differ depending on runtime configuration.
Internal flow behind the example
The previous example maps to this engine flow:
ClientOptions::persistent
↓
WAL path configured
client.open()
↓
metadata initialized
↓
WAL opened
↓
store recovered
↓
sync initialized
↓
transport prepared
client.put()
↓
store operation
↓
WAL append
↓
store apply
↓
sync operation created
client.sync_state()
↓
sync state read
client.tick()
↓
sync scheduler moves pipeline
client.refresh_node_info()
↓
metadata refreshed
client.close()
↓
runtime cleanupThe SDK gives one clean API over that flow.
When to use the SDK directly
Use the SDK directly when you want to:
- build an application
- store local data
- add offline-first behavior
- persist local operations
- inspect sync state
- connect peers
- use Softadastra without manual engine wiring
For most applications, the SDK is the right level.
When to use engine modules directly
Use engine modules directly when you are:
- developing Softadastra internals
- adding a new storage mode
- testing WAL internals
- building a new sync strategy
- building a new transport backend
- building a new discovery backend
- debugging low-level runtime behavior
Engine-level code gives more control, but it requires more wiring.
SDK and CLI relationship
The SDK and CLI both sit above the engine.
C++ SDK
↓
engine modules
CLI
↓
engine modulesThey expose similar concepts in different forms.
SDK:
client.put("app/name", "Softadastra");
client.sync_state();
client.tick();CLI:
softadastra store put app/name Softadastra
softadastra sync status
softadastra sync tickBoth follow the same model.
SDK and local-first behavior
The SDK should preserve Softadastra's local-first rules.
A local write should not require:
- remote server
- connected peer
- transport
- discovery
- cloud access
A transport failure should not delete local state.
A discovery failure should not block store access.
A sync failure should not make a local value unreadable.
This is the practical meaning of local-first behavior in the SDK.
Common mistakes
Using the engine when the SDK is enough
If your goal is to build an application, start with:
#include <softadastra/sdk.hpp>Do not manually wire internal modules unless you need low-level control.
Forgetting to open the client
Wrong:
Client client{options};
client.put("app/name", "Softadastra");Correct:
Client client{options};
auto opened = client.open();
if (opened.is_err())
{
return 1;
}
client.put("app/name", "Softadastra");Assuming put means remote delivery
put() writes locally.
It can create sync work, but it does not guarantee another node already received the operation.
Inspect sync:
auto state = client.sync_state();Run a tick:
auto tick = client.tick();Assuming transport is required for local store
Transport is optional.
This should still work:
options.enable_transport = false;
client.put("local/key", "value");
client.get("local/key");Sharing a WAL path between nodes
Each node should have its own WAL path.
Good:
node-a -> data/node-a.wal
node-b -> data/node-b.walBad:
node-a -> data/shared.wal
node-b -> data/shared.walDebugging with CLI
Use the CLI to inspect the same runtime concepts:
softadastra status
softadastra node info
softadastra store put app/name Softadastra
softadastra store get app/name
softadastra sync status
softadastra sync tick
softadastra peersThis helps confirm that SDK behavior matches the runtime model.
Summary
The C++ SDK is a stable public API over Softadastra Engine.
The mapping is:
ClientOptions -> runtime configuration
Client -> SDK facade
put/get -> store
persistent -> WAL + store recovery
sync_state -> sync state
tick -> sync scheduler
transport -> peer delivery
discovery -> peer finding
metadata -> node identity
Result -> explicit error handlingThe SDK lets you build local-first applications without manually wiring every engine module.
Next step
Continue with: