crates/bindings-cpp/DEVELOP.md
This document explores how upgrading to C++23 and C++26 could fundamentally transform the SpacetimeDB C++ bindings, moving from runtime registration to compile-time type system integration and eliminating most macro usage.
Without reflection, the compiler cannot:
This forces us into a multi-layer macro system with runtime registration:
struct User {
uint32_t id;
std::string name;
std::string email;
};
SPACETIMEDB_STRUCT(User, id, name, email)
Why we need it:
bsatn_traits<User> specialization that knows how to serialize/deserializeWhat it generates:
template<> struct bsatn_traits<User> {
static AlgebraicType algebraic_type() {
// Builds Product type with fields [id, name, email]
}
static void serialize(Writer& w, const User& val) {
w.write(val.id); w.write(val.name); w.write(val.email);
}
};
Why it can't be compile-time: We can't discover the fields without listing them explicitly.
SPACETIMEDB_TABLE(User, users, Public)
Why we need it separately from STRUCT:
What it generates:
extern "C" __attribute__((export_name("__preinit__20_register_table_User")))
void __preinit__20_register_table_User() {
Module::RegisterTable<User>("users", true);
}
// Plus a tag type for clean syntax: ctx.db[users]
Why it must be runtime: The module schema must be built dynamically when the WASM module loads, as we can't generate a complete module description at compile-time.
FIELD_PrimaryKey(users, id);
FIELD_Unique(users, email);
FIELD_Index(users, name);
Why we need separate macros per constraint:
What they generate:
// Compile-time validation
static_assert(FilterableValue<decltype(User::id)>, "Primary keys must be filterable");
// Runtime registration
extern "C" __attribute__((export_name("__preinit__21_field_constraint_users_id")))
void __preinit__21_field_constraint_users_id() {
AddFieldConstraint<User>("users", "id", FieldConstraint::PrimaryKey);
}
Why the split: Compile-time validation via concepts, but runtime registration for module schema.
__preinit__01_ - Clear global state
__preinit__10_ - Field registration
__preinit__19_ - Auto-increment integration and scheduled reducers
__preinit__20_ - Table and lifecycle reducer registration
__preinit__21_ - Field constraints (must come after tables)
__preinit__25_ - Row level security filters
__preinit__30_ - User reducers
__preinit__40_ - Views
__preinit__50_ - Procedures
__preinit__99_ - Type validation and error detection
Why we need priority ordering:
Why it's runtime: WASM modules are initialized linearly, and we need to build the module schema during this initialization phase.
SPACETIMEDB_ENUM(UserRole, Admin, Moderator, Member)
SPACETIMEDB_NAMESPACE(UserRole, "Auth") // Separate macro for namespace
Why we need a separate macro:
What it generates:
namespace SpacetimeDB::detail {
template<> struct namespace_info<UserRole> {
static constexpr const char* value = "Auth";
};
}
Why it's compile-time but still needs a macro:
if constexpr to detect namespace at compile-timeextern "C" const uint8_t* __describe_module__() {
// Serialize the complete module built by __preinit__ functions
return Module::serialize();
}
Why it's needed:
The fundamental limitation: Without compile-time reflection, we cannot know at compile-time:
This creates a cascade of limitations:
Each macro exists because C++20 lacks the reflection capabilities to do this work automatically. The runtime registration exists because we can't build a complete module description at compile-time without knowing what types exist and their relationships.
The current C++20 SDK relies on:
__preinit__ functions (because no compile-time type discovery)__preinit__99_validate_types (because incomplete compile-time info)this for Zero-Cost Field AccessorsCurrent approach:
template<typename TableType, typename FieldType>
class TypedFieldAccessor : public TableAccessor<TableType> {
FieldType TableType::*member_ptr_;
// Complex inheritance hierarchy
};
C++23 approach:
struct TableAccessor {
template<typename Self>
auto filter(this Self&& self, auto&& predicate) {
// Deduce table type from self, no inheritance needed
return self.table_.filter(std::forward<decltype(predicate)>(predicate));
}
};
Benefits:
if consteval for Hybrid Compile/Runtime ValidationCurrent approach:
// Static assertions in macros
static_assert(FilterableValue<FieldType>, "Error message");
// Plus runtime validation in __preinit__99
C++23 approach:
template<typename T>
constexpr auto validate_constraint() {
if consteval {
// Compile-time path: full validation
static_assert(FilterableValue<T>);
return compile_time_type_id<T>();
} else {
// Runtime fallback for dynamic types
return runtime_type_registry::get<T>();
}
}
Benefits:
std::expected for Error PropagationCurrent approach:
// Global error flags
static bool g_multiple_primary_key_error = false;
static std::string g_multiple_primary_key_table_name = "";
C++23 approach:
template<typename T>
using RegistrationResult = std::expected<TypeId, RegistrationError>;
constexpr RegistrationResult register_table() {
if (/* multiple primary keys detected */)
return std::unexpected(RegistrationError::MultiplePrimaryKeys);
return TypeId{...};
}
Benefits:
constexpr std::unique_ptr for Compile-Time Type TreesCurrent approach:
// Runtime type tree building
std::vector<AlgebraicType> types;
types.push_back(...);
C++23 approach:
constexpr auto build_type_tree() {
std::unique_ptr<TypeNode> root = std::make_unique<TypeNode>();
// Build entire type hierarchy at compile time
return root;
}
constexpr auto type_tree = build_type_tree();
Benefits:
std::ranges for Cleaner Type ProcessingCurrent approach:
// Manual iteration and filtering
std::vector<AlgebraicType> types;
for (const auto& type : all_types) {
if (isPrimitive(type)) continue;
if (isOptionType(type)) continue;
types.push_back(processType(type));
}
C++23 approach:
// Declarative pipeline with ranges
auto process_types() {
return all_types
| std::views::filter(not_primitive)
| std::views::filter(not_option)
| std::views::transform(processType)
| std::ranges::to<std::vector>();
}
// Better: lazy evaluation for type checking
auto valid_types = registered_types
| std::views::filter([](auto& t) { return validate_type(t).has_value(); });
Benefits:
std::mdspan for Table Data AccessCurrent approach:
// Custom iterators and accessors
for (const auto& row : table) { }
C++23 approach:
template<typename T>
using TableView = std::mdspan<T, std::extents<size_t, std::dynamic_extent, field_count<T>()>>;
// Direct columnar access
auto names = table_view[std::full_extent, name_column];
Benefits:
Note: The C++26 examples below are based on current proposals (particularly P2996 for reflection, which uses the
^reification operator). The final C++26 standard may differ significantly as proposals evolve through the standardization process. These examples are illustrative of the capabilities that reflection would enable, not necessarily the exact syntax that will be standardized.
Current approach:
SPACETIMEDB_STRUCT(User, id, name, email)
SPACETIMEDB_TABLE(User, users, Public)
FIELD_PrimaryKey(users, id);
SPACETIMEDB_ENUM(UserRole, Admin, Moderator, Member)
SPACETIMEDB_NAMESPACE(UserRole, "Auth") // Separate macro for namespace
Illustrative C++26 approach (exact syntax TBD in standardization):
// Natural C++ attributes replace macros
struct [[spacetimedb::table("users", public)]] User {
[[spacetimedb::primary_key]] uint32_t id;
[[spacetimedb::unique]] std::string email;
std::string name;
};
enum class [[spacetimedb::namespace("Auth")]] UserRole {
Admin, Moderator, Member
};
// Automatic registration via reflection (pseudocode - actual API TBD)
template<typename T> requires has_spacetimedb_table_attr<T>
consteval void register_table() {
for (constexpr auto member : reflect_members_of<T>()) {
if constexpr (has_attribute<member, spacetimedb::primary_key>) {
register_primary_key<T>(member.name(), member.type());
}
}
}
// Automatic namespace detection via reflection
template<typename T>
consteval std::string get_qualified_name() {
if constexpr (has_attribute<T, spacetimedb::namespace>) {
return get_namespace_attribute<T>() + "." + get_type_name<T>();
}
return get_type_name<T>();
}
Benefits:
Current approach:
static_assert(FilterableValue<FieldType>, "Field cannot have Index constraint");
C++26 approach:
template<typename T>
void add_index_constraint(T TableType::*field)
[[pre: FilterableValue<T>]]
[[pre: !has_existing_index(field)]]
{
// Contract violations become compile-time or runtime errors
// depending on evaluation context
}
Benefits:
Current approach:
switch(type.tag()) {
case AlgebraicTypeTag::Product: ...
case AlgebraicTypeTag::Sum: ...
// Manual casting and handling
}
C++26 approach:
inspect(type) {
<Product> p => serialize_product(p),
<Sum> s => serialize_sum(s),
<Array> [auto elem_type] => serialize_array(elem_type),
<Option> opt => serialize_option(opt),
_ => throw InvalidType{}
}
Benefits:
Current approach:
extern "C" __attribute__((export_name("__preinit__20_register_table_User")))
void __preinit__20_register_table_User() {
Module::RegisterTable<User>("users", true);
}
C++26 approach:
// Automatic discovery and registration at compile time
template<typename... Tables>
consteval auto generate_module_descriptor() {
ModuleDescriptor desc;
(reflect_and_register<Tables>(desc), ...);
return desc;
}
// Single compile-time constant contains entire module
constexpr auto module = generate_module_descriptor<
discover_tables_via_reflection()... // Find all types with [[spacetimedb::table]]
>();
// Single runtime export
extern "C" const uint8_t* __describe_module__() {
return module.serialize(); // Already computed at compile-time
}
Benefits:
constexpr AllocationCurrent approach:
// Runtime type vector building
std::vector<AlgebraicType> types;
C++26 approach:
constexpr auto build_typespace() {
std::vector<AlgebraicType> types;
// Fully constexpr vector operations
for (auto type : reflect_all_types()) {
types.push_back(analyze_type(type));
}
return types;
}
constexpr auto typespace = build_typespace();
Benefits:
Current approach (workaround using Outcome<void>):
SPACETIMEDB_REDUCER(process, ReducerContext ctx, uint32_t id) {
if (id == 0) {
return Err("Invalid ID"); // Error represented as string
}
ctx.db[users].insert({id});
return Ok(); // No value, just success
}
C++26 potential approach (with better Result types):
// Could use std::expected with richer error information
template<typename T, typename E = std::string>
using Result = std::expected<T, E>;
SPACETIMEDB_REDUCER(process, ReducerContext ctx, uint32_t id) {
if (id == 0) {
return std::unexpected(ProcessError::InvalidId); // Type-safe errors
}
ctx.db[users].insert({id});
return {}; // Clear success value
}
Benefits:
Current approach:
// Manual field listing in SPACETIMEDB_STRUCT macro
SPACETIMEDB_STRUCT(MyType, field1, field2, field3)
C++26 approach:
template<typename T>
constexpr void serialize(Writer& w, const T& obj) {
[:expand(^T::members()):] >> [&]<auto member> {
w.write(obj.[:member:]);
};
}
// Automatic serialization for any struct, no macros needed
Benefits:
thisstd::expected for error handlingif consteval for hybrid validationconstexpr type tree buildingstd::expectedthisDisclaimer: The following performance projections are educated estimates based on similar language features and SDK patterns. Actual performance gains will depend on:
- Compiler optimization capabilities
- Final C++26 feature specifications
- WASM code generation characteristics
- Specific module structure (table count, field count, etc.)
constexpr evaluation (estimated +5-15%)C++26's static reflection will enable a significant paradigm shift from runtime registration to compile-time module generation. The SpacetimeDB C++ bindings could achieve zero-overhead abstractions with no macros and no runtime registration - just pure, standard C++.
The journey through C++23 provides valuable incremental improvements:
deducing thisstd::expectedif constevalC++26's reflection capabilities will allow us to achieve compile-time type safety and module generation with natural C++ syntax, making the C++ bindings substantially more ergonomic and performant than currently possible.
Important caveats: