Skip to content

High-Level API Usage

Use this page when you want to store C++ structs through AkkaraDB Native without handling raw byte buffers directly.

The high-level API is exposed through akkaradb/AkkaraDB.hpp.

#include "akkaradb/AkkaraDB.hpp"
#include <cstdint>
#include <string>
struct User {
uint64_t id;
std::string name;
uint32_t age;
};
AKKARADB_ENTITY(User, id, name, age);
int main() {
auto db = akkaradb::AkkaraDB::open("data/app", akkaradb::StartupMode::NORMAL);
auto users = db->table<&User::id>("users");
users.put(User{1, "Alice", 30});
auto alice = users.get(1);
}

AkkaraDB::open(path, mode) is the simplest entry point. Use AkkaraDB::open(AkkaraDB::Options) when you need to override engine thresholds, VersionLog, codecs, blob settings, API server settings, or startup behavior.

StartupMode is passed through to the underlying engine configuration.

ModeTypical use
ULTRA_FASTSmoke tests, benchmarks, short-lived local experiments. Disables WAL, SST, blob, manifest, and VersionLog, disables forced flush/sync on close, and uses a large memtable threshold.
FASTFast startup where full durable defaults are not required. Uses async WAL sync, disables VersionLog, promotes SST reads, and uses a larger memtable threshold.
NORMALGeneral embedded use. Uses async WAL sync with the default component set.
DURABLEUse when durability should be favored over startup/write speed. Uses sync WAL and enables VersionLog.

The exact low-level option mapping belongs to the engine layer. Treat the mode as a profile and use AkkaraDB::Options::overrides when you need explicit control.

AkkaraDB::Options::overrides currently exposes overrides for the per-shard memtable threshold, VersionLog, SST codec, blob codec, blob threshold, SST read promotion, Bloom filter bits per key, and max L0 SST files. Options::api forwards API server backend, port, transport, TLS/PSK, HTTP, TCP, and gRPC limits to the underlying engine options.

PackedTable is parameterized by a member pointer to the primary-key field.

struct Profile {
uint64_t id;
std::string email;
std::string name;
uint32_t age;
};
AKKARADB_ENTITY(Profile, id, email, name, age);
auto profiles = db->table<&Profile::id>("profiles");

AKKARADB_ENTITY(Type, PrimaryKey, ...) does two things:

Generated metadataPurpose
RefTraits<Type>Lets Ref<Type>, schema registration, and row-id references know the primary key.
Query proxy fieldsLets table.query([](auto row) { ... }) expose named fields.

If you only need query fields and not Ref<T> traits, use AKKARADB_QUERYABLE(Type, ...).

Entities are encoded with BinPack. For aggregate structs, fields are encoded in declaration order through Boost.PFR, so storage compatibility depends on field order and type layout.

ShapeNotes
bool, signed/unsigned integers, float, doubleBuilt-in adapters.
enumEncoded through the underlying integer type.
std::string, std::string_viewstring_view is write-only in the adapter; stored values decode as owning strings where used.
std::vector<T>, std::vector<uint8_t>, std::array<T, N>Elements must also have adapters.
std::map<K, V>, std::unordered_map<K, V>Keys and values must have adapters.
std::optional<T>Encodes a presence byte and then the value when present.
std::pair<A, B>, std::tuple<Ts...>Encodes elements in order.
aggregate structsTrivially copyable aggregates use a memcpy fast path; other aggregates are field-by-field.
akkaradb::Ref<T>Encodes the referenced row id.
akkaradb::Immutable<T>Encodes the wrapped value and returns sealed values on decode.

Changing field order, field type, or table name after data exists should be treated as a storage migration.

PackedTable stores one encoded entity per primary key.

profiles.put(Profile{1, "[email protected]", "Alice", 30});
bool found = profiles.exists(1);
auto profile = profiles.get(1);
Profile out{};
bool decoded = profiles.getInto(1, out);
profiles.remove(1);
MethodReturnNotes
put(entity)voidInserts or replaces by the entity primary key.
get(pk)std::optional<Entity>Decodes and returns the entity when present.
getInto(pk, out)boolDecodes into an existing object and returns whether a row was found.
exists(pk)boolChecks the table row key.
remove(pk)voidRemoves the row, table indexes, and row-id metadata.
upsert(pk, fn)voidLoads or default-constructs an entity, assigns the primary key, runs fn, then writes.
updatePrimaryKey(oldPk, entity)voidMoves a row to entity's primary key while preserving the stable row id.
rowIdOf(pk)std::optional<RowId>Resolves a primary key to the internal row id.
primaryKeyOf(rowId)std::optional<PK>Resolves the current primary key for a row id.
getByRowId(rowId)std::optional<Entity>Reads the current entity through row-id metadata.
getIntoByRowId(rowId, out)boolRow-id version of getInto.
count()size_tCounts rows in the table key range.

upsert() is useful when the update is easier to express as a mutation callback.

profiles.upsert(1, [](Profile& profile) {
profile.id = 1;
profile.name = "Alice Updated";
profile.age += 1;
});

The table sets entity.*PrimaryKeyPtr = pk before the callback. The callback should keep that primary key stable; use updatePrimaryKey() when the primary key itself must change.

updatePrimaryKey() moves an existing row to a new primary key and keeps its internal row id.

profiles.updatePrimaryKey(
1,
Profile{10, "[email protected]", "Alice Cooper", 31}
);

This matters for Ref<T> because references resolve through row-id metadata rather than through the visible primary-key bytes. The destination primary key must not already exist.

scanAll() and scan() return cursor-like ranges with hasNext() and next().

auto rows = profiles.scanAll();
while (rows.hasNext()) {
auto entry = rows.next();
// entry.id is the primary key, entry.value is the decoded Profile.
}

Range scans use primary-key order.

auto page = profiles.scan(100ULL, 200ULL);
while (page.hasNext()) {
auto entry = page.next();
}

scan(startPk) scans from startPk to the end of the table namespace. Numeric primary keys are encoded in sortable order, so signed and unsigned numeric keys scan in natural numeric order.

Register an index with a member pointer.

auto byAge = profiles.index<&Profile::age>();
profiles.put(Profile{1, "[email protected]", "Alice", 30});
profiles.put(Profile{2, "[email protected]", "Bob", 30});
auto age30 = byAge.find(30);
while (age30.hasNext()) {
auto entry = age30.next();
}

You can also register and chain indexes without keeping the returned index object.

profiles.indexed<&Profile::email>()
.indexed<&Profile::age>()
.indexed<&Profile::name>();

Use prefixIndexed<&Field>() separately for string prefix search. It is string-like-field only, does not serve findBy<&Field>(), and is useful for hot startsWith() predicates and simple trailing-percent like("prefix%") patterns.

profiles.prefixIndexed<&Profile::email>();

findBy<&Field>(value) returns the first matching entity and requires the field index to be registered.

auto bob = profiles.findBy<&Profile::email>("[email protected]");

Indexes are maintained when put(), remove(), or updatePrimaryKey() modifies an indexed row. Register indexes before writing rows that should be findable through that index; the current API does not automatically backfill an index over existing rows.

The default C++ query API builds typed expression objects at compile time through proxy fields. Native builds can also run the optional akkara-query Clang plugin, which rewrites supported typed entity lambdas into bytecode descriptors.

auto adults = profiles
.query([](auto profile) {
return profile.age >= 18;
})
.limit(10)
.toVector();
auto alice = profiles
.query([](auto profile) {
return profile.email == "[email protected]";
})
.first();

The query proxy is generated by AKKARADB_ENTITY or AKKARADB_QUERYABLE. Supported operators include equality, inequality, comparisons, logical && / ||, in, notIn, startsWith, contains, like, null checks, nested fields, and map lookups.

Use [](auto row) for the normal proxy DSL. Use [](const Entity& row) when the Clang plugin should attempt native bytecode rewrite.

MethodNotes
where(fn)Adds another predicate with logical AND.
limit(n)Stops after n matched rows.
first()Returns std::optional<Entry>.
any()Returns whether at least one row matches.
count()Counts matching rows.
toVector()Materializes matching entries.

Query result order is not a stable API contract. Sort materialized results in application code when order matters.

The planner looks for a usable predicate on a registered index. The full expression is still evaluated before a row is returned, so the index only narrows the candidate set.

auto result = profiles.query([](auto profile) {
return profile.email == "[email protected]" && profile.age >= 18;
}).first();

In this example, an email index can seed the scan, and age >= 18 is applied as the remaining filter.

Predicate shapeIndexed plan behavior
field == literalEquality range over the field index.
field != literalFull field-index scan, then expression filtering.
numeric field >/>=/</<= literalOrdered index range for arithmetic non-bool fields.
field.in(values)One equality range per value, with candidate primary-key dedupe.
field.notIn(values)Full field-index scan, then expression filtering.
optional field.isNull()Equality range for the encoded empty optional.
optional field.isNotNull()Full field-index scan, then expression filtering.
string startsWithPrefix-index range when prefixIndexed<&Field>() is registered; otherwise a full field-index scan if a normal index exists.
string containsFull field-index scan, then expression filtering.
string like("exact")Equality range.
string like("prefix%")Prefix-index range for simple prefix patterns when prefixIndexed<&Field>() is registered; otherwise a full field-index scan if a normal index exists.
predicates under &&The planner scores usable indexed sides and chooses the stronger candidate source.
predicates under `
nested fields, map lookupsCorrectly evaluated, but not currently index-seeded by themselves.

See Query for planner priority, literal conversion rules, like() wildcard semantics, native bytecode rewrite, and query lambda boundaries.

The query proxy supports optional fields, nested struct fields, and map lookups.

auto unnamed = users.query([](auto user) {
return user.nickname.isNull();
}).toVector();
auto tokyo = users.query([](auto user) {
return user.address.template field<&Address::city>() == "Tokyo";
}).toVector();
auto gold = users.query([](auto user) {
return user.tags.get("tier") == std::optional<std::string>{"gold"};
}).toVector();

Nested fields use template field<&Nested::field>() because the expression is a dependent template call.

Wrap a field in akkaradb::Immutable<T> when a value may be set at creation time but should not change after it has been persisted.

struct Account {
uint64_t id;
akkaradb::Immutable<std::string> handle;
uint32_t age;
};
AKKARADB_ENTITY(Account, id, handle, age);

put() seals immutable fields after loading or writing. A later replacement that changes a sealed immutable field throws. Primary-key fields cannot use Immutable<T>.

onUpdate<&Field>() runs only when an existing row is replaced and the selected field changes.

profiles.onUpdate<&Profile::age>(
[](const auto& oldAge, const auto& newAge, const Profile& oldProfile, Profile& newProfile) {
newProfile.email = std::format("age-{}@example.test", newAge);
}
);

Handlers can observe the old entity and mutate the new entity before it is encoded. Use them for normalization, derived fields, and small validation rules. Keep heavy side effects outside table hooks because they run inside the table write path.

Ref<T> represents another entity through either a primary key, row id, or loaded value. Once attached to a table binding, it can resolve lazily.

struct Author {
uint64_t id;
std::string name;
};
AKKARADB_ENTITY(Author, id, name);
struct Post {
uint64_t id;
akkaradb::Ref<Author> author;
std::string title;
};
AKKARADB_ENTITY(Post, id, author, title);
authors.put({1, "Alice"});
posts.put({100, akkaradb::ref<Author>(1), "Hello"});
auto post = posts.get(100);
auto authorName = post->author->name;

When a Ref<T> is created from a full entity, the referenced entity is considered dirty. PackedTable::put() flushes dirty refs before writing the owner entity.

Joins are typed views over scans and lookups.

auto joined = posts
.join<&Post::author>(authors)
.where([](const Post& post, const Author& author) {
return author.name == "Alice";
})
.toVector();

For plain fields, provide both member pointers.

auto joined = posts.join<&Post::authorId, &Author::id>(authors).toVector();

If the right field is the right table's primary key, the join uses primary-key lookups. Otherwise it scans the right table for each left row.

AkkaraDB::Schema registers typed tables and installs hook-based foreign-key behavior.

auto schema = db->schema()
.table<&Author::id>("authors")
.table<&Post::id>("posts")
.foreignKey<&Post::author>({akkaradb::OnDelete::Cascade}, {akkaradb::OnUpdate::Cascade})
.open();
auto& authors = schema.table<Author>();
auto& posts = schema.table<Post>();

For a plain field relation, provide the source field and target field.

schema.foreignKey<&PlainPost::authorId, &Author::id>(
{akkaradb::OnDelete::Restrict},
{akkaradb::OnUpdate::Cascade}
);
ActionDelete behaviorPrimary-key update behavior
RestrictRejects deleting a referenced target.Rejects moving a referenced target key.
CascadeRemoves source rows that reference the target.Rewrites source fields to the new target key.
SetNullSets nullable source fields to std::nullopt.Sets nullable source fields to std::nullopt.

Only one action can be selected for delete and one for update. SetNull requires a nullable source field. Foreign keys are implemented by table hooks and scans, not by a native constraint subsystem.

When the target field is not the target table primary key, foreignKey<FieldPtr, TargetFieldPtr>() registers an index on the target field so existence checks can use indexed lookup. Delete and update actions still scan the source table to find referencing rows. For Ref<T> fields, delete actions require the target primary key; update actions on refs are effectively skipped because refs follow row ids.

The native high-level API keeps typed data in the same engine key space as the low-level API. When VersionLog is enabled on the underlying engine, table rows participate in the same version history because each typed row is ultimately an engine key/value write.

This C++ high-level header currently exposes current-row CRUD, row-id lookup, scan, query, index, ref, join, and schema behavior. It does not expose typed getAt() or typed history() helpers in PackedTable; use db->engine() and the low-level VersionLog APIs when you need explicit historical reads or rollback control.

SymptomLikely cause
findBy<&Field>() throwsThe field index was not registered with index<&Field>() or indexed<&Field>().
Newly registered index misses old rowsIndex registration does not backfill existing rows. Rewrite or rebuild rows after registering the index.
Ref<T> cannot resolveThe ref is detached, the target table is not registered in the schema/binding, or the target row was removed.
foreignKey<&RefField>() throws target table not registeredThe schema does not include the referenced entity table.
SetNull setup failsThe source field is not std::optional<T>.
updatePrimaryKey() throws destination existsThe new primary key is already present.
Loaded Immutable<T> field rejects assignmentThe value was sealed after decode or persist.
Data appears missing after table renameThe table name is part of the hashed storage prefix.
Struct change corrupts decode assumptionsBinPack aggregate encoding depends on field order and compatible field types.

For a first C++ embedding:

  1. Define plain structs with stable primary-key fields.
  2. Add AKKARADB_ENTITY beside each persisted struct.
  3. Open one AkkaraDB per data directory.
  4. Register indexes immediately after opening a table.
  5. Use query() for typed filtering and findBy() for indexed equality lookup.
  6. Use Ref<T> and Schema only where row-id stability or referential actions are actually needed.