Skip to content
behemehalPublic

About

Local database solution with clean and strict data integrity.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

36 Commits

Folders and files

Repository files navigation

SafeEn

Crates.io Version Documentation

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_json is used only by the examples.
  • Portable. A database is a single file with a fixed little-endian layout.
  • no_std. Needs only alloc. Runs on bare metal and in the browser.
[dependencies]
safe_en = "2.0"

Quick start

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.

Queries

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)).

Other operations

# 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 index

Indexes 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.

Types

String, Char, I8, I64, U64, Bool, F32, F64, and arrays of any of them — including nested arrays. Columns are NOT NULL unless declared nullable.

Without a filesystem

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.

Constraints

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.

Timestamps

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.

Defaults and named inserts

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.

Durability

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.

Transactions

# 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.

File format

"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.

Upgrading from 1.x

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_where deleted the wrong rows and then panicked.
  • A rejected insert stored a malformed row anyway.
  • save returned () and discarded IO errors; it now returns a Result.
  • load panicked on damaged input; it now returns a LoadError.

Examples

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 back

From JavaScript

js/ 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/

JavaScript

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/js

There is a live demo — a small database browser that runs entirely in your own browser.

License

GPL-2.0. See LICENSE.md.

About

Local database solution with clean and strict data integrity.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages