Getting Started
GreatEmbeddo generates a complete, self-contained C99 database for your embedded system from a
.gedb schema file. There is no runtime library to link — the generated code is the database.
Follow these four steps to integrate GreatEmbeddo into a project:
- Write a
.gedbschema describing your tables and indexes. - Run the
greatembeddoCLI to generate C99 source files. - Add the generated
.cand.hfiles to your build. - Implement the three I/O callbacks (read block, erase block, write block) for your storage.
Schema Example
A typical myproject.gedb file for an automotive ECU storing fault codes:
-- myproject.gedb
-- Schema for ECU fault log
CREATE TABLE ecu_config (
id uint32 PRIMARY KEY NOT NULL,
key char[32] NOT NULL,
value_i32 int32,
value_f float
) EXPECT 64 REPLICAS 1;
CREATE TABLE fault_log (
id uint64 PRIMARY KEY NOT NULL,
ts_ms uint64 NOT NULL,
dtc_code uint32 NOT NULL,
severity uint8 NOT NULL DEFAULT 0,
freeze_data uint8[16]
) EXPECT 4096 REPLICAS 2;
CREATE TABLE calibration (
param_id uint16 PRIMARY KEY NOT NULL,
table_id uint8 NOT NULL,
value float NOT NULL,
min_val float NOT NULL,
max_val float NOT NULL
) EXPECT 512 REPLICAS 1;
CREATE INDEX idx_fault_dtc ON fault_log (dtc_code);
CREATE INDEX idx_cal_table ON calibration (table_id);
QUERY get_faults_by_dtc
SELECT * FROM fault_log WHERE dtc_code = @dtc uint32 ORDER BY ts_ms DESC;CLI Usage
> greatembeddo <schema.gedb> [options]
Options:
-o, --output <dir> Output directory (default: current directory)
--block-size <bytes> Flash block size, power of 2 (required)
--program-page-size <bytes> NAND program-page size, power of 2 (default: 0 =
byte-granular writes for RAM/FRAM/MRAM)
--cache-size <bytes> RAM cache size in bytes (required)
--block-count <n> Total number of blocks on the flash device (required)
--metadata-replicas <n> Metadata replication factor (default: 3, max 4)
--lock <strategy> Writer-lock strategy: mtx (default), pthread, spin
--max-stack <bytes> Stack budget for the full call chain from any public
API entry point (default: 65536, min 256); each
query's sort buffer is derived to fit this limit
--stats Print storage statistics only, do not generate code
--memory Print static RAM breakdown only, do not generate code
--caveats Print performance caveats only, do not generate code
--yes / -y Auto-accept all performance caveats
--strict Abort (exit 1) if any caveats exist
--skip-lock-check Skip the .gedb.lock append-only check (last resort)
--rust Also make the output directory a Rust crate (see Rust Crate)
--quiet Suppress all output except errors
-h, --help Show this help message
-v, --version Print version and exit
Migration generation is available in the Pro version.
Example — generate code for a 4 MiB NOR flash with 4 KiB blocks:
> greatembeddo myproject.gedb \
--output src/generated/ \
--block-size 4096 \
--block-count 1024 \
--cache-size 65536
Check storage requirements before generating:
> greatembeddo myproject.gedb --stats --block-count 1024 --block-size 4096 --cache-size 65536
Schema version : 5
Tables : 3
Block size : 4096 bytes
Metadata blocks: 16
Table Rows Row bytes Blocks (est) Replicas
-----------------------------------------------------------------
ecu_config 64 52 2 1
fault_log 4096 40 165 2
calibration 512 22 5 1
-----------------------------------------------------------------
Total (estimated) 207Schema Reference
The .gedb schema language is a subset of SQL DDL extended with embedded-specific annotations.
All identifiers are case-insensitive. Keywords are uppercase by convention.
Column Types
| GEDB Type | C99 Type | Size | Notes |
|---|---|---|---|
uint8 | uint8_t | 1 byte | |
int8 | int8_t | 1 byte | |
uint16 | uint16_t | 2 bytes | |
int16 | int16_t | 2 bytes | |
uint32 | uint32_t | 4 bytes | |
int32 | int32_t | 4 bytes | |
uint64 | uint64_t | 8 bytes | |
int64 | int64_t | 8 bytes | |
float | float | 4 bytes | IEEE 754 |
double | double | 8 bytes | IEEE 754 |
bool | bool | 1 byte | |
char[N] | char[N] | N bytes | Fixed-length string |
T[N] | T[N] | sizeof(T)×N | Fixed-length array of any type |
varbinary(N) | uint8_t[N] + uint64_t | N + 8 bytes | Variable-length binary data; N is max capacity |
CREATE TABLE
CREATE TABLE <name> (
<col> <type> [PRIMARY KEY] [NOT NULL] [DEFAULT <value>]
[RANGE (<min>, <max>)]
[REFERENCES <table>(<col>) [ON DELETE RESTRICT|CASCADE|SET NULL]],
...
) [EXPECT <rows>] [REPLICAS <n>];
EXPECT n— hint for storage sizing; has no effect on correctnessREPLICAS n— number of redundant copies stored on flash (default 2 for tables, 1 for indexes; max 4)RANGE(min, max)— inserts/updates with an out-of-range value are rejected withGEDB_ERR_PARAM- Exactly one
PRIMARY KEYcolumn is required per table
CREATE INDEX / CREATE UNIQUE INDEX
CREATE INDEX <name> ON <table> (<col> [, <col> ...]);
CREATE UNIQUE INDEX <name> ON <table> (<col> [, <col> ...]);
-- When adding a UNIQUE index to an existing table (migration), ON CONFLICT is required:
CREATE UNIQUE INDEX <name> ON <table> (<col> [, ...]) ON CONFLICT REMOVE_DUPLICATES_REASSIGN_PRIMARY;
CREATE UNIQUE INDEX <name> ON <table> (<col> [, ...]) ON CONFLICT REMOVE_DUPLICATES_CASCADE_DELETE;
CREATE UNIQUE INDEX <name> ON <table> (<col> [, ...]) ON CONFLICT REMOVE_DUPLICATES_SET_NULL;
CREATE UNIQUE INDEX <name> ON <table> (<col> [, ...]) ON CONFLICT REMOVE_ALL_DUPLICATES_CASCADE_DELETE;
CREATE UNIQUE INDEX <name> ON <table> (<col> [, ...]) ON CONFLICT REMOVE_ALL_DUPLICATES_SET_NULL;
- Composite indexes are supported
- UNIQUE indexes enforce a uniqueness constraint at insert/update time
- A column declared
UNIQUEgets its own unique index,IX_<table>_<col>, unless a unique index on that column alone exists; a non-unique or composite index over the column does not stand in for it REMOVE_DUPLICATES_*— keep the row with the lowest primary key per duplicate group; delete the restREMOVE_ALL_DUPLICATES_*— delete every row in each duplicate group- The suffix says what happens to the rows that reference a deleted row through a foreign key, whatever their
ON DELETE:_REASSIGN_PRIMARYpoints them at the row that was kept,_SET_NULLsets the reference to NULL,_CASCADE_DELETEdeletes them (their own children then follow their ownON DELETE). A reference never dangles. - Refused at compile time:
_REASSIGN_PRIMARYor_SET_NULLwhen a UNIQUE constraint covers a referencing column,_SET_NULLwhen a referencing column is NOT NULL, and_CASCADE_DELETEwhen the deletion reaches anON DELETE RESTRICT
Schema Changes
Schema-changing commands (ALTER TABLE, DROP TABLE, DROP COLUMN,
DROP INDEX) are always permitted in the .gedb file and validated at
code-generation time. Applying them to an existing database on a deployed device requires the
Pro edition, which generates a migration function that upgrades the on-flash
data in place.
-- Add a column (default value required for existing rows)
ALTER TABLE <name> ADD <col> <type> [DEFAULT <value>] [NOT NULL] ...;
-- Change a column's type (ON NARROW required for narrowing conversions)
ALTER TABLE <name> ALTER <col> <new-type>;
ALTER TABLE <name> ALTER <col> <new-type> ON NARROW CLAMP;
ALTER TABLE <name> ALTER <col> <new-type> ON NARROW WRAP;
ALTER TABLE <name> ALTER <col> <new-type> ON NARROW NULL;
-- ... and ON CONFLICT when the change can merge values under a unique index
ALTER TABLE <name> ALTER <col> <new-type> ON NARROW CLAMP ON CONFLICT REMOVE_DUPLICATES_REASSIGN_PRIMARY;
ALTER TABLE <name> ALTER <col> <new-type> ON CONFLICT REMOVE_DUPLICATES_CASCADE_DELETE;
-- Update expected row count hint
ALTER TABLE <name> EXPECT <n>;
-- Change replication factor
ALTER TABLE <name> REPLICAS <n>;
DROP TABLE <name>;
ALTER TABLE <name> DROP COLUMN <column>;
DROP INDEX <name>;
Narrowing policies apply when the new type cannot represent all values of the old type
(e.g. int32 → int16, float → int32, and a signed type to an unsigned one of any
width, such as int8 → uint16, which cannot hold a negative value):
ON NARROW CLAMP— clamp values to the min/max of the new typeON NARROW WRAP— truncate via C cast (modular arithmetic)ON NARROW NULL— set out-of-range values to null (column must be nullable)
Widening conversions (e.g. int16 → int32, float → double)
do not require ON NARROW.
Shortening a non-char array (e.g. uint32[4] → uint32[2], or uint16[4] → uint32[2])
drops its trailing elements and does not require it either; an array whose element type narrows
(uint32[2] → uint16[2]) does, and each element follows the policy.
Primary key columns cannot be altered.
A single value and an array or char buffer convert through element 0: the value becomes element 0
and the other elements are zero (int8 → int8[2]), and an array becomes its element 0
(int8[2] → int8). ON NARROW is needed when element 0's conversion needs it
(int32 → char[4]), a char counting as a byte.
A varbinary and a single value convert through byte 0 the same way; an empty varbinary
becomes NULL, or 0 in a NOT NULL column.
Under a unique index, a change that can turn two distinct values into equal ones
makes duplicates the migration has to remove, so it also needs an ON CONFLICT policy
(the same REMOVE_DUPLICATES_* / REMOVE_ALL_DUPLICATES_* policies as above,
with the same compile-time refusals), unless the UNIQUE declares its own: every narrowing,
whatever its ON NARROW; a char buffer or array that shrinks; and
float → double, which turns a signaling NaN into the matching quiet NaN.
The ALTER's policy applies to that step only, to every unique index over the column,
composite ones included. A change that keeps values apart (an integer to one that holds all its values, a buffer grown)
takes no policy.
varbinary columns support capacity-only changes (varbinary(M) → varbinary(N));
changing to or from varbinary is not supported.
Data migrations (seeding & transformation)
Between schema statements you can interleave INSERT, UPDATE and
DELETE statements to seed or transform data as part of a migration. They run in
file order when a device upgrades, each operating on the schema defined by all
preceding statements — so a backfill UPDATE can target a column added by an
ALTER TABLE directly above it. Values must be literals
(no @parameters — those are only for QUERY definitions).
-- Backfill a newly added column on existing rows
ALTER TABLE Device ADD Region uint8 DEFAULT 0;
UPDATE Device SET Region = 1 WHERE Id < 1000;
-- Seed a row, prune rows
INSERT INTO Device (Id, Name) VALUES (9001, "gateway");
DELETE FROM Device WHERE Region = 0;
-- ON MISSING controls FK references whose parent row does not exist:
-- FAIL (abort the migration) | SKIP (drop this row) | NULL (null the FK column)
INSERT INTO Reading (Id, DeviceId, Value) VALUES (1, 9001, 0.0) ON MISSING SKIP;
Like other schema changes, applying data migrations to a deployed device requires the Pro edition.
QUERY
QUERY <name>
SELECT <cols> FROM <table> [WHERE ...] [ORDER BY ...] [LIMIT ...];
Named queries are compiled into typed C99 iterator functions. Parameters are declared with
@name type syntax (e.g. @id uint32) and become typed function arguments.
Integer overflow wraps in two's complement and a division by zero is NULL; neither is
an error. A query's _next returns GEDB_WARN_ARITHMETIC instead of
GEDB_OK for a row one of whose values wrapped or divided by zero, and returns the row
as usual, so a row loop continues on it:
while ((st = gedb_query_q_next(txn, &it, &row)) == GEDB_OK || st == GEDB_WARN_ARITHMETIC).
An overflow in the WHERE, ON, HAVING or ORDER BY key
that kept a row warns on that row; a row a clause dropped carries nothing. A CAST to an
integer type that wraps or saturates warns too. A compiled DML statement returns the warning after
its writes succeeded, including when it stores a value its integer column does not hold.
UNION and UNION ALL
A QUERY may combine two or more SELECT branches with
UNION ALL (keep every row) or UNION (drop duplicates). The branches
compile into a single iterator — there is no temporary table and no allocation; whatever the
shape needs is part of sizeof(gedb_query_..._iter_t) and fixed at code-generation
time. A trailing ORDER BY, LIMIT or OFFSET binds to the
whole compound, as in SQL, and all three are accepted on every UNION shape.
QUERY recent_or_critical
SELECT * FROM fault_log WHERE dtc_code = @dtc uint32
UNION ALL
SELECT * FROM fault_log WHERE severity >= @sev uint8
ORDER BY ts_ms DESC LIMIT 20;
When every branch reads the same table, the result row is the
projection its branches select — the columns named, in order, with a
<field>_is_null flag for a nullable one, exactly as a plain
SELECT of those columns yields. Two spellings keep that table's own
gedb_<table>_row_t instead: a lone SELECT *, which selects every
column and so already is the table row, and branch lists that differ from one
another — the projection is applied once, from the first branch's list, so a later branch's
row has no labelling it could be given. A * standing beside other
items is written out as the columns it stands for, in every branch at once, so such a
compound projects like any other. Branches that read different tables
project a result row of their own; see below.
What a same-table UNION deduplicates on is a separate question
from what it returns. When every branch selects the table's primary key, the branches merge in
primary-key order and a repeated key is dropped: within one table equal keys are the same row
and distinct keys differ in the tuple, so keying on the primary key is SQL's
deduplication on the selected tuple. When any branch omits the key, that equivalence breaks —
two rows can agree on every selected column and still have different keys, and
SELECT Cat FROM T UNION SELECT Cat FROM T must return one row per distinct
Cat, not one per id — so such a compound runs the same reconciled cursor the
cross-table shapes use: it sorts on the projected tuple and drops a repeat of it.
UNION ALL deduplicates nothing, and is unaffected either way.
UNION and UNION ALL have equal precedence and associate left
to right, so a compound that mixes them means what SQL says it means:
A UNION B UNION ALL C is (A UNION B) UNION ALL C — A and B are
deduplicated, then every row of C is appended, including rows that duplicate one already
emitted. A UNION ALL B UNION C is (A UNION ALL B) UNION C, where the
outermost junction deduplicates and the result has no duplicates at all. In general the
compound splits at its last deduplicating junction: everything left of it is one
deduplicated group, everything after it is appended untouched. Mixing junctions costs a
compound nothing else: it runs whichever cursor the same compound would run without the mix —
the same-table one, the reconciled one when the deduplicated group omits the primary key, or
the cross-table one — with each buffered row tagged by the branch that produced it.
How each shape runs
| Shape | Strategy | Working memory |
|---|---|---|
UNION ALL, no ORDER BY |
Streaming append — one branch is drained, then the next | One scan cursor |
UNION, no ORDER BY |
Multi-pass bounded dedup — merging the branches in primary-key order when they select that key, otherwise over the projected rows | One SORT_BUFFER |
either, with ORDER BY |
Two-phase bounded sort | One SORT_BUFFER per phase |
mixed junctions (A UNION B UNION ALL C) |
The same strategy as the rows above, with each buffered row tagged by the
branch that produced it so only the branches left of the last
UNION deduplicate |
One SORT_BUFFER (plus 4 bytes per buffered row) |
Only the streaming append is single-pass and O(1). The other two fill a bounded buffer and re-scan every branch on each pass, which the CLI reports as a performance caveat:
Query 'recent_or_critical' uses two-phase multi-pass UNION ALL with ORDER BY (buffer 2526 rows
per phase). Every branch is re-scanned on each outer pass. Consider an index whose leading
columns match the ORDER BY to reduce passes.
Branches over different tables
Branches may read different tables. Each branch scans its own table and projects into one common result row — a struct of its own rather than any table's row:
-- given: CREATE TABLE service_log (
-- id uint64 PRIMARY KEY NOT NULL, ts_ms uint64 NOT NULL, action_code uint32 NOT NULL);
QUERY event_timeline
SELECT ts_ms AS at_ms, dtc_code AS code FROM fault_log
UNION ALL
SELECT ts_ms, action_code FROM service_log;
/* generated result row */
typedef struct {
uint64_t at_ms;
uint32_t code;
} gedb_query_event_timeline_row_t;
- Explicit lists of equal length.
*is rejected across tables — it has no shape the branches can share. - Types match by position — type and array length, so
int32[4]andint32[2]do not match, and neither doint32anduint32. - Nullability may differ. A position nullable in any branch is
nullable in the result and gains a
<field>_is_nullflag; a branch whose own column isNOT NULLstoresfalseinto it. - Names come from the first branch — its
ASalias, else the column name. - Row order is the concatenation's own: branch by branch, and within a branch
in primary-key order. Rows that tie on an
ORDER BYkey keep that order. ORDER BYmust name a selected column. What gets sorted is the projection, and no table row survives to sort by — SQL requires the same of a compound query.
A position where any branch selects a computed item projects a synthetic scalar slot
instead — int64_t for general expressions, double for float-valued
ones, uint64_t for a bare uint64 column — and every branch's item at
that position, plain columns included, is evaluated into that slot. The branches must agree on
the slot's value domain, and aggregates, window functions and whole
array / char / varbinary values cannot enter one.
Cross-table branches that neither deduplicate nor sort stream in O(1), as
event_timeline above does. With either, the query runs a multi-pass bounded sort
over the projected rows — and since the sort key is that projected row, no index can
serve it. Appending ORDER BY at_ms DESC to the query above changes its caveat to:
Query 'event_timeline' uses multi-pass bounded UNION over the projected rows (buffer 7893 rows). Every branch
is re-scanned on each pass, and the sort key is the projected row — no index can serve it.
Consider UNION ALL without ORDER BY to stream instead.
A branch — the leading SELECT included — may also carry its own WHERE,
DISTINCT, GROUP BY with aggregates and its own HAVING, and
its own JOIN. A joined branch runs as an internal sub-cursor
that the UNION drives, and the whole sub-cursor counts against --max-stack: such a
query needs a budget several scan iterators larger than the equivalent scan-only UNION.
A branch may also carry a window function, which likewise runs as an internal
sub-cursor. The window is over the branch, not the UNION — SQL composes a compound query
from independent SELECTs — so each branch's ROW_NUMBER() counts its own
rows from 1, and since a branch carries no ORDER BY of its own, every row is a peer:
ROW_NUMBER() numbers arrival order and RANK() is 1 throughout.
A branch may carry a window function and its own JOIN — one sub-cursor does
both, and what it numbers is the joined pairs: an outer row matching three inner rows
contributes three separately numbered rows. The leading SELECT is no exception:
its join, WHERE and window call move into branch 0's plan, so it streams and
numbers its pairs exactly as a later branch does.
A branch may likewise carry SELECT DISTINCT over its own JOIN, in any
position. The dedup is the UNION's, not the branch's: under UNION the set
semantics already remove everything it could, and under UNION ALL the multi-pass
cursor clusters that branch's equal tuples — so the duplicates the join manufactured
within the branch go, while the same tuple in another branch survives.
Shapes that are reported instead
Every combination the bounded-memory generator cannot serve is reported at code-generation time, naming the branch and the clause responsible. Among them:
- Same-table branches with a computed select item must use identical select lists — the projection is applied once, from the first branch's list. A deduplicating compound that does not select the primary key is the exception: it runs the reconciled cursor, which evaluates each branch's own item into the slot, so its lists need only reconcile (equal length, agreeing value domain per position).
-
A branch carrying both a
JOINandGROUP BYruns underUNION ALLwithoutORDER BY; deduplicating or sorting that shape is reported. ROW_NUMBER/RANKover a deduplicating UNION of grouped queries — underUNION ALLeach branch numbers its own groups, but the duplicate test cannot know the number an earlier branch gave a group.-
A branch other than the last carrying its own
ORDER BY. A trailingORDER BYis not this case: it binds to the whole compound and is supported.
Limits & Capacity
All limits are compile-time constants derived from --block-size, --block-count,
and the schema. There is no dynamic memory allocation; every number below is fixed at code-generation time.
Flash Device
| Parameter | Minimum | Default | Maximum |
|---|---|---|---|
Block size (--block-size) | any power of 2 (blocks smaller than 4 KiB are bundled into 4 KiB logical blocks internally) | — (required) | 231 − 1 |
Block count (--block-count) | metadata reservation + 1 | — (required) | 263 − 1 |
Metadata replication (--metadata-replicas) | 1 | 3 | 4 |
| Metadata blocks reserved | A flat pool of 16 logical blocks (the superblock-blocks ring, spread across the device by the wear-leveling permutation). Independent of --metadata-replicas — commit redundancy comes from the ring's active-set width, not per-block replication. | ||
Metadata commits are appended within the current metadata replica group rather than overwriting it. The group is only erased when its slots fill up and the ring advances, reducing metadata erase pressure by roughly the slot count.
RAM (static, inside gedb_db_t)
| Component | Size |
|---|---|
Block cache (--cache-size) | Configurable; minimum one logical block. Usually the largest single component. |
| Tree traversal & compaction scratch | ≈ (max tree depth + 2) × logical block size. Scales with logical block size and tree depth. |
| Allocation & recovery bitmaps | ≈ 1 byte per logical block. Scales with the logical block count (= storage size ÷ logical block size). |
| Per-query sort buffers | Only for sorted / grouped / dedup queries; each holds up to ≈ (logical block size ÷ row size) rows. Reported per query by --memory. |
| Live superblock | ~96 B + 17 B × tables + 8 B × indexes + 8 B × FK indexes |
| Committed-snapshot copy (for lock-free readers) | Same as live superblock |
| Writer lock + query counter + slot index | ≤ 16 B |
There is no dynamic allocation — every component above is fixed at code-generation time and lives
inside gedb_db_t. Two knobs dominate the total: the cache and the
per-level traversal scratch grow with the logical block size, while the
bitmaps grow with the logical block count — so a large device divided into
many small blocks spends more on bitmaps, and few large blocks spend more on cache and scratch.
Run --memory to print the exact byte breakdown for a given configuration, or read
sizeof(gedb_db_t) at compile time.
With a 1 MiB --cache-size, gedb_db_t exceeds 1 MiB — too large for the stack.
Allocate it as a file-scope static or on the heap:
static gedb_db_t g_db; /* file-scope static — zero-initialised, safe on any platform */
gedb_db_t *db = malloc(sizeof(gedb_db_t)); /* heap — requires free() later */Schema Limits
| Item | Limit |
|---|---|
| Tables |
No hard cap; practical limit set by the metadata record fitting in one block:(BlockSize − 64 − ~96 B fixed) ÷ 17 B — e.g. ~954 on 16 KiB blocks
|
| Columns per table | No hard cap; row size must not exceed GEDB_MAX_INLINE_ROW or use overflow |
| Nullable columns | Null bitmap adds ⌈nullable_count ÷ 8⌉ bytes to each row |
| Secondary indexes per table | No hard cap; each consumes one 8-byte root slot in the superblock |
| FK columns per table | No hard cap; each consumes one 8-byte FK-root slot in the superblock |
Replicas per table (REPLICAS) | 1 – 4 |
| Composite index key width | 32 bytes (e.g. 4 × uint64_t, or 2 × char[16]) |
I/O Layer
The generated runtime never touches your storage medium directly. Every storage access goes
through three callbacks you provide to gedb_init: read, erase, and write.
What follows is the contract those callbacks must honour, with the rationale for each
clause — so you know which corners you can safely cut.
Callback contract
typedef gedb_status_t (*gedb_read_block_fn)(uint64_t block_id, uint32_t offset,
void *buf, uint32_t len, void *ctx);
typedef gedb_status_t (*gedb_erase_block_fn)(uint64_t block_id, void *ctx);
typedef gedb_status_t (*gedb_write_block_fn)(uint64_t block_id, uint32_t offset,
const void *data, uint32_t len, void *ctx);
block_id: 0-indexed block number, in[0, GEDB_BLOCK_COUNT).offset: byte offset within the block, in[0, GEDB_BLOCK_SIZE).len: byte count for the access.offset + len ≤ GEDB_BLOCK_SIZEalways holds.ctx: theio_ctxpointer you passed togedb_init. Use it to thread your driver state — the runtime treats it as opaque.- Return
GEDB_OK(zero) on success. Any non-zero return is treated as an I/O failure immediately — the runtime does not retry. If you want to absorb a transient glitch, retry inside your ownwrite_fn/erase_fn; that is where retry policy belongs. A failed write or erase retires the physical block on the first failure: it lands on the persistent bad-block list and the allocator skips it for the rest of the device's life. - Per-replica failures are tolerated. As long as at least one replica in a block group accepts the write or erase, the operation succeeds and the transaction commits. Surviving replicas serve future reads; the lagging replica is queued for repair and restored to full redundancy by the next
gedb_repair_stepcall. A transaction only fails when every replica of a block fails simultaneously — a real device-wide hardware fault, not a single bad block.
Under page-granular mode (GEDB_PROGRAM_PAGE_SIZE > 0), every
write_fn call is page-aligned and page-sized: offset % PAGE_SIZE == 0
and len % PAGE_SIZE == 0. Under byte-granular mode (the default for FRAM, MRAM,
or battery-backed SRAM), writes can be any size from 1 byte up to one full block.
Erase
The runtime calls erase_fn before reusing a block. What that needs to
do depends on your storage medium:
- File-backed, FRAM, MRAM, battery-backed SRAM — direct overwrite is
supported, so
erase_fncan just returnGEDB_OK. - NOR / NAND flash — must physically erase the block so the
subsequent
write_fnsees all0xFFcells. ReturnGEDB_OKonly after the erase has completed.
Write may verify with read-back
The runtime trusts write_fn's return value: GEDB_OK means the data
is durable enough that the next read_fn for the same
(block_id, offset, len) returns the same bytes. If your hardware needs
read-back verification to achieve that guarantee — for example, programming a NAND page
and reading it back to confirm bit settling — do it inside write_fn. The
runtime doesn't care how you achieve durability, only that the contract holds.
Conversely, fire-and-forget writes are fine if your medium reports failures synchronously.
The runtime's per-record CRC catches silent corruption on subsequent reads and queues the
block for repair (healed by the next gedb_repair_step call) regardless of
which strategy your write_fn uses.
Custom ECC and integrity
GreatEmbeddo already CRC-protects every record and replicates blocks per each table's
REPLICAS setting. If your medium needs additional protection —
NAND with mandatory parity bytes, raw flash with read-disturb at scale, exotic radiation-hard
memory — layer that protection underneath the callbacks. Store ECC bytes in a separate
spare area, fix correctable errors during read_fn, and surface uncorrectable
errors by returning a non-zero status.
The runtime treats your callback's view of the medium as the ground truth. As long as
write_fn(b, 0, data, BLOCK_SIZE) followed by read_fn(b, 0, buf, BLOCK_SIZE)
returns the same bytes, the runtime doesn't care what mechanism you use to achieve that
round-trip. ECC, wear-aware retry, partial-page caching, write-leveling — all invisible
above the callback boundary.
Concurrency is not your problem
The runtime serialises every callback invocation via an internal I/O lock. Under every lock
strategy (--lock mtx, --lock pthread,
--lock spin), the three callbacks are never invoked concurrently
— your driver does not need to be thread-safe. A single, plain, single-threaded driver
is sufficient even when the database is shared across many threads.
--lock spin (a CAS busy-wait spinlock) provides this without any OS thread
library, for bare-metal or RTOS targets.
The same guarantee covers concurrent gedb_transaction_read callers: every reader
serialises with the writer — and with every other reader — through the I/O lock at
the callback boundary, even though readers do not acquire the writer lock.
API Reference
All functions return gedb_status_t. Always check the return value.
Initialisation
gedb_db_t is a plain struct whose size is known at compile time, but it is too large for the stack (typically >1 MiB with a 1 MiB cache).
Allocate it as a file-scope static or on the heap, then pass a pointer to gedb_init.
gedb_init takes I/O callbacks and optional notification callbacks as individual parameters.
It never formats or erases the device on its own: when it finds no valid database (blank
flash, or a device formatted by a different database) it returns
GEDB_NEEDS_FORMATTING, and the application decides whether to call
gedb_format. It returns GEDB_ERR_SCHEMA if the flash was
formatted by different generated code.
static gedb_db_t db; /* file-scope static — do not declare gedb_db_t on the stack */
gedb_status_t st = gedb_init(&db,
my_flash_read, my_flash_erase, my_flash_write,
&flash_ctx, /* io_ctx — passed to every I/O callback */
NULL); /* corruption_fn — optional: announces detected damage, repairs and data loss */
if (st == GEDB_NEEDS_FORMATTING) {
/* No valid database found. Format with a caller-chosen non-zero 64-bit
database id (mixed into every record's CRC scope, so a later reformat
with a new id instantly invalidates old data — no erase pass needed). */
st = gedb_format(&db, my_flash_read, my_flash_erase, my_flash_write,
&flash_ctx, NULL, my_database_id);
}
if (st != GEDB_OK) { /* handle error */ }
With the Pro version, gedb_init automatically detects an older on-disk schema
version and applies generated migrations before returning. In the Free version,
GEDB_ERR_SCHEMA indicates a hard mismatch between the on-disk data and the generated code.
Transactions
GreatEmbeddo provides two transaction functions: gedb_transaction_write for
mutations and gedb_transaction_read for read-only queries. Both take a callback;
return GEDB_OK from the callback to commit (or complete), any other value to roll back.
The one exception is GEDB_WARN_ARITHMETIC, a compiled statement's warning that a value
it wrote or stored wrapped: gedb_transaction_write commits on it and returns it.
typedef gedb_status_t (*gedb_txn_fn)(gedb_txn_t *txn, void *user_ctx);
gedb_status_t gedb_transaction_write(gedb_db_t *db, gedb_txn_fn fn, void *user_ctx);
gedb_status_t gedb_transaction_read(gedb_db_t *db, gedb_txn_fn fn, void *user_ctx);
Write transactions
All writes must occur inside gedb_transaction_write.
At most one writer may be active at a time. With --lock mtx or --lock pthread,
callers block until the writer lock is available using the OS thread library.
With --lock spin, callers block by busy-waiting (CAS spinlock) — suitable
for bare-metal or RTOS environments where no OS thread library is available.
Nested write transactions are not supported. Calling
gedb_transaction_write from inside another gedb_transaction_write
callback on the same thread returns GEDB_ERR_REENTRANT under
--lock mtx and --lock pthread. Under --lock spin
there is no thread identity, so the same mistake deadlocks on the
writer-lock reacquire. Perform all further writes through the gedb_txn_t *
passed to your callback; do not open a new transaction from within one.
/* Example: write transaction */
static gedb_status_t do_insert(gedb_txn_t *txn, void *ctx) {
gedb_fault_log_row_t *row = (gedb_fault_log_row_t *)ctx;
return gedb_fault_log_insert(txn, row); /* non-GEDB_OK triggers rollback */
}
gedb_fault_log_row_t row = { .id = 1, .dtc_code = 0xC0DE, .ts_ms = now_ms() };
gedb_status_t r = gedb_transaction_write(&db, do_insert, &row);
if (r != GEDB_OK) { /* handle error — rollback already happened */ }
Batch inserts
To insert multiple rows atomically, call _insert in a loop inside a single
gedb_transaction_write callback. All rows are committed together; if any insert
fails, the entire batch is rolled back. This is also faster than inserting one-by-one because
the writer lock is acquired once and only one superblock-update record is committed.
/* Example: insert three rows atomically — all-or-nothing */
static gedb_status_t do_batch_insert(gedb_txn_t *txn, void *ctx) {
(void)ctx;
gedb_fault_log_row_t row;
memset(&row, 0, sizeof(row));
gedb_status_t st;
row.dtc_code = 0xC0DE; row.ts_ms = 1000;
st = gedb_fault_log_insert(txn, &row);
if (st != GEDB_OK) return st;
row.dtc_code = 0xBEEF; row.ts_ms = 2000;
st = gedb_fault_log_insert(txn, &row);
if (st != GEDB_OK) return st;
row.dtc_code = 0xCAFE; row.ts_ms = 3000;
st = gedb_fault_log_insert(txn, &row);
if (st != GEDB_OK) return st;
return GEDB_OK; // commit all three rows atomically
}
gedb_status_t r = gedb_transaction_write(&db, do_batch_insert, NULL);
if (r != GEDB_OK) { /* handle error — all three rows were rolled back */ }
Read transactions
gedb_transaction_read is lock-free with respect to the writer lock: it
never acquires the writer lock and never blocks other readers or writers. It captures a
consistent snapshot of the database at the moment the callback begins. All reads within the
callback — including multi-step scans — see this frozen snapshot. Concurrent
writes are invisible for the entire duration of the callback.
Flash access is protected by a separate internal IO lock. Under every lock
strategy (--lock mtx, --lock pthread, or --lock spin),
the IO lock is held for the duration of each callback, so the three I/O functions
(read_fn, erase_fn, write_fn) are never called
concurrently — callers may use a single non-thread-safe driver.
/* Example: read transaction with snapshot isolation */
static gedb_status_t do_scan(gedb_txn_t *txn, void *ctx) {
gedb_fault_log_iter_t iter;
gedb_fault_log_scan_open(txn, &iter);
gedb_fault_log_row_t row;
gedb_status_t st;
while ((st = gedb_fault_log_scan_next(txn, &iter, &row)) == GEDB_OK) {
/* process row — guaranteed consistent snapshot */
}
return st == GEDB_DONE ? GEDB_OK : st;
}
gedb_status_t r = gedb_transaction_read(&db, do_scan, NULL);
if (r != GEDB_OK) { /* handle error */ }Concurrency & Isolation
GreatEmbeddo is designed for concurrent access. Writers are serialised by an exclusive lock; readers are fully lock-free. The isolation guarantees depend on how you access the data:
| Context | Isolation level | Sees concurrent commits? |
|---|---|---|
gedb_transaction_read |
Snapshot | No — reads a frozen point-in-time copy of the database. No phantom reads, no skipped rows, no duplicates. |
gedb_transaction_write |
Serialised (exclusive writer) | No other writer can commit concurrently. Scans inside a write transaction see the transaction's own modifications (read-committed with respect to self). |
How it works
On every successful commit, the writer first flushes dirty block deltas or rare replacement blocks,
then appends one committed metadata snapshot and publishes that snapshot via a seqlock.
gedb_transaction_read copies the committed snapshot at the start of the callback and uses
it for the entire duration. Most small writes append directly into live leaf/internal blocks; when a
block fills, the writer falls back to a replacement-block path. An RCU-style epoch mechanism ensures
retired blocks are not reclaimed while a reader could still observe the older committed snapshot.
Scan iterators (scan_open / scan_next) are always used inside
one of the two transaction functions — they take the gedb_txn_t * that
gedb_transaction_read or gedb_transaction_write passes to your
callback, so the whole scan sees that transaction's single consistent snapshot.
CRUD Operations (generated per table)
All CRUD functions take the gedb_txn_t * passed to your transaction callback; call them inside gedb_transaction_write for writes or gedb_transaction_read for _get.
/* Insert a row. Returns GEDB_ERR_DUPLICATE if PK already exists. */
gedb_status_t gedb_fault_log_insert(gedb_txn_t *txn, const gedb_fault_log_row_t *row);
/* Get row by primary key. Returns GEDB_ERR_NOT_FOUND if absent. */
gedb_status_t gedb_fault_log_get(gedb_txn_t *txn, uint64_t id, gedb_fault_log_row_t *out_row);
/* Full replace of an existing row. Returns GEDB_ERR_NOT_FOUND if absent. */
gedb_status_t gedb_fault_log_update(gedb_txn_t *txn, const gedb_fault_log_row_t *row);
/* Delete a row by primary key. Returns GEDB_ERR_NOT_FOUND if absent. */
gedb_status_t gedb_fault_log_delete(gedb_txn_t *txn, uint64_t id);
/* Insert or replace (upsert). Always succeeds unless I/O error. */
gedb_status_t gedb_fault_log_upsert(gedb_txn_t *txn, const gedb_fault_log_row_t *row);Scanning
Full-table scans use an iterator pattern: open a cursor, call next in a loop.
No explicit close is needed (iterators are stack-allocated).
Scans run inside gedb_transaction_read for snapshot isolation, or
inside gedb_transaction_write when scanning as part of a mutation;
both pass a gedb_txn_t * to your callback.
See Concurrency & Isolation for details.
/* Inside a gedb_txn_fn callback that received gedb_txn_t *txn */
gedb_fault_log_iter_t iter;
gedb_status_t st = gedb_fault_log_scan_open(txn, &iter);
if (st != GEDB_OK) { /* handle error */ }
gedb_fault_log_row_t row;
while ((st = gedb_fault_log_scan_next(txn, &iter, &row)) == GEDB_OK) {
/* process row */
}
if (st != GEDB_DONE) { /* handle error */ }Corruption & Repair
Reads and writes never perform repair work — their latency stays
predictable. When an operation encounters a damaged block, a lagging replica, or a
corrupt secondary-index tree, it detects and queues the damage, announces it
through corruption_fn, and degrades gracefully: reads return what is
recoverable, writes that would need the damaged structure fail with
GEDB_ERR_CORRUPT_NEEDS_REPAIR.
A read announces its loss once, however many rows it had to skip:
a scan or query raises one GEDB_CORRUPTION_PARTIAL_RESULT (or
GEDB_CORRUPTION_DATA_LOSS) for its whole run. That holds for a query
over several tables too: a join, a subquery or a multi-pass sort that crosses damage
in two tables reports once and names the first damaged table it met. The other
damage is not lost: every damaged tree the query touched is queued for repair, and
repair announces each table's loss when it removes it.
All healing happens inside gedb_repair_step — your firmware must
call it, typically from a low-priority idle task. Each call first drains
queued damage (known-corrupt blocks, then one flagged index/FK rebuild), then advances
a bounded background sweep: block scrub, primary-tree walk, index verification. The
work per call is bounded, so it never stalls the idle loop.
gedb_repair_full loops the step to quiescence — use it after
corruption_fn fires or an operation returns
GEDB_ERR_CORRUPT_NEEDS_REPAIR, then retry the failed operation.
/* Idle task: one bounded unit of repair work per iteration. */
void idle_task(void) {
gedb_repair_step(&db);
}
/* After a corruption callback or GEDB_ERR_CORRUPT_NEEDS_REPAIR:
restore full redundancy, then retry the failed operation. */
gedb_status_t st = gedb_repair_full(&db);
Both functions run concurrently with readers and block on the writer lock like any
other write. They return GEDB_OK even when irrecoverable blocks had to be
freed — each loss is announced through corruption_fn as
GEDB_CORRUPTION_DATA_LOSS, never hidden in a return code. If the flash
keeps failing repair's own writes, gedb_repair_full ends early and
announces each damaged block it could not remove the same way, then returns
GEDB_OK. A later call removes those blocks once the flash works again,
without announcing them a second time. A database that
never calls gedb_repair_step keeps working, but detected damage stays
degraded: lagging replicas are not restored to full redundancy, and writes touching a
corrupt index keep failing until repair runs.
Rust Crate
--rust makes the output directory a Cargo crate as well: next to the C
files it writes Cargo.toml, build.rs and lib.rs.
Depend on it by path —
gedb = { path = "generated" } — and use it from hosted or bare-metal Rust
(1.81 or later).
let mut storage = DbStorage::boxed(); // gedb_db_t, zeroed, never moved
let mut flash = RamFlash::new(); // or your own impl gedb::Flash
let id = NonZeroU64::new(0x5EED).unwrap();
let db = Database::open_or_format(&mut storage, &mut flash, id)?;
db.write(|txn| {
let mut row = gedb::tables::SensorRow::default(); // schema DEFAULTs, else NULL / zero
row.set_id(1);
row.set_name(b"pump")?;
row.set_value(21.5);
txn.sensor_insert(&row) // Err rolls the transaction back
})?;
let ids = db.read(|txn| {
let mut iter = IterStorage::new(); // the C _iter_t, on the stack
txn.query_get_by_name(&mut iter, b"pump")?
.map(|row| row.map(|row| row.id()))
.collect::<Result<Vec<_>, Error>>()
})?;
- No bindgen.
build.rscompiles the generated C with thecccrate, the only dependency — the Pro build's migration files included, soDatabase::openmigrates just asgedb_initdoes. - Layout from the target's C compiler.
build.rswritesgedb_rust_layout.c, a table of every size, alignment and member offset the Rust side relies on, intoOUT_DIR(never beside the C), compiles it to an object file and reads it back — cross-compiling included.lib.rssizesgedb_db_tand the iterators from it and checks every row struct against it at compile time. - The API.
gedb::sysdeclares the C functions as generated. The safe layer has aFlashtrait for the I/O callbacks (and a defaulted corruption callback),readandwritetransactions as closures,<table>_get,_scan,_insert,_update,_upsert,_delete, and aquery_<name>per query: anIteratorof rows for a SELECT, a statement for DML._get_fingeredspeeds up a run of nearby keys through aPkFinger, andDatabasehas the_autofunctions, each in a transaction of its own. - Statuses. Negative ones are
Err(Error). A warning such asGEDB_WARN_ARITHMETICis aWarning, never an error: a statement returns it, a cursor reports it for its row throughwarning(). - Rows keep the C layout, with an accessor per column:
Option<T>for a nullable one,&[u8]forchar[N]and varbinary,&[T; N]for an array. A table row'sDefaultholds the schema's DEFAULTs, NULL or zero elsewhere. no_stdwithout the defaultstdfeature, with no allocation: theDbStoragegoes in astatic, each cursor'sIterStorageon the stack. Generate with--lock spinfor a target without<threads.h>.- Threads. Read transactions run lock-free beside each other and a
writer, so
read,writeand the_autofunctions take&selfandDatabaseisSendandSyncwhen its device isSend— withstd, or in a--lock spinbuild. A device callback that calls back into its database getsErr(Error::Reentrant).
Migration Recovery
With the Pro version, gedb_init() transparently
upgrades an older on-disk schema before returning. When a migration cannot complete, the
function returns an error and the device stays on its previous schema version.
Handling migration results in C
static gedb_db_t db; /* file-scope static — do not declare gedb_db_t on the stack */
gedb_status_t st = gedb_init(&db, my_read, my_erase, my_write, NULL, NULL);
switch (st) {
case GEDB_OK:
/* Database is open and at the latest schema version. */
break;
case GEDB_NEEDS_FORMATTING:
/* No valid database on the device (e.g. blank flash). Format it,
then it is ready at the latest schema version. */
st = gedb_format(&db, my_read, my_erase, my_write, NULL, NULL, my_database_id);
break;
case GEDB_ERR_SCHEMA:
/* On-disk schema is from a future or unrecognised version.
This device needs firmware that matches its schema. */
log_error("schema mismatch — cannot migrate");
break;
default:
/* I/O error during migration. The transaction was rolled back;
the database is still at the old schema version and intact.
Old firmware can still boot this device. */
log_error("migration failed: %d", st);
break;
}
Correcting a schema mistake
The .gedb file is append-only: once a line has been generated and the
.gedb.lock file is updated, that line cannot be edited.
To undo a mistake, append corrective statements below the problem line:
-- Mistake (already locked)
ALTER TABLE sensors ADD bad_col float;
-- Fix — append this
ALTER TABLE sensors DROP COLUMN bad_col;
The CLI generates a migration path that walks every intermediate version in order. Devices that already ran the broken migration drop the column; devices on older schema versions also pass through the bad intermediate step — adding then dropping the column — on their way to the latest version. Either way the device ends up at the correct final schema.
--skip-lock-check
As a last resort, you can bypass the append-only checksum chain with
--skip-lock-check. This lets you edit or remove locked lines and regenerate,
but all deployed devices must receive updated firmware — the old checksum
chain is no longer valid. The CLI prints a warning when this flag is active.
After using --skip-lock-check, delete the .gedb.lock file and
regenerate normally to establish a fresh checksum chain.