crates/bindings-cpp/ARCHITECTURE.md
The SpacetimeDB C++ bindings provides a sophisticated compile-time/runtime hybrid system for building database modules in C++ that compile to WebAssembly (WASM) and run inside the SpacetimeDB database. This document describes the architectural components, type registration flow, and key differences from other language SDKs.
The SDK provides Outcome<T>, a type-safe error handling mechanism matching Rust's Result<T, E> pattern:
ReducerResult): Used by reducers, can return success (Ok()) or error (Err(message))The SDK uses a numbered preinit function system to ensure correct initialization order:
__preinit__01_ - Clear global state (first)
__preinit__10_ - Field registration
__preinit__19_ - Auto-increment integration and scheduled reducers
__preinit__20_ - Table and lifecycle reducer registration
__preinit__21_ - Field constraints
__preinit__25_ - Row level security filters
__preinit__30_ - User reducers
__preinit__40_ - Views
__preinit__50_ - Procedures
__preinit__99_ - Type validation and error detection (last)
The C++ bindings uses Outcome<T> for type-safe, exception-free error handling that matches Rust's Result<T, E> pattern (where E is always std::string).
// For reducers - cannot fail with a value, only with an error message
using ReducerResult = Outcome<void>;
Creating Results:
#include <spacetimedb.h>
using namespace SpacetimeDB;
struct User {
Identity identity;
std::optional<std::string> name;
bool online;
};
SPACETIMEDB_STRUCT(User, identity, name, online);
SPACETIMEDB_TABLE(User, user, Public);
FIELD_PrimaryKey(user, identity);
SPACETIMEDB_REDUCER(create_user, ReducerContext ctx, std::string name) {
// Validation with early error return
if (name.empty()) {
return Err("Name cannot be empty");
}
if (name.length() > 255) {
return Err("Name is too long");
}
// Success path
ctx.db[user].insert(User{ctx.sender(), name, false});
return Ok(); // No value needed - just success
}
Checking Results:
SPACETIMEDB_REDUCER(call_other_logic, ReducerContext ctx) {
// Note: In practice, reducers don't call other reducers directly
// But if implementing error-handling helper functions:
auto result = validate_something();
if (result.is_err()) {
return Err(result.error()); // Propagate error
}
// Continue on success
return Ok();
}
Error Semantics:
Err() is returned:
Ok() is returned:
Key Difference from Reducers:
T (not Outcome<T>)// Creating success outcomes
Outcome<T>::Ok(value) // Outcome<T> - with a value
Ok() // Outcome<void> - without a value
Ok(value) // Helper - type deduced from value
// Creating error outcomes
Outcome<T>::Err(message) // Outcome<T> - with error message
Err(message) // Outcome<void> - with error message
Err<T>(message) // Helper - explicit type specification
// Checking results
outcome.is_ok() // bool - true if success
outcome.is_err() // bool - true if error
// Accessing values/errors
outcome.value() // T& or T&& - get success value (UB if error)
outcome.error() // const std::string& - get error message (UB if success)
Why not exceptions?
Why separate ReducerResult and Outcome<T>?
Location: Template instantiation during compilation
Components:
C++20 Concepts (table_with_constraints.h):
template<typename T>
concept FilterableValue =
std::integral<T> ||
std::same_as<T, std::string> ||
std::same_as<T, Identity> ||
// ... other filterable types
template<typename T>
concept AutoIncrementable =
std::same_as<T, int8_t> ||
std::same_as<T, uint32_t> ||
// ... integer types only
Static Assertions in FIELD_ macros:
#define FIELD_Unique(table_name, field_name) \
static_assert([]() constexpr { \
using FieldType = decltype(std::declval<TableType>().field_name); \
static_assert(FilterableValue<FieldType>, \
"Field cannot have Unique constraint - type is not filterable."); \
return true; \
}(), "Constraint validation for " #table_name "." #field_name);
Validation Coverage:
Error Output: Clear compile-time error messages with specific guidance
Location: WASM module load, before any user code executes
extern "C" __attribute__((export_name("__preinit__01_clear_global_state")))
void __preinit__01_clear_global_state() {
ClearV9Module(); // Reset module definition and handler registries
getModuleTypeRegistration().clear(); // Reset type registry and error state
}
Generated by macros during preprocessing:
Table Registration (_preinit__20):
SPACETIMEDB_TABLE(User, users, Public)
// Generates:
extern "C" __attribute__((export_name("__preinit__20_register_table_User_line_42")))
void __preinit__20_register_table_User_line_42() {
SpacetimeDB::Module::RegisterTable<User>("users", true);
}
Field Constraints (_preinit__21):
FIELD_PrimaryKey(users, id);
// Generates:
extern "C" __attribute__((export_name("__preinit__21_field_constraint_users_id_line_43")))
void __preinit__21_field_constraint_users_id_line_43() {
getV9Builder().AddFieldConstraint<User>("users", "id", FieldConstraint::PrimaryKey);
}
Auto-Increment Integration Registration (_preinit__19):
Auto-increment fields require special handling during insert() operations. When SpacetimeDB processes an auto-increment insert, it returns only the generated column values (not the full row) in BSATN format. The C++ bindings uses a registry-based integration system to properly handle these generated values and update the user's row object.
FIELD_PrimaryKeyAutoInc(users, id);
// Generates both constraint registration AND auto-increment integration:
// 1. Auto-increment integration function (unique per field via __LINE__)
namespace SpacetimeDB { namespace detail {
static void autoinc_integrate_47(User& row, SpacetimeDB::bsatn::Reader& reader) {
using FieldType = decltype(std::declval<User>().id);
FieldType generated_value = SpacetimeDB::bsatn::deserialize<FieldType>(reader);
row.id = generated_value; // Update field with generated ID
}
}}
// 2. Registration function to register the integrator
extern "C" __attribute__((export_name("__preinit__19_autoinc_register_47")))
void __preinit__19_autoinc_register_47() {
SpacetimeDB::detail::get_autoinc_integrator<User>() =
&SpacetimeDB::detail::autoinc_integrate_47;
}
Runtime Integration Process:
When insert() is called on a table with auto-increment fields:
insert() returns the updated row with the correct generated IDThis system enables users to immediately access generated IDs:
struct User {
uint64_t id;
std::optional<std::string> name;
};
SPACETIMEDB_STRUCT(User, id, name);
SPACETIMEDB_TABLE(User, user, Public);
FIELD_PrimaryKeyAutoInc(user, id);
SPACETIMEDB_REDUCER(create_user2, ReducerContext ctx, std::string name) {
User new_user{0, name}; // id=0 will be auto-generated
User inserted_user = ctx.db[user].insert(new_user); // Returns user with generated ID
LOG_INFO("Created user with ID: " + std::to_string(inserted_user.id));
return Ok(); // Must return ReducerResult
}
Reducer Registration (_preinit__30):
SPACETIMEDB_REDUCER(add_user, ReducerContext ctx, std::string name) {
if (name.empty()) {
return Err("Name cannot be empty"); // Return error - rolled back
}
ctx.db[user].insert(User{0, name});
return Ok(); // Success - transaction committed
}
// Generates registration function that captures parameter types, creates dispatch handler,
// and wraps return value in ReducerResult (Outcome<void>)
During constraint registration, track primary keys per table:
// In V9Builder::AddFieldConstraint
if (constraint == FieldConstraint::PrimaryKey) {
if (table_has_primary_key[table_name]) {
SetMultiplePrimaryKeyError(table_name); // Set global error flag
}
table_has_primary_key[table_name] = true;
}
Component: ModuleTypeRegistration system (module_type_registration.h)
Core Principle: Only user-defined structs and enums get registered in the typespace. Primitives, arrays, Options, and special types are always inlined.
Architecture Note: V9Builder serves as the registration coordinator but delegates all type processing to the ModuleTypeRegistration system. This separation ensures a single, unified type registration pathway.
Registration Flow:
class ModuleTypeRegistration {
AlgebraicType registerType(const bsatn::AlgebraicType& bsatn_type,
const std::string& explicit_name = "",
const std::type_info* cpp_type = nullptr) {
// 1. Check if primitive → return inline
if (isPrimitive(bsatn_type)) return convertPrimitive(bsatn_type);
// 2. Check if array → return inline Array with recursive element processing
if (bsatn_type.tag() == bsatn::AlgebraicTypeTag::Array)
return convertArray(bsatn_type);
// 3. Check if Option → return inline Sum structure
if (isOptionType(bsatn_type)) return convertOption(bsatn_type);
// 4. Check if special type → return inline Product structure
if (isSpecialType(bsatn_type)) return convertSpecialType(bsatn_type);
// 5. User-defined type → register in typespace, return Ref
return registerUserDefinedType(bsatn_type, explicit_name, cpp_type);
}
};
Circular Reference Detection:
// Track types currently being registered
std::unordered_set<std::string> types_being_registered_;
AlgebraicType registerUserDefinedType(...) {
if (types_being_registered_.contains(type_name)) {
setError("Circular reference detected in type: " + type_name);
return createErrorType();
}
types_being_registered_.insert(type_name);
// ... process type ...
types_being_registered_.erase(type_name);
}
Location: Final preinit function - runs after all registration is complete
Error Detection:
extern "C" __attribute__((export_name("__preinit__99_validate_types")))
void __preinit__99_validate_types() {
// 1. Check for circular reference errors
if (g_circular_ref_error) {
createErrorModule("ERROR_CIRCULAR_REFERENCE_" + g_circular_ref_type_name);
return;
}
// 2. Check for multiple primary key errors
if (g_multiple_primary_key_error) {
createErrorModule("ERROR_MULTIPLE_PRIMARY_KEYS_" + g_multiple_primary_key_table_name);
return;
}
// 3. Check for type registration errors
if (getModuleTypeRegistration().hasError()) {
createErrorModule("ERROR_TYPE_REGISTRATION_" + sanitize(error_message));
return;
}
}
Error Module Creation: When errors are detected, the normal module is replaced with a special error module containing an invalid type reference. When SpacetimeDB tries to resolve the type, it fails with an error message that includes the descriptive error type name.
Function: __describe_module__() - Called by SpacetimeDB after preinit functions complete
Process:
The C++ bindings provides a unique compile-time namespace qualification system for enum types, allowing better organization in generated client code without affecting server-side C++ usage.
Location: enum_macro.h - namespace_info template specialization
namespace SpacetimeDB::detail {
// Primary template - no namespace by default
template<typename T>
struct namespace_info {
static constexpr const char* value = nullptr;
};
}
// SPACETIMEDB_NAMESPACE macro creates specialization
#define SPACETIMEDB_NAMESPACE(EnumType, NamespacePrefix) \
namespace SpacetimeDB::detail { \
template<> \
struct namespace_info<EnumType> { \
static constexpr const char* value = NamespacePrefix; \
}; \
}
Location: module_type_registration.h - Compile-time namespace detection
template<typename T>
class LazyTypeRegistrar {
static bsatn::AlgebraicType getOrRegister(...) {
std::string qualified_name = type_name;
// Compile-time check for namespace information
if constexpr (requires { SpacetimeDB::detail::namespace_info<T>::value; }) {
constexpr const char* namespace_prefix =
SpacetimeDB::detail::namespace_info<T>::value;
if (namespace_prefix != nullptr) {
qualified_name = std::string(namespace_prefix) + "." + type_name;
}
}
// Register with qualified name
type_index_ = getModuleTypeRegistration().registerAndGetIndex(
algebraic_type, qualified_name, &typeid(T));
}
};
When an enum with namespace qualification is registered:
Why Separate Macros?
Why Template Specialization?
Alternative 1: Preinit Runtime Modification (Rejected)
Alternative 2: Embedded in SPACETIMEDB_ENUM (Rejected)
Current Approach Benefits:
Rust bindings:
C# bindings:
C++ bindings:
Rust bindings:
C# bindings:
C++ bindings:
Rust bindings:
C# bindings:
C++ bindings - Two-Tier System:
Reducer errors (ReducerResult / Outcome<void>):
Ok() on success (transaction committed)Err(message) on failure (transaction rolled back)Type registration errors:
Outcome<T> Type:
is_ok(), is_err(), value(), error()Rust bindings:
C# bindings:
C++ bindings:
Developer writes code
↓
C++ compilation → Compile-time validation (concepts, static_assert)
↓
Emscripten WASM build → Template instantiation validation
↓
Module publishing → Runtime validation (__preinit__99_)
↓
SpacetimeDB loading → Server-side validation and error reporting