A local database for situations that need strict data integrity and absolute portability. One file, typed columns, no dependencies.
- Typed schema. Every column declares a type and every value is checked against it before it is stored.
- Verified files. Each file carries a magic number, a format version and a CRC-32. Corruption is reported, never silently loaded.
- No dependencies. The library pulls in nothing;
serde_jsonis used only by the examples. - Portable. A database is a single file with a fixed little-endian layout.
no_std. Needs onlyalloc. Runs on bare metal and in the browser.
[dependencies]
safe_en = "2.0"use safe_en::{database, query, row};
let mut db = database! {
name: "shop",
orders {
id: I64,
customer: String,
city: String,
total: F64,
coupon: String?, // `?` makes the column nullable
items: [String], // brackets make it an array
}
};
let orders = db.table("orders").unwrap();
orders.insert(row![1_i64, "Ahmet", "İzmir", 249.90_f64, "WELCOME", vec!["keyboard"]]).unwrap();
orders.insert(row![2_i64, "Mehmet", "Ankara", 1899.00_f64, null, vec!["monitor"]]).unwrap();
for order in orders.get_where(query!(city == "İzmir" && total > 100.0)) {
println!("{}", order);
}
db.save("shop.sfn").unwrap();Everything the macros do can be written by hand — see
Database::create_table
and TableRow.
Column names are bare identifiers. &&, ||, ! and parentheses have their
normal Rust precedence.
use safe_en::query;
# use safe_en::{database, row};
# let mut db = database! { users { name: String, age: I64, city: String, nickname: String?, tags: [String] } };
# let users = db.table("users").unwrap();
# users.insert(row!["Ahmet", 21_i64, "İzmir", null, vec!["vip"]]).unwrap();
users.get_where(query!(age >= 18 && city == "İzmir"));
users.get_where(query!(age between 18, 30));
users.get_where(query!(city in ["İzmir", "Bursa"]));
users.get_where(query!(tags has "vip"));
users.get_where(query!(name starts "Ahm"));
users.get_where(query!(nickname is null));
users.get_where(query!(len(tags) > 0));| Operator | Meaning |
|---|---|
== != > >= < <= |
comparison; integers compare across widths |
between a, b |
inclusive range |
in [..] |
matches any of the listed values |
has v |
substring of a string, or element of an array |
starts / ends |
string prefix / suffix |
is null / not null |
nullable column tests |
len(col) / has(col) |
array length / column presence |
A compared-against value must be a single token — a literal, a variable, or a
parenthesized expression. Wrap anything else, including negative numbers, in
parentheses: query!(value == (-5)).
# use safe_en::{database, query, row};
# let mut db = database! { users { name: String, age: I64, city: String } };
# let t = db.table("users").unwrap();
# t.insert(row!["Ahmet", 21_i64, "İzmir"]).unwrap();
t.order_by("age", true).unwrap(); // sort ascending
t.sum("age").unwrap(); // sum, mean, min, max
t.distinct("city").unwrap(); // distinct values
t.count_where(query!(age > 18)); // count without materialising rows
t.first_where(query!(age > 18)); // stop at the first match
t.create_index("city").unwrap(); // O(1) equality lookups
t.get_by("city", "İzmir").unwrap(); // same result with or without an indexIndexes live in memory only. They are never written to disk and are dropped
when the table is mutated in a way that could move rows; get_by falls back to
a scan, so results never depend on whether an index exists.
String, Char, I8, I64, U64, Bool, F32, F64, and arrays of any of
them — including nested arrays. Columns are NOT NULL unless declared nullable.
The crate is no_std; the default std feature only adds save and load,
which are thin filesystem wrappers over to_bytes and from_bytes. Those two
are the real interface and are always available, so anywhere you have bytes you
have a database:
safe_en = { version = "2.0", default-features = false }# use safe_en::{database, row, Database};
# let mut db = database! { users { name: String } };
# db.table("users").unwrap().insert(row!["Ahmet"]).unwrap();
let bytes = db.to_bytes();
// ... flash, localStorage, IndexedDB, a socket, an SD card ...
let restored = Database::from_bytes(&bytes).unwrap();
# assert_eq!(restored, db);Verified in CI against thumbv7em-none-eabihf (bare metal, no OS) and
wasm32-unknown-unknown.
On the web, treat the stored bytes as untrusted. Local storage is editable
by the user, so from_bytes receives attacker-controlled input. It never panics
on malformed data — which matters most in WebAssembly, where a panic aborts the
whole module rather than unwinding. A test corrupts every byte of a database at
every offset, truncates it at every length, and throws a thousand checksum-valid
random payloads at the parser.
Note that the binary format is not a security measure. It is readable in DevTools in a few seconds, and the CRC-32 detects accidental corruption but is trivially recomputed, so it is not tamper-evidence. If you need that, verify an HMAC server-side; a key cannot be hidden in a WebAssembly bundle.
localStorage also stores UTF-16 strings rather than bytes, so it needs base64
(+33%, leaving roughly 3.7 MB of a 5 MB quota). IndexedDB takes a Uint8Array
directly and has far larger quotas.
Declared on the column and enforced on every write — insert, update, increment and push:
use safe_en::{database, row, table::Timestamp};
let mut db = database! {
users {
id: I64 primary, // unique and never null
email: String unique max_len(254),
age: I64 range(0_i64, 150_i64), // inclusive
bio: String? min_len(2),
tags: [String] max_len(5), // elements, not characters
joined: Timestamp,
}
};
assert_eq!(db.table("users").unwrap().primary_key(), Some("id"));| Modifier | Meaning |
|---|---|
primary |
unique and never null; at most one per table |
unique |
no two rows share a value |
range(lo, hi) / min(v) / max(v) |
numeric or timestamp bounds, inclusive |
min_len(n) / max_len(n) |
characters in a string, elements in an array |
Lengths count characters, not bytes — max_len(3) accepts "İzm". NULL
is exempt from every constraint, as in SQL, so several rows may leave a unique
nullable column empty. A constraint that cannot apply to its column's type is a
schema error, caught at create_table.
Timestamp stores milliseconds since the Unix epoch in UTC — 8 bytes, ordered
chronologically, rendered as ISO-8601. The crate does no calendar arithmetic;
that would mean a date dependency, and it has none.
# use safe_en::{database, row, table::Timestamp};
# let mut db = database! { events { at: Timestamp, label: String } };
db.table("events").unwrap()
.insert(row![Timestamp::from_millis(1_767_225_600_000), "new year"])
.unwrap();It is a distinct type from I64, so an arbitrary integer cannot land in a
timestamp column by accident. In JavaScript it accepts a Date and reads back
as an ISO-8601 string.
A column can declare a default, used when a named insert leaves it out. A default that does not fit its column is a schema error, caught when the table is created rather than at the first insert that relies on it.
use safe_en::{database, fields};
let mut db = database! {
users {
name: String,
visits: I64 = 0_i64,
active: Bool = true,
nickname: String?,
}
};
// `visits` and `active` take their defaults; `nickname` is null.
db.table("users").unwrap().insert_named(fields! { name: "Ahmet" }).unwrap();Positional insert still requires every column — omission only means something
when values are named.
save() is atomic — a reader sees the old file or the new one, never a mix —
but it is not durable. It rewrites the whole database, so it is too expensive to
call after every change, and anything since the last call lives only in memory.
Saving once on exit is exactly the pattern a crash defeats.
Durable keeps a snapshot and an append-only journal, so a write costs
O(change) instead of O(database):
use safe_en::storage::{FileStorage, SyncPolicy};
use safe_en::{columns, row, Durable, Filter};
let mut db = Durable::open(FileStorage::new("app.sfn"))?
.with_sync_policy(SyncPolicy::Always);
db.create_table("events", columns! { kind: String, weight: I64 })?;
db.insert("events", row!["read", 1_i64])?;
db.remove("events", &Filter::col("weight").gt(100))?;
db.compact()?; // fold the journal into a fresh snapshot
# Ok::<(), safe_en::DurableError>(())Reopening replays the journal over the snapshot. A process that died partway
through an append leaves a partial record, which is discarded — that is the
normal shape of a crash, and torn_tail() reports it. SyncPolicy is the
PRAGMA synchronous trade: Always survives a power cut, Every(n) risks the
last n-1 writes, Never survives a process crash but not a power cut.
Storage is a trait, not a file. FileStorage and MemoryStorage ship with the
crate; implement Storage for anything else — IndexedDB, flash, an object
store. A backend that can also append cheaply implements Journal and gets real
durability; one that cannot gets snapshot-on-write, which is the honest answer
for localStorage, where appending means rewriting the whole value.
# use safe_en::{fields, Durable, Filter};
# use safe_en::storage::MemoryStorage;
# let mut db = Durable::open(MemoryStorage::new()).unwrap();
# db.create_table("accounts", safe_en::columns! { name: String, balance: I64 }).unwrap();
db.transaction(|tx| {
tx.set("accounts", &Filter::col("name").eq("ahmet"), fields! { balance: 40_i64 })?;
tx.set("accounts", &Filter::col("name").eq("ayse"), fields! { balance: 60_i64 })?;
Ok(())
})?;
# Ok::<(), safe_en::DurableError>(())A transaction is written as a single journal record, so the length and checksum that already frame every record give atomicity for free — a crash partway through the append leaves a torn record that replay discards whole. There are no begin or commit markers to get out of step. Returning an error rolls the group back in memory and writes nothing.
Transactions are not isolated (there is one database and one writer) and do not nest.
"SFEN" magic, 4 bytes
u16 format version (currently 2), little endian
... payload: name, tables, columns, rows
u32 CRC-32 of the payload, little endian
Files written by SafeEn 1.x have no magic, version or checksum. They are detected and read automatically, so existing databases keep working; saving always writes the current version.
1.x had defects that could corrupt or lose data. See CHANGELOG.md for the full list and the API changes. The short version:
- Strings of 256 bytes or more were silently truncated on load. The writer was correct, so existing files hold intact data that 1.x could not read back — loading them with 2.0 recovers it.
remove_wheredeleted the wrong rows and then panicked.- A rejected
insertstored a malformed row anyway. savereturned()and discarded IO errors; it now returns aResult.loadpanicked on damaged input; it now returns aLoadError.
cargo run --example queries # the query surface, end to end
cargo run --example persistence # saving, loading, damaged files
cargo run --example durability # crash recovery; add --crash to kill it mid-write
cargo run --example save_big_data # build a database from JSON
cargo run --example load_big_data # read it backjs/ runs the same .wasm file unchanged in the
browser, Node, Deno and Bun — no bindgen, no bundler, no JS dependencies. The
module has zero imports because the whole persistence interface is
to_bytes / from_bytes.
cd js
cargo build --release --target wasm32-unknown-unknown
node js/node.mjs # Node (or: bun js/node.mjs)
deno run --allow-read --allow-write js/deno.ts # Deno, or --kv for Deno KV
python3 -m http.server # then open web/The same database compiled to WebAssembly. No bindgen, no bundler, no runtime
dependency; it runs unchanged in the browser, Node, Deno and Bun. It is
installed from GitHub rather than a registry, and the built module is committed,
so using it needs no Rust toolchain. See js/ for the full guide.
// Deno and the browser -- no install step.
import { create } from
"https://cdn.jsdelivr.net/gh/behemehal/SafeEn@v2.0.0/js/src/index.js";# Node and Bun -- from a release tarball, or a clone.
npm install https://github.com/behemehal/SafeEn/releases/download/v2.0.0/safe-en-2.0.0.tgz
git clone https://github.com/behemehal/SafeEn && npm install ./SafeEn/jsThere is a live demo — a small database browser that runs entirely in your own browser.
GPL-2.0. See LICENSE.md.