A complete walkthrough — from an empty directory to a production-safe plugin with models, migrations, validators, triggers, and scheduled jobs. All examples use real Hive API types from include/hive-api/ and include/hive-model/.
Hive enforces strict module boundaries. Violating them causes build failures (dependency order enforced by CMake). Before writing any code, understand which module your code belongs in.
| Module | Include this for | Do NOT include from |
|---|---|---|
hive-api |
Plugin base class, PluginFactory macros, IValidator, IRepository, Trigger, TriggerPhase, Job, JobConfig, OperationResult, AccessTokenContext — everything a plugin author needs | Never include hive-http or hive-db-sqlite internals from plugin code |
hive-model |
ModelDefinition, ColumnDefinition, ColumnDefinitionFlag, EnumDefinition — pure metadata types | Does not depend on HTTP or persistence modules |
hive-essential |
Shared enums and runtime services: Crudl, UserRole, UserStatus, AccessMode, RegistrationMode, DatabaseType, Configuration | — |
hive-orm |
BaseModel entity class, column constant structs (BaseColumns-style typed access) | Should not include hive-db-sqlite or hive-http |
hive-plugin-* |
Include hive-api and hive-model only. Never directly call hive-db-sqlite or hive-http. | All database access must go through IRepository. All HTTP routing through ModelDefinition registration. |
Every entity you want Hive to manage must be described by a ModelDefinition. This single struct drives REST routing, database persistence, frontend rendering, caching, and validation — simultaneously.
dictionary_termThis is the actual (abridged) definition from include/hive-plugin-dictionary/.../models/DictionaryTerm.hpp — def is an alias for ModelDefinition, coldef for ColumnDefinition:
// ColumnDefinitionFlag bitmask values (ColumnDefinition.hpp):
// MANDATORY=1 UNIQUE=2 FOREIGN_KEY=4 AUTO=8
// HIDDEN=16 READONLY=32 MUTABLE=64 INTERNAL=128
// TEXT=256 TEXTAREA=512 INTEGER=1024 REAL=2048
// BLOB=4096 BOOL=8192 DATETIME=16384
inline const def DICTIONARY_TERM_DEFINITION =
def(COLS::MODEL_NAME, "dictionary") // (model name, plugin name)
.set_group("Dictionary", 200) // frontend nav group + order
.set_all_rest_operations() // enable C+R+U+D+L
.set_title_column(COLS::TITLE) // display column for FK labels
.set_columns({
// id, created_at, updated_at are prepended automatically
coldef(COLS::DICTIONARY_MAP_ID, MANDATORY | FOREIGN_KEY | READONLY)
.set_description("Dictionary map this term belongs to."),
coldef(COLS::TITLE, MANDATORY)
.set_description("Title of the dictionary term."),
coldef(COLS::DISAMBIGUATION),
coldef(COLS::DEFINITION, TEXTAREA)
.set_description("Definition of the term."),
coldef(COLS::STATUS)
.set_default_value(0)
.set_enum_definition(enums::term_status_to_enum_definition())
.set_description("Status of the term."),
coldef(COLS::IMPORTANCE)
.set_default_value(2)
.set_enum_definition(enums::importance_to_enum_definition()),
coldef(COLS::DIFFICULTY)
.set_default_value(2)
.set_enum_definition(enums::difficulty_to_enum_definition()),
coldef(COLS::IS_FOR_REPETITION, BOOL).set_default_value(false),
coldef(COLS::IS_ROOT, BOOL).set_default_value(false),
})
// Custom actions: navigation buttons in the generated UI —
// params support the "{id}" placeholder for the current record
.add_custom_list_action("dictionary_term_visit", "List term visits",
{"dictionary_term_id", "{id}"})
.add_custom_create_action("dictionary_tag", "Add tag",
{"dictionary_term_id", "{id}"})
.add_custom_read_action("dictionary_older_term", "Read older term",
{"id", "{id}"})
.add_custom_read_action("dictionary_newer_term", "Read newer term",
{"id", "{id}"});
| Method | Effect |
|---|---|
set_rest_operations("crl") | Enable only some operations — each char maps to Create/Read/Update/Delete/List |
set_readonly() | No mutations allowed via API |
set_virtual_table(true) | Model has no real DB table — data comes from InsteadOf triggers (e.g. note_navigation, dictionary_term_metric) |
set_no_table(true) | Model without any table at all |
set_cache_enabled(false) | Disable the LRU cache for this model (done for high-write models like api_log, history) |
set_cached_after_create(false) | Skip pre-populating the cache on create |
| Convention | Behaviour |
|---|---|
set_columns() | Automatically prepends id, created_at, updated_at as the first three columns |
*_id suffix | Automatically typed as Integer and linked as a foreign key to the model named by the prefix |
set_enum_definition(...) | Binds an EnumDefinition so the API/UI shows human-readable labels instead of raw integers |
set_default_value(...) / set_description(...) | Default value for create; description surfaced in the generated UI |
Validators are the security and consistency gate of Hive. They run before any trigger or persistence call. A validator can inspect the AccessTokenContext (who is calling, with what role) and the request payload, and either pass or reject the operation.
#include <hive/api/OperationResult.hpp>
struct OperationResult {
int status; // 0 = OK, any other number = HTTP error code
std::string error; // error description, empty if OK
bool ok() const { return status == 0; }
bool ko() const { return !ok(); }
explicit operator bool() const noexcept;
};
// Built-in result macros (use these instead of raw constructors)
ok_result // {0, ""}
status_403_forbidden // {403, "You are not authorized..."}
status_405_unsupported_operation // {405, "Unsupported operation"}
#include <hive/api/IValidator.hpp>
class IValidator {
public:
virtual OperationResult can_create(DbPtr& db, AccessTokenContext& token,
entity_fields& ef) const = 0;
virtual OperationResult can_read (DbPtr& db, AccessTokenContext& token,
identification id) const = 0;
virtual OperationResult can_update(DbPtr& db, AccessTokenContext& token,
entity_fields& ef,
entity_fields& old_fields) const = 0;
virtual OperationResult can_delete(DbPtr& db, AccessTokenContext& token,
identification id) const = 0;
virtual OperationResult can_list (DbPtr& db, AccessTokenContext& token,
string_map& filter) const = 0;
};
can_update receives both the new and the old entity fields, enabling diff-based validation. AccessTokenContext provides ok() (authenticated?) and user_id for ownership checks.
DictionaryTermValidatorDomain plugins usually implement validators on a typed layer above IValidator: authorization hooks receive a RequestContext (with resolved role) and typed model instances instead of raw field maps. Real excerpt:
OperationResult DictionaryTermValidator::validate_create_authorization(
const RequestContext& ctx, const Model& entity) const
{
return_if(ctx.role < hive::essential::UserRole::Editor,
403, "You can not create Terms.")
if (!dictionary::has_right_for_map(ctx, entity.dictionary_map_id,
plugins::core::enums::SingleRight::Write))
{
return {403, "You do not have permission to create a Term for this map."};
}
return ok_result;
}
OperationResult DictionaryTermValidator::validate_update_authorization(
const RequestContext& ctx,
const Model& old_entity, const Model& new_entity) const
{
return_if(ctx.role < hive::essential::UserRole::Editor,
403, "You can not update Terms.")
if (!dictionary::has_right_for_map(ctx, new_entity.dictionary_map_id,
plugins::core::enums::SingleRight::Write))
{
return {403, "You do not have permission to update a Term for this map."};
}
return ok_result;
}
Note the layered access control: the role hierarchy check (ctx.role < Editor) composes with per-map rights (has_right_for_map with SingleRight::{Read, Write, Delete}) built on the core plugin's team/team_member models — on top of the global AccessMode gate.
// Validators are attached at model registration — one validator per model.
// The REGISTER_MODEL macro expands to:
plugin->register_model(
models::DICTIONARY_TERM_DEFINITION,
std::make_shared<validators::DictionaryTermValidator>(),
repository_factory);
Triggers hook into the CRUD pipeline. Unlike validators (which only accept/reject), triggers can modify data, replace persistence entirely, or execute side effects after persistence.
| TriggerPhase | Value | When it fires | Can modify data? | Can abort? |
|---|---|---|---|---|
Before | 0 | After validators, before repository call | Yes — mutate the incoming fields | Yes — via a non-OK result |
After | 1 | After repository call, post-persistence | No (entity already written) | No (cannot undo) |
InsteadOf | 2 | Replaces the repository call entirely | Yes — full control | Returning std::nullopt falls through to the default behaviour |
Around | 3 | Like Before and After combined in one trigger | Yes | Yes (Before part) |
#include <hive/api/Trigger.hpp>
class Trigger : public AbstractTriggerJob {
Trigger(
const std::string& name,
const std::string& description,
int priority, // lower = runs first
std::set<hive::essential::Crudl> operations,
TriggerPhase phase,
const std::string& table // "*" = all tables
);
};
// Before/After handler — receives the full operation context:
run_before_or_after(
operation, // Crudl
stack_depth, // recursion guard for nested trigger calls
validation_result,
action_result, // out param — non-OK aborts (Before phase)
def, // ModelDefinition of the target model
user_id, id,
fields, // mutable entity fields
old_fields, // previous values (for Update)
query_params);
// InsteadOf handlers return std::optional — empty means
// "fall through to the standard repository call":
std::optional<pair<int, OperationResult>> run_instead_of_create(...);
std::optional<pair<entity_fields, OperationResult>> run_instead_of_read(...);
std::optional<pair<vector<entity_fields>,
OperationResult>> run_instead_of_list(...);
// Triggers and jobs can call registered named queries:
nlohmann::json call_query(const std::string& query_name,
nlohmann::json& request);
| Trigger | Phase / Table | What it demonstrates |
|---|---|---|
HistoryCommonTrigger (core) | After, "*", priority 1000 | Global audit: serializes every successful CRUDL operation to the history table as JSON; skips log tables and failed operations; runs last via high priority |
BeforeCreateNoteTrigger (slip_box) | Before, note | Data preparation: computes sibling order and content linkage before persistence (uses FindNextSiblingOrderSQLiteQuery) |
UpdateNotePathAndDepthAfterTrigger (slip_box) | After, note | Derived-data maintenance: recomputes hierarchical path/depth for a whole subtree after a parent change |
InsteadOfListDictionaryTermFulltextTrigger (dictionary) | InsteadOf, virtual fulltext model | Virtual read: replaces the default list with a relevance-ranked fulltext search (exact > prefix > substring) |
InsteadOfReadNoteNavigationTrigger (slip_box) | InsteadOf, virtual note_navigation | Virtual entity: assembles previous/next/parent/children/links for the graph exploration view — no table behind it |
RReviewAfterCreateTrigger (repetition) | After, r_review | Cross-model automation: each graded review updates the per-item scheduling state |
// In the plugin factory:
plugin->register_trigger(std::make_shared<triggers::RSessionBeforeCreateTrigger>());
plugin->register_trigger(std::make_shared<triggers::RReviewAfterCreateTrigger>());
// Trigger execution order within a phase is determined by priority:
// Lower integer = earlier execution
// Priority 1 → fires first
// Priority 1000 → fires last (e.g. HistoryCommonTrigger)
Jobs run on a fixed schedule managed by CronScheduler (4 worker threads). Each job run is persisted to the job_run table. Jobs can access repositories and queries via JobConfig.
#include <hive/api/Job.hpp>
class Job : public AbstractTriggerJob {
Job(
const std::string& job_name, // unique — stored in job_entry
const std::string& job_description,
const std::string& cron_expression,
bool enabled_by_default = true,
bool run_once_when_missed = true // catch up after downtime
);
// Called by a CronScheduler worker thread.
// Return value: error string — empty string means success.
virtual std::string run(cronq::JobConfig& job_config) = 0;
// Jobs (like triggers) access data through registered named queries:
nlohmann::json call_query(const std::string& query_name,
nlohmann::json& request);
};
// Per-job configuration stored with the job_entry record:
auto [val, err] = job_config.get_string("key");
auto days = job_config.get_int_or_default("days", 30);
auto path = job_config.get_string_or_default("export_path", "./export");
auto hash = job_config.get_sha256(); // SHA-256 of the whole config
| Job | Cron | Pattern it demonstrates |
|---|---|---|
CleanupJob (core) | @daily | Retention maintenance — deletes old logs/sessions/tokens via CleanupSQLiteQuery, thresholds from JobConfig |
VacuumJob (core) | @monthly, disabled by default, run_once_when_missed=false | Heavy maintenance that should never fire automatically on startup |
HtmlExportJob (slip_box) | configurable | Batch export — renders the whole knowledge graph as navigable static HTML |
DictionaryHtmlExportJob (dictionary) | configurable | Domain-specific export driven by JobConfig values |
cronq)"@daily" // shorthand — once a day
"@monthly" // shorthand — once a month
"0 2 * * 0" // every Sunday at 02:00
"*/15 * * * *" // every 15 minutes
"0 9 * * 1" // every Monday at 09:00
Job definitions are synchronized into the job_entry table at startup; every execution writes a job_run record with status and output. Missed schedules fire once at startup when run_once_when_missed=true.
Hive uses versioned migration scripts embedded as C++ raw string literals, with SHA-256 chain-hash integrity. The integrity system guarantees that applied migrations are never silently modified after deployment. Migration state is tracked in the schema_history table.
V{sequence_number}__{descriptive_name}
Regex: V(\d+)__([a-zA-Z0-9_]+)
# Valid (real names from the built-in plugins):
V1__create_user
V2__create_dictionary_term
V3__create_dictionary_term_visit
# INVALID (will fail validation):
v1_create_user # lowercase v
V1_create_user # single underscore
V1__create user # space in name
Each plugin ships a *MigrationScripts class per database dialect that adds its scripts in order. Real excerpt from DictionarySQLiteMigrationScripts.cpp:
add_migration("V2__create_dictionary_term.sql", R"(
CREATE TABLE dictionary_term (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
dictionary_map_id INTEGER NOT NULL,
title TEXT NOT NULL,
disambiguation TEXT,
definition TEXT,
status INTEGER NOT NULL,
importance INTEGER NOT NULL,
difficulty INTEGER NOT NULL,
UNIQUE(dictionary_map_id, title, disambiguation),
FOREIGN KEY(dictionary_map_id) REFERENCES dictionary_map(id)
);
CREATE INDEX idx_dictionary_term_map ON dictionary_term(dictionary_map_id);
CREATE INDEX idx_dictionary_term_title ON dictionary_term(title);
)");
// The plugin factory registers the scripts for the configured dialect:
REGISTER_MIGRATIONS(Dictionary, SQLite)
// The core plugin additionally ships PostgreSQL scripts:
// REGISTER_MIGRATIONS(Core, PostgreSQL)
// For each migration script in sequence order:
// checksum = SHA-256(sql_content)
// chain_hash = SHA-256(previous_chain_hash || checksum)
// Store: (name, checksum, chain_hash, applied_at) in schema_history
//
// On subsequent startups:
// Recompute checksum of the embedded SQL
// If checksum != stored: FATAL — migration was modified
// Recompute chain_hash: if differs: FATAL — reordering detected
//
// Execution is transactional — a failed migration rolls back cleanly.
//
// Golden rule: NEVER edit a migration after it has been applied.
// Add a new migration instead.
Plugins are assembled by a factory that creates an api::Plugin instance and registers all contributions. This is the real pattern used by every built-in plugin (excerpt mirrors RepetitionPluginFactory.cpp):
#include "hive/api/PluginFactory.hpp"
#include "hive/plugins/myplugin/models/MyModel.hpp"
#include "hive/plugins/myplugin/validators/MyModelValidator.hpp"
#include "hive/plugins/myplugin/triggers/MyAfterCreateTrigger.hpp"
#include "hive/plugins/myplugin/migrations/MyPluginSQLiteMigrationScripts.hpp"
namespace hive::plugins::myplugin
{
api::PluginPtr MyPluginFactory::create(
std::shared_ptr<api::RepositoryFactory>& repository_factory) const
{
auto plugin = std::make_shared<api::Plugin>(
MY_PLUGIN_NAME, // "my_plugin"
"My domain", // description
std::vector<std::string>{"my_app"}, // frontend apps
std::vector<std::string>{} // explicit dependencies
); // an implicit dependency on "core" is always added
// 1. Migrations — per configured database dialect
REGISTER_MIGRATIONS(MyPlugin, SQLite)
// 2. Models — ModelDefinition + validator + repository factory
// REGISTER_MODEL(model, Model, MODEL) expands to
// plugin->register_model(models::MODEL_DEFINITION,
// std::make_shared<validators::ModelValidator>(),
// repository_factory);
REGISTER_MODEL(my_model, MyModel, MY_MODEL)
// 3. Triggers, queries, jobs
plugin->register_trigger(
std::make_shared<triggers::MyAfterCreateTrigger>());
plugin->register_query(
std::make_shared<db::sqlite::queries::myplugin::FindMyThingsSQLiteQuery>());
plugin->register_job(std::make_shared<jobs::MyExportJob>());
return plugin;
// close_for_changes() is called after registration —
// the plugin becomes immutable at runtime
}
}
file(GLOB_RECURSE PLUGIN_MYPLUGIN_SOURCES
hive/plugins/myplugin/**/*.cpp
hive/plugins/myplugin/*.cpp
)
add_library(hive_plugin_myplugin STATIC ${PLUGIN_MYPLUGIN_SOURCES})
target_include_directories(hive_plugin_myplugin
PUBLIC ${CMAKE_SOURCE_DIR}/include/hive-plugin-myplugin
PRIVATE ${CMAKE_SOURCE_DIR}/include/hive-essential
${CMAKE_SOURCE_DIR}/include/hive-model
${CMAKE_SOURCE_DIR}/include/hive-api
)
target_link_libraries(hive_plugin_myplugin
PUBLIC hive_essential hive_model hive_api
)
// Main.cpp defines:
#define REGISTER_PLUGIN(plugin, Plugin) \
plugin_registry->register_plugin( \
hive::plugins::plugin::Plugin##PluginFactory().create(repository_factory));
// register_plugins() picks the repository factory by database_type
// (SqliteRepositoryFactory today) and registers each enabled plugin:
REGISTER_PLUGIN(core, Core)
REGISTER_PLUGIN(dictionary, Dictionary) // behind MINDNET_ENABLE_DICTIONARY_PLUGIN
REGISTER_PLUGIN(myplugin, MyPlugin)
When a plugin isn't working as expected, work through this checklist in order:
Check server startup logs for the plugin registration output. If missing: verify the plugin is listed in allowed_plugins in hive.properties, the REGISTER_PLUGIN call exists in Main.cpp (and its CMake feature flag is ON), and the plugin name string matches exactly.
Check startup logs for migration output. If integrity error: revert the edited migration script or recreate the DB. If a migration is skipped: verify the V{n}__name naming — double underscore required.
sqlite3 hive.db \
"SELECT * FROM schema_history ORDER BY id DESC LIMIT 10;"
curl -s http://localhost:9000/api/v1/model_definition | \
jq '.[] | select(.name == "book")'
If missing: check register_model() call in initialize(). Verify set_all_rest_operations() or explicit ops are set.
TOKEN="3f9c2a..." # from login response
curl -s http://localhost:9000/api/v1/book \
-H "Authorization: Bearer $TOKEN"
curl -s -X POST http://localhost:9000/api/v1/book \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"title":"Test Book"}'
403 = validator rejecting. 405 = operation not enabled in ModelDefinition. 500 = server error — check logs.
After a create: check the history table. Check created_at / updated_at fields. If Before trigger not modifying fields: verify it returns fields (not std::nullopt).
sqlite3 hive.db \
"SELECT * FROM history WHERE model_name='book' ORDER BY id DESC LIMIT 5;"
sqlite3 hive.db \
"SELECT * FROM job_entry WHERE name='book_weekly_report';"
sqlite3 hive.db \
"SELECT * FROM job_run ORDER BY id DESC LIMIT 5;"
If job not in job_entry: check register_job(). If runs fail: check job_run.output for the error string. If never runs: validate the cron expression.
# configuration/hive.properties
max_log_level=DEBUG
Debug output shows request processing steps: auth context resolution, validator results, trigger execution, and scheduler activity. ModelCache::print_info() exposes cache hit/miss statistics.
All types a plugin author needs are in include/hive-api/ and include/hive-model/.
| Header | Key types / constants |
|---|---|
hive/api/Plugin.hpp | Plugin — register_model(def, validator, repo_factory), register_trigger, register_query, register_job, register_migrations, register_library_file, close_for_changes, depends_on_plugins, get_apps |
hive/api/PluginFactory.hpp | PluginFactory + REGISTER_MODEL / REGISTER_MIGRATIONS macros |
hive/api/IValidator.hpp | IValidator — can_create, can_read, can_update, can_delete, can_list (all take DbPtr& + AccessTokenContext&) |
hive/api/IRepository.hpp | IRepository — create, read, update, remove, list, list_in_ids, list_ids |
hive/api/Trigger.hpp | Trigger(name, description, priority, operations, phase, table) — run_before_or_after, run_instead_of_create/read/update/delete/list, call_query |
hive/api/TriggerPhase.hpp | TriggerPhase: Before=0 After=1 InsteadOf=2 Around=3 |
hive/api/Job.hpp + hive/api/cronq/JobConfig.hpp | Job(name, description, cron, enabled_by_default, run_once_when_missed) — run(JobConfig&) → string; JobConfig::get_string / get_int_or_default / get_sha256 |
hive/api/OperationResult.hpp | OperationResult {status, error}, macros ok_result, status_403_forbidden, status_405_unsupported_operation |
hive/api/AccessTokenContext.hpp | AccessTokenContext {user_id, msg, status, system} — ok(), ko(), is_system() |
hive/api/ModelCache.hpp | ModelCache — LRU + TTL cache: get, put, invalidate, clear, shrink_to, set_ttl_ms, set_capacity_size |
hive/model/ModelDefinition.hpp | ModelDefinition(name, plugin_name) — fluent builder: set_group, set_columns, set_rest_operations, set_title_column, set_virtual_table, set_cache_enabled, add_custom_*_action |
hive/model/ColumnDefinition.hpp | ColumnDefinition(name, flags) — set_default_value, set_description, set_enum_definition; all ColumnDefinitionFlag constants |
hive/model/EnumDefinition.hpp | EnumDefinition — binds enum values to display strings for API/UI rendering |
hive/essential/Crudl.hpp | Crudl enum: Create, Read, Update, Delete, List |
hive/essential/AccessMode.hpp | AccessMode (0–8): MaintenanceMode … PublicFullAccess + access matrix helpers |
hive/essential/UserRole.hpp | UserRole: Guest=0 Reader=1 Editor=2 Reviewer=3 Admin=4 SuperAdmin=5 System=100 |
hive/essential/DatabaseType.hpp | DatabaseType: Sqlite, PostgreSQL — multi-DB groundwork |
The project TODO tracks several substantial engineering directions — useful context before proposing larger changes:
Define IDatabase / IStatement / IMigration interfaces, refactor the SQLite layer behind them, then add PostgreSQL. The DatabaseType enum, db_* config keys, and CorePostgreSQLMigrationScripts already exist as groundwork.
Incrementally migrate from #include headers to C++ module units — faster builds, stronger encapsulation, cleaner dependency graph. Planned module-by-module, starting with leaf modules.
Emulate NOT NULL / UNIQUE / FOREIGN KEY / DEFAULT with SQLite triggers where ALTER TABLE can't add them — without invalidating applied-migration checksums.
A checklist-driven table-rebuild engine (create new table → copy → swap) for schema changes SQLite cannot express in place.