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

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:

txt
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:

txt
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 handling

The basic model is:

txt
C++ app
  ↓
Softadastra SDK
  ↓
Softadastra Engine modules

Why 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:

cpp
Client client{
    ClientOptions::local("node-a")};

Then:

cpp
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.

txt
SDK C++
  -> Client
  -> ClientOptions
  -> Value
  -> Peer
  -> NodeInfo
  -> Result
  -> Error

Engine
  -> core
  -> wal
  -> store
  -> sync
  -> transport
  -> discovery
  -> metadata

Use 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:

cpp
#include <softadastra/sdk.hpp>

Then use:

cpp
using namespace softadastra::sdk;

This gives access to the main SDK types:

  • Client
  • ClientOptions
  • Value
  • Peer
  • NodeInfo
  • Result
  • Error
  • SyncResult
  • TickResult

ClientOptions maps to engine configuration ​

ClientOptions defines how the SDK should compose the runtime.

Example:

cpp
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:

txt
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          -> metadata

The SDK hides the conversion from ClientOptions to lower-level engine config objects.

Client maps to the runtime facade ​

Client is the main SDK object.

cpp
Client client{options};

Conceptually, Client owns or coordinates the runtime modules needed by the selected options.

txt
Client
  ↓
store
WAL, if enabled
sync
transport, if enabled
discovery, if enabled
metadata

The application does not need to create those modules manually.

Open maps to runtime initialization ​

Before using the SDK, call:

cpp
auto opened = client.open();

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

Conceptually, open() can initialize:

txt
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 ready

Opening 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:

cpp
auto result = client.put(
    "app/name",
    "Softadastra");

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

Conceptually:

txt
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 Result

The 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:

cpp
auto result = client.get("app/name");

if (result.is_ok())
{
    std::cout << result.value().to_string() << "\n";
}

Conceptually:

txt
client.get()
  ↓
store lookup
  ↓
return Value or Error

get() 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.

cpp
auto result = client.get("missing/key");

if (result.is_err())
{
    std::cout << result.error().code_string() << "\n";
}

Expected output style:

txt
not_found

remove maps to a store operation ​

A remove operation looks like this:

cpp
auto result = client.remove("app/name");

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

Conceptually:

txt
client.remove()
  ↓
create delete operation
  ↓
append to WAL, if enabled
  ↓
apply delete to store
  ↓
track sync operation, if enabled
  ↓
return Result

A 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:

cpp
client.contains("app/name");
client.size();
client.empty();

They map to the local store.

txt
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:

cpp
ClientOptions options =
    ClientOptions::persistent(
        "node-persistent",
        "data/node-persistent.wal");

options.auto_flush = true;

Conceptually:

txt
ClientOptions::persistent
  ↓
enable WAL
  ↓
set WAL path
  ↓
open store with recovery

The persistent write path is:

txt
put
  ↓
WAL append
  ↓
store apply
  ↓
sync tracking

The recovery path is:

txt
client.open()
  ↓
read WAL
  ↓
replay valid operations
  ↓
restore local store

sync_state maps to the sync engine ​

Use sync_state() to inspect pending synchronization work:

cpp
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:

txt
client.sync_state()
  ↓
sync engine state
  ↓
outbox count
  ↓
queued count
  ↓
in-flight count
  ↓
acknowledged count
  ↓
failed count
  ↓
retry count

The 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:

cpp
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:

txt
client.tick()
  ↓
retry expired work
  ↓
select queued operations
  ↓
produce next batch
  ↓
prepare delivery through transport, if enabled
  ↓
return TickResult

A tick is explicit.

This makes synchronization easier to test, debug, and control.

tick with pruning ​

If supported:

cpp
auto tick = client.tick(true);

Conceptually:

txt
tick
  ↓
move sync pipeline
  ↓
remove completed entries, if safe

Pruning 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:

cpp
options.enable_transport = true;
options.transport_host = "127.0.0.1";
options.transport_port = 4041;

Start it explicitly:

cpp
auto result = client.start_transport();

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

Conceptually:

txt
client.start_transport()
  ↓
transport config
  ↓
bind host and port
  ↓
start transport backend
  ↓
ready to connect peers

Transport is the delivery layer.

It does not decide what a sync operation means.

connect maps to peer transport ​

A peer describes another node:

cpp
Peer peer{
    "node-b",
    "127.0.0.1",
    4042};

Connect:

cpp
auto connected = client.connect(peer);

if (connected.is_err())
{
    std::cout << "connection failed: "
              << connected.error().message()
              << "\n";
}

Conceptually:

txt
client.connect(peer)
  ↓
transport connect
  ↓
peer registry update
  ↓
messages can be delivered, if connection succeeds

A connection failure should not invalidate local state.

txt
transport failure
  ↓
sync work remains tracked
  ↓
local value remains readable

start_discovery maps to discovery ​

Discovery is optional.

Enable it through options:

cpp
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:

cpp
auto result = client.start_discovery();

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

Conceptually:

txt
client.start_discovery()
  ↓
discovery config
  ↓
listen for discovery messages
  ↓
announce local node
  ↓
track discovered peers

Discovery finds peers.

Transport connects peers.

Sync sends operations.

peers maps to discovery and peer registry ​

Use:

cpp
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:

txt
client.peers()
  ↓
known peer registry
  ↓
discovered or configured peers

No 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:

cpp
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:

txt
client.refresh_node_info()
  ↓
metadata service
  ↓
node id
  ↓
display name
  ↓
hostname
  ↓
operating system
  ↓
version
  ↓
capabilities
  ↓
uptime

Metadata describes the node.

It does not store application data.

Result maps to core error handling ​

Most SDK operations return explicit results.

cpp
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:

txt
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:

txt
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.

cpp
#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:

txt
value: Softadastra
outbox: 1
batch: 1
node: node-a

The exact sync numbers can differ depending on runtime configuration.

Internal flow behind the example ​

The previous example maps to this engine flow:

txt
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 cleanup

The 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.

txt
C++ SDK
  ↓
engine modules

CLI
  ↓
engine modules

They expose similar concepts in different forms.

SDK:

cpp
client.put("app/name", "Softadastra");
client.sync_state();
client.tick();

CLI:

bash
softadastra store put app/name Softadastra
softadastra sync status
softadastra sync tick

Both 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:

cpp
#include <softadastra/sdk.hpp>

Do not manually wire internal modules unless you need low-level control.

Forgetting to open the client ​

Wrong:

cpp
Client client{options};
client.put("app/name", "Softadastra");

Correct:

cpp
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:

cpp
auto state = client.sync_state();

Run a tick:

cpp
auto tick = client.tick();

Assuming transport is required for local store ​

Transport is optional.

This should still work:

cpp
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:

txt
node-a -> data/node-a.wal
node-b -> data/node-b.wal

Bad:

txt
node-a -> data/shared.wal
node-b -> data/shared.wal

Debugging with CLI ​

Use the CLI to inspect the same runtime concepts:

bash
softadastra status
softadastra node info
softadastra store put app/name Softadastra
softadastra store get app/name
softadastra sync status
softadastra sync tick
softadastra peers

This helps confirm that SDK behavior matches the runtime model.

Summary ​

The C++ SDK is a stable public API over Softadastra Engine.

The mapping is:

txt
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 handling

The SDK lets you build local-first applications without manually wiring every engine module.

Next step ​

Continue with:

Use the JavaScript SDK with the Engine

Released under the Apache License 2.0.